minmal-file-cloud-email-pim.../clients/desktop/src-tauri/src/cloud_files/windows.rs

494 lines
17 KiB
Rust

//! Windows Cloud Files API Integration.
//!
//! Registriert den Sync-Ordner als Sync-Root, legt Platzhalter-Dateien an
//! und reicht Zugriffe auf Dateidaten als HTTPS-Download durch. Der
//! Explorer zeigt Wolken-/Haken-Overlays automatisch an, solange die
//! Pin-Stati korrekt gesetzt sind.
//!
//! Voraussetzung: Windows 10 1709+ (cfapi.dll). Der Account-Identifier
//! sollte stabil sein (z.B. Hash(Server-URL + Username)).
#![cfg(windows)]
use super::RemoteEntry;
use once_cell::sync::Lazy;
use std::path::{Path, PathBuf};
use std::ptr;
use std::sync::{Arc, Mutex};
use widestring::U16CString;
use windows::core::PCWSTR;
use windows::Win32::Storage::CloudFilters as CF;
use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
#[derive(Default, Clone)]
pub struct CloudContext {
pub server_url: String,
pub access_token: String,
pub mount_point: PathBuf,
}
static CONTEXT: Lazy<Arc<Mutex<CloudContext>>> =
Lazy::new(|| Arc::new(Mutex::new(CloudContext::default())));
static CONNECTION_KEY: Lazy<Mutex<Option<CF::CF_CONNECTION_KEY>>> =
Lazy::new(|| Mutex::new(None));
pub fn set_context(server_url: String, access_token: String, mount_point: PathBuf) {
let mut ctx = CONTEXT.lock().unwrap();
ctx.server_url = server_url;
ctx.access_token = access_token;
ctx.mount_point = mount_point;
}
fn ctx_snapshot() -> CloudContext {
CONTEXT.lock().unwrap().clone()
}
const PROVIDER_VERSION: &str = "1.0";
// Windows-FILETIME: 100ns-Ticks seit 1601-01-01. Unix-Epoch liegt
// 11_644_473_600 Sekunden danach.
fn unix_to_ft_ticks(unix_secs: i64) -> i64 {
(unix_secs + 11_644_473_600) * 10_000_000
}
// ---------------------------------------------------------------------------
// Sync-Root-Registrierung
// ---------------------------------------------------------------------------
pub fn register_sync_root(
mount_point: &PathBuf,
provider_name: &str,
account_id: &str,
) -> Result<(), String> {
// COM initialisieren (cfapi benoetigt MTA-Apartment)
unsafe {
let _ = CoInitializeEx(Some(ptr::null()), COINIT_MULTITHREADED);
}
std::fs::create_dir_all(mount_point).map_err(|e| format!("mkdir: {e}"))?;
let display = format!("Mini-Cloud - {}", account_id);
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
.map_err(|e| format!("path encode: {e}"))?;
let display_wide = U16CString::from_str(&display).map_err(|e| e.to_string())?;
let provider_wide = U16CString::from_str(provider_name).map_err(|e| e.to_string())?;
let version_wide = U16CString::from_str(PROVIDER_VERSION).map_err(|e| e.to_string())?;
let mut info = CF::CF_SYNC_REGISTRATION::default();
info.StructSize = std::mem::size_of::<CF::CF_SYNC_REGISTRATION>() as u32;
info.ProviderName = PCWSTR(provider_wide.as_ptr());
info.ProviderVersion = PCWSTR(version_wide.as_ptr());
// Stabile GUID fuer "Mini-Cloud" (random einmalig generiert).
info.ProviderId = windows::core::GUID::from_u128(0x4D696E69_436C_6F75_6444_7566667944ab);
let mut policies = CF::CF_SYNC_POLICIES::default();
policies.StructSize = std::mem::size_of::<CF::CF_SYNC_POLICIES>() as u32;
policies.HardLink = CF::CF_HARDLINK_POLICY::default();
policies.Hydration = CF::CF_HYDRATION_POLICY::default();
policies.Population = CF::CF_POPULATION_POLICY::default();
policies.InSync = CF::CF_INSYNC_POLICY::default();
// Das Struct-Feld ist `CF_HYDRATION_POLICY` (u16-Wrapper um das
// _PRIMARY-Enum). Direkter Feldzugriff:
policies.Hydration.Primary = CF::CF_HYDRATION_POLICY_PARTIAL;
policies.Population.Primary = CF::CF_POPULATION_POLICY_PARTIAL;
// Holder fuer displayname, damit wir ihn spaeter ggf. in ein eigenes
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
let _ = display_wide;
// Erst versuchen ohne UPDATE-Flag (frische Registrierung). Bei "schon
// registriert"-Fehler nochmal mit UPDATE-Flag. So laeuft es beim ersten
// Aktivieren UND bei Folgestarts.
unsafe {
if let Err(e) = CF::CfRegisterSyncRoot(
PCWSTR(path_wide.as_ptr()),
&info,
&policies,
CF::CF_REGISTER_FLAG_NONE,
) {
let already = e.code().0 == 0x80070091u32 as i32 // DIR_NOT_EMPTY
|| e.code().0 == 0x800710D1u32 as i32; // Already registered
if already {
CF::CfRegisterSyncRoot(
PCWSTR(path_wide.as_ptr()),
&info,
&policies,
CF::CF_REGISTER_FLAG_UPDATE,
)
.map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?;
} else {
return Err(format!("CfRegisterSyncRoot: {e}"));
}
}
}
connect_callbacks(mount_point)?;
Ok(())
}
pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> {
disconnect_callbacks()?;
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
unsafe {
CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr()))
.map_err(|e| format!("CfUnregisterSyncRoot: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Callback-Tabelle
// ---------------------------------------------------------------------------
unsafe extern "system" fn on_fetch_data(
info: *const CF::CF_CALLBACK_INFO,
params: *const CF::CF_CALLBACK_PARAMETERS,
) {
let info = &*info;
let params = &*params;
let fetch = &params.Anonymous.FetchData;
// FileIdentity enthaelt unsere Mini-Cloud-File-ID als UTF-8-Bytes.
let identity = std::slice::from_raw_parts(
info.FileIdentity as *const u8,
info.FileIdentityLength as usize,
);
let file_id: i64 = std::str::from_utf8(identity)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let offset: i64 = fetch.RequiredFileOffset;
let length: u64 = fetch.RequiredLength as u64;
let connection_key = info.ConnectionKey;
let transfer_key = info.TransferKey;
// HTTPS-Download im separaten Thread (Callback darf nicht blockieren).
let ctx = ctx_snapshot();
std::thread::spawn(move || {
log_msg(&ctx.mount_point, &format!(
"FETCH_DATA file_id={file_id} offset={offset} len={length}"
));
match transfer_range(connection_key, transfer_key, file_id, offset, length, &ctx) {
Ok(()) => log_msg(&ctx.mount_point, &format!(
"fetch file_id={file_id} OK"
)),
Err(e) => {
log_err(&ctx.mount_point, &format!(
"fetch file_id={file_id} offset={offset} len={length} FAILED: {e}"
));
// Garantiert Fehler-Completion, damit Windows nicht in Timeout laeuft.
let _ = complete_transfer(connection_key, transfer_key, None, offset, length);
}
}
});
}
pub fn log_msg(mount: &Path, msg: &str) {
use std::io::Write;
// Log-Datei NEBEN den Mount, damit sie nicht selbst als Platzhalter
// behandelt wird.
let log = mount
.parent()
.map(|p| p.join(".minicloud-cloudfiles.log"))
.unwrap_or_else(|| PathBuf::from(".minicloud-cloudfiles.log"));
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
let _ = writeln!(f, "[{}] {}", chrono::Utc::now().to_rfc3339(), msg);
}
}
fn log_err(mount: &Path, msg: &str) {
log_msg(mount, msg);
}
/// True wenn die Datei ein cfapi-Platzhalter ist (noch nicht hydriert)
/// oder gerade vom Cloud-Filter verwaltet wird. Fuer solche Dateien
/// duerfen wir KEINEN Upload ausloesen, sonst verwandelt der Sync-Loop
/// jeden Platzhalter sofort in eine vollstaendig lokale Datei.
pub fn is_cfapi_placeholder(path: &Path) -> bool {
use windows::Win32::Storage::FileSystem::GetFileAttributesW;
let Ok(w) = U16CString::from_str(path.to_string_lossy().as_ref()) else {
return false;
};
let attrs = unsafe { GetFileAttributesW(PCWSTR(w.as_ptr())) };
if attrs == u32::MAX {
return false;
}
// FILE_ATTRIBUTE_OFFLINE (0x1000) oder
// FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS (0x400000) oder
// FILE_ATTRIBUTE_RECALL_ON_OPEN (0x40000)
(attrs & 0x0040_1000) != 0 || (attrs & 0x0004_0000) != 0
}
fn transfer_range(
connection_key: CF::CF_CONNECTION_KEY,
transfer_key: i64,
file_id: i64,
offset: i64,
length: u64,
ctx: &CloudContext,
) -> Result<(), String> {
if ctx.server_url.is_empty() || ctx.access_token.is_empty() {
return Err("CloudContext nicht gesetzt (Server/Token leer)".into());
}
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("client: {e}"))?;
let url = format!(
"{}/api/files/{}/download",
ctx.server_url.trim_end_matches('/'),
file_id
);
let range = format!("bytes={}-{}", offset, offset as u64 + length - 1);
let resp = client
.get(&url)
.bearer_auth(&ctx.access_token)
.header("Range", &range)
.send()
.map_err(|e| format!("send: {e}"))?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 206 {
return Err(format!("HTTP {}", status));
}
let bytes = resp.bytes().map_err(|e: reqwest::Error| e.to_string())?;
// Wenn Server kein Range unterstuetzt und volle Datei liefert,
// aus dem Body den angeforderten Bereich ausschneiden.
let slice: &[u8] = if status.as_u16() == 206 {
&bytes[..]
} else {
let start = offset as usize;
let end = (start + length as usize).min(bytes.len());
if start >= bytes.len() {
&[]
} else {
&bytes[start..end]
}
};
complete_transfer(connection_key, transfer_key, Some(slice), offset, slice.len() as u64)
}
fn complete_transfer(
connection_key: CF::CF_CONNECTION_KEY,
transfer_key: i64,
data: Option<&[u8]>,
offset: i64,
length: u64,
) -> Result<(), String> {
let mut op_info = CF::CF_OPERATION_INFO::default();
op_info.StructSize = std::mem::size_of::<CF::CF_OPERATION_INFO>() as u32;
op_info.Type = CF::CF_OPERATION_TYPE_TRANSFER_DATA;
op_info.ConnectionKey = connection_key;
op_info.TransferKey = transfer_key;
let mut params = CF::CF_OPERATION_PARAMETERS::default();
params.ParamSize = std::mem::size_of::<CF::CF_OPERATION_PARAMETERS>() as u32;
unsafe {
let transfer = &mut params.Anonymous.TransferData;
if let Some(data) = data {
transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0); // STATUS_SUCCESS
transfer.Buffer = data.as_ptr() as _;
transfer.Offset = offset;
transfer.Length = length as i64;
} else {
transfer.CompletionStatus =
windows::Win32::Foundation::NTSTATUS(0xC0000001u32 as i32); // STATUS_UNSUCCESSFUL
}
CF::CfExecute(&op_info, &mut params).map_err(|e| format!("CfExecute: {e}"))?;
}
Ok(())
}
fn connect_callbacks(mount_point: &Path) -> Result<(), String> {
let callbacks = [
CF::CF_CALLBACK_REGISTRATION {
Type: CF::CF_CALLBACK_TYPE_FETCH_DATA,
Callback: Some(on_fetch_data),
},
// Sentinel: Type = INVALID beendet die Tabelle.
CF::CF_CALLBACK_REGISTRATION {
Type: CF::CF_CALLBACK_TYPE_NONE,
Callback: None,
},
];
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
let key = unsafe {
CF::CfConnectSyncRoot(
PCWSTR(path_wide.as_ptr()),
callbacks.as_ptr(),
None,
CF::CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO
| CF::CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH,
)
.map_err(|e| format!("CfConnectSyncRoot: {e}"))?
};
*CONNECTION_KEY.lock().unwrap() = Some(key);
Ok(())
}
fn disconnect_callbacks() -> Result<(), String> {
if let Some(key) = CONNECTION_KEY.lock().unwrap().take() {
unsafe {
CF::CfDisconnectSyncRoot(key)
.map_err(|e| format!("CfDisconnectSyncRoot: {e}"))?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Placeholder-Erzeugung
// ---------------------------------------------------------------------------
pub fn populate_placeholders(
mount_point: &PathBuf,
entries: &[RemoteEntry],
) -> Result<(), String> {
use std::collections::HashMap;
let by_id: HashMap<i64, &RemoteEntry> = entries.iter().map(|e| (e.id, e)).collect();
fn rel_path<'a>(
entry: &'a RemoteEntry,
by_id: &HashMap<i64, &'a RemoteEntry>,
) -> PathBuf {
let mut parts = vec![entry.name.as_str()];
let mut cur = entry.parent_id;
while let Some(id) = cur {
if let Some(p) = by_id.get(&id) {
parts.push(p.name.as_str());
cur = p.parent_id;
} else {
break;
}
}
parts.reverse();
parts.iter().collect()
}
// Erst Ordner anlegen
for e in entries.iter().filter(|e| e.is_folder) {
let p = mount_point.join(rel_path(e, &by_id));
std::fs::create_dir_all(&p).ok();
}
// Dann Dateien als Platzhalter
for e in entries.iter().filter(|e| !e.is_folder) {
let rel = rel_path(e, &by_id);
let parent = rel
.parent()
.map(|p| mount_point.join(p))
.unwrap_or_else(|| mount_point.clone());
let identity = e.id.to_string();
if let Err(err) =
create_placeholder(&parent, &e.name, e.size, &e.modified_at, identity.as_bytes())
{
eprintln!("placeholder {}: {}", e.name, err);
}
}
Ok(())
}
pub fn create_placeholder_at(
parent_dir: &Path,
name: &str,
size: i64,
modified_iso: &str,
file_identity: &[u8],
) -> Result<(), String> {
create_placeholder(parent_dir, name, size, modified_iso, file_identity)
}
fn create_placeholder(
parent_dir: &Path,
name: &str,
size: i64,
modified_iso: &str,
file_identity: &[u8],
) -> Result<(), String> {
let parent_wide = U16CString::from_str(parent_dir.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
let name_wide = U16CString::from_str(name).map_err(|e| e.to_string())?;
let mtime_unix = chrono::DateTime::parse_from_rfc3339(modified_iso)
.map(|dt| dt.timestamp())
.unwrap_or(0);
let ft_ticks = unix_to_ft_ticks(mtime_unix);
let mut ph = CF::CF_PLACEHOLDER_CREATE_INFO::default();
ph.RelativeFileName = PCWSTR(name_wide.as_ptr());
ph.FsMetadata.FileSize = size;
ph.FsMetadata.BasicInfo.FileAttributes = FILE_ATTRIBUTE_NORMAL.0;
ph.FsMetadata.BasicInfo.LastWriteTime = ft_ticks;
ph.FsMetadata.BasicInfo.CreationTime = ft_ticks;
ph.FsMetadata.BasicInfo.ChangeTime = ft_ticks;
ph.FsMetadata.BasicInfo.LastAccessTime = ft_ticks;
ph.Flags = CF::CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC;
ph.FileIdentity = file_identity.as_ptr() as _;
ph.FileIdentityLength = file_identity.len() as u32;
// CfCreatePlaceholders nimmt in windows-rs 0.58 einen Slice und einen
// Option<*mut u32> fuer "wie viele wurden angelegt".
let mut phs = [ph];
let mut count: u32 = 0;
unsafe {
CF::CfCreatePlaceholders(
PCWSTR(parent_wide.as_ptr()),
&mut phs,
CF::CF_CREATE_FLAG_NONE,
Some(&mut count as *mut u32),
)
.map_err(|e| format!("CfCreatePlaceholders: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Pin / Unpin (offline halten)
// ---------------------------------------------------------------------------
pub fn set_pin_state(file: &Path, pinned: bool) -> Result<(), String> {
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES,
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
let path_wide = U16CString::from_str(file.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
let handle = unsafe {
CreateFileW(
PCWSTR(path_wide.as_ptr()),
FILE_READ_ATTRIBUTES.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS,
None,
)
}
.map_err(|e| format!("open: {e}"))?;
let state = if pinned {
CF::CF_PIN_STATE_PINNED
} else {
CF::CF_PIN_STATE_UNPINNED
};
let res = unsafe {
CF::CfSetPinState(handle, state, CF::CF_SET_PIN_FLAG_NONE, None)
};
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(handle);
}
res.map_err(|e| format!("CfSetPinState: {e}"))?;
Ok(())
}