diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e69ef0c..4cbc1c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: test: - # macOS: src-tauri only builds against macOS APIs (menu bar, FSEvents). + # macOS: the primary platform (menu bar, FSEvents, Apple Photos). runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -34,3 +34,39 @@ jobs: npm ci npx tsc --noEmit npx vite build + + test-windows: + # Windows: run the same test/lint gauntlet against the Windows code paths, + # then prove the MSI installer actually builds. + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Rust tests + run: cargo test --workspace + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Build MSI installer + # `tauri build` runs the frontend typecheck + build via beforeBuildCommand. + run: | + npm ci + npm run build:msi + + - uses: actions/upload-artifact@v4 + with: + name: FileFlow-msi + path: target/release/bundle/msi/*.msi + if-no-files-found: error diff --git a/README.md b/README.md index 93a19ee..565710b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- Platform: macOS + Platform: macOS | Windows Tauri 2 Rust 1.77+ React 19 @@ -11,8 +11,9 @@

- A quiet macOS menu-bar app that moves your files where they belong β€” automatically, and safely.
- Ingest a drive into dated folders, route one folder into another, or import straight into Apple Photos. + A quiet menu-bar / tray app that moves your files where they belong β€” automatically, and safely.
+ Ingest a drive into dated folders, route one folder into another, or import straight into Apple Photos.
+ Native on macOS, and on Windows via an MSI installer (Photos import is macOS-only).

@@ -36,7 +37,7 @@ safe by default. Three flows, set up once from the control panel and then left a |---|---|---| | πŸ’Ύ | **Drive β†’ folder** | Plug in a recognised external drive (SD card, USB stick, …) and FileFlow copies its files into a destination, organized by capture date, verifies every copy, then β€” optionally β€” wipes and ejects the drive. Moves any file type by default; filter by extension if you want. | | πŸ“ | **Folder β†’ folder** | Watch a folder; whatever lands in it is moved into a target folder, filtered by extension. | -| πŸ–ΌοΈ | **Folder β†’ Photos** | Point a watched folder (e.g. your Lightroom exports) at an Apple Photos album, and new files are imported automatically. | +| πŸ–ΌοΈ | **Folder β†’ Photos** | Point a watched folder (e.g. your Lightroom exports) at an Apple Photos album, and new files are imported automatically. *(macOS only.)* | A destination is **any writable path** β€” a local folder, a cloud-synced folder (OneDrive, iCloud Drive, Dropbox, Google Drive), a mounted network share (SMB/NFS, e.g. a TrueNAS @@ -54,6 +55,20 @@ Your files are irreplaceable, so the destructive path β€” wiping a drive after i ## Install +### Windows (MSI) + +Grab the latest `FileFlow_x.y.z_x64_en-US.msi` from the +[releases page](https://github.com/Arylmera/FileFlow/releases) and run it. The installer +takes care of everything, including fetching the WebView2 runtime on machines that don't +have it yet. The build is unsigned, so SmartScreen may warn on first run β€” click +**More info β–Έ Run anyway**. Upgrades install over the previous version (same MSI upgrade +code), keeping your config. + +On Windows, FileFlow lives in the **system tray**; the *Drive β†’ folder* and +*Folder β†’ folder* flows work exactly as on macOS. *Folder β†’ Photos* is macOS-only. + +### macOS + FileFlow is a personal utility, not an App Store download. The fastest way in β€” one command, nothing else to do: ```sh @@ -68,7 +83,9 @@ On first launch FileFlow lives in the **menu bar** (no Dock icon by default). Cl ## First-run setup -Depending on which flows you use, FileFlow needs one or two macOS permissions. It detects when either is missing and surfaces guidance in the window β€” you don't have to hunt for them blind. +On Windows no special permissions are needed β€” install and go. + +On macOS, depending on which flows you use, FileFlow needs one or two permissions. It detects when either is missing and surfaces guidance in the window β€” you don't have to hunt for them blind. | Permission | Why it's needed | Where to grant it | |---|---|---| @@ -90,7 +107,8 @@ Settings live in a single config file, managed entirely from the UI β€” see [Fil ## Build from source -**Prerequisites:** Rust β‰₯ 1.77, Node β‰₯ 18, Xcode Command Line Tools. +**Prerequisites:** Rust β‰₯ 1.77, Node β‰₯ 18. On macOS: Xcode Command Line Tools; on +Windows: the MSVC build tools (the Tauri bundler downloads WiX itself for the MSI). ```sh npm install @@ -101,8 +119,9 @@ cargo test -p fileflow-core # run the domain-logic tests Produce a release build: ```sh -npm run tauri build # β†’ target/release/bundle/macos/FileFlow.app +npm run tauri build # macOS β†’ target/release/bundle/macos/FileFlow.app npm run build:dmg # also build the installer .dmg (GUI session only β€” see below) +npm run build:msi # Windows β†’ target/release/bundle/msi/FileFlow_x.y.z_x64_en-US.msi ``` The default build produces the `.app` only, which works headlessly. The **`.dmg`** step @@ -150,15 +169,18 @@ it's inert for ad-hoc dev builds. ## Files & locations -| | Path | -|---|---| -| **Config** | `~/Library/Application Support/com.guillaumelemer.fileflow/config.toml` β€” managed from the UI (*Settings β–Έ Open config folder*). | -| **Logs** | `~/Library/Logs/com.guillaumelemer.fileflow/fileflow.log` β€” *Settings β–Έ Open log file*; verbosity via *Settings β–Έ Log level*. | +| | macOS | Windows | +|---|---|---| +| **Config** | `~/Library/Application Support/com.guillaumelemer.fileflow/config.toml` | `%APPDATA%\com.guillaumelemer.fileflow\config.toml` | +| **Logs** | `~/Library/Logs/com.guillaumelemer.fileflow/fileflow.log` | `%LOCALAPPDATA%\com.guillaumelemer.fileflow\logs\fileflow.log` | + +Both are managed from the UI β€” *Settings β–Έ Open config folder* / *Open log file*; +verbosity via *Settings β–Έ Log level*. -By default the app runs as a menu-bar agent (no Dock icon), reachable from the tray. -*Settings* toggles the Dock and menu-bar icons independently β€” at least one stays visible so -the window is always reachable. It launches at login when enabled and enforces a single -instance. +By default the app runs as a menu-bar agent (no Dock icon) on macOS, and from the system +tray on Windows. On macOS, *Settings* toggles the Dock and menu-bar icons independently β€” +at least one stays visible so the window is always reachable. It launches at login when +enabled and enforces a single instance. ## Architecture diff --git a/core/src/error.rs b/core/src/error.rs index 75a3f1c..90af687 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -29,6 +29,15 @@ pub enum Error { #[error("Photos automation not authorized β€” grant access in System Settings β–Έ Privacy & Security β–Έ Automation")] PhotosNotAuthorized, + /// A Photos rule ran on a platform without Apple Photos. Fails loudly so files + /// are never silently left behind. + #[error("Apple Photos import is only available on macOS")] + PhotosUnsupported, + + /// Eject requested on a platform with no eject backend (e.g. Linux dev builds). + #[error("ejecting drives is not supported on this platform")] + EjectUnsupported, + #[error("osascript error: {0}")] Osascript(String), diff --git a/core/src/ingest.rs b/core/src/ingest.rs index 5298089..502667a 100644 --- a/core/src/ingest.rs +++ b/core/src/ingest.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; use std::io::Read; use std::path::{Path, PathBuf}; +#[cfg(any(target_os = "macos", target_os = "windows"))] use std::process::Command; use walkdir::WalkDir; @@ -60,12 +61,19 @@ impl IngestReport { } } -/// Expand a leading `~` to `$HOME`. (ponytail: only `~`; add `${VAR}` if a config needs it.) +/// Expand a leading `~` to the user's home directory β€” `$HOME` on Unix, falling back +/// to `%USERPROFILE%` on Windows. (ponytail: only `~`; add `${VAR}` if a config needs it.) pub fn expand(path: &str) -> PathBuf { - if let Some(rest) = path.strip_prefix('~') { - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(format!("{home}{rest}")); - } + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok(); + expand_with_home(path, home.as_deref()) +} + +/// Pure body of [`expand`]: substitute a leading `~` with `home` (no-op when `None`). +pub fn expand_with_home(path: &str, home: Option<&str>) -> PathBuf { + if let (Some(rest), Some(home)) = (path.strip_prefix('~'), home) { + return PathBuf::from(format!("{home}{rest}")); } PathBuf::from(path) } @@ -414,10 +422,18 @@ pub fn cleanup(report: &IngestReport) -> Result { /// Unmount/eject the card. Caller decides *whether* (policy + confirmation) and only /// ever after a fully successful import. Eject failure is a warning, not a data risk. +/// +/// macOS ejects via `diskutil`; Windows via the Shell.Application COM verb (the same +/// path as Explorer's Eject). Other platforms have no backend and error explicitly. pub fn eject(volume_root: &Path, policy: EjectPolicy) -> Result { if policy == EjectPolicy::Never { return Ok(false); } + eject_backend(volume_root) +} + +#[cfg(target_os = "macos")] +fn eject_backend(volume_root: &Path) -> Result { let status = Command::new("diskutil").arg("eject").arg(volume_root).status()?; if status.success() { Ok(true) @@ -426,6 +442,30 @@ pub fn eject(volume_root: &Path, policy: EjectPolicy) -> Result { } } +#[cfg(target_os = "windows")] +fn eject_backend(volume_root: &Path) -> Result { + // `E:\` β†’ `E:` β€” the COM verb wants the bare drive letter. + let letter = volume_root + .to_string_lossy() + .trim_end_matches(['\\', '/']) + .to_string(); + let script = crate::windows::eject_script(&letter) + .ok_or_else(|| Error::EjectFailed(volume_root.to_path_buf()))?; + let status = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .status()?; + if status.success() { + Ok(true) + } else { + Err(Error::EjectFailed(volume_root.to_path_buf())) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn eject_backend(_volume_root: &Path) -> Result { + Err(Error::EjectUnsupported) +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/src/layout.rs b/core/src/layout.rs index 529401a..29f079f 100644 --- a/core/src/layout.rs +++ b/core/src/layout.rs @@ -20,12 +20,34 @@ fn substitute(template: &str, tokens: &[(&str, &str)]) -> String { /// naming form) can never climb out of the destination root β€” the result is always a /// relative path with no traversal components. pub fn render(template: &str, year: &str, date: &str, name: &str) -> String { + // On Windows the rendered segments must also be legal NTFS/exFAT names, or the + // path we verify against desyncs from the path the filesystem actually creates. + #[cfg(windows)] + return render_windows_safe(template, year, date, name); + #[cfg(not(windows))] + render_segments(template, year, date, name).join("/") +} + +/// Like [`render`], but every segment is passed through +/// [`crate::windows::sanitize_segment`] β€” what [`render`] does on Windows builds. +/// Exposed on all platforms so the behaviour is testable everywhere. +pub fn render_windows_safe(template: &str, year: &str, date: &str, name: &str) -> String { + render_segments(template, year, date, name) + .iter() + .map(|s| crate::windows::sanitize_segment(s)) + .filter(|s| !s.is_empty()) + .collect::>() + .join("/") +} + +/// Substitute tokens and split into cleaned segments (shared by both renderers). +fn render_segments(template: &str, year: &str, date: &str, name: &str) -> Vec { let raw = substitute(template, &[("{year}", year), ("{date}", date), ("{name}", name)]); raw.split('/') .map(str::trim) .filter(|s| !s.is_empty() && *s != "." && *s != "..") - .collect::>() - .join("/") + .map(str::to_string) + .collect() } /// Render a filename template like `{date}_{seq}` into a single filename. @@ -53,6 +75,12 @@ pub fn render_filename( // A bare "." or ".." would resolve to the dest dir or its parent once joined β€” never a // real filename. Fall back to the sequence, same as an empty render. let stem = if stem.is_empty() || stem == "." || stem == ".." { seq_str.as_str() } else { stem }; + // Same Windows-legality rule as folder segments in [`render`]. + #[cfg(windows)] + let stem = { + let s = crate::windows::sanitize_segment(stem); + if s.is_empty() { seq_str.clone() } else { s } + }; if ext.is_empty() { stem.to_string() } else { diff --git a/core/src/lib.rs b/core/src/lib.rs index 794831e..800d5a8 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -12,5 +12,6 @@ pub mod ingest; pub mod layout; pub mod photos; pub mod util; +pub mod windows; pub use error::{Error, Result}; diff --git a/core/src/photos.rs b/core/src/photos.rs index 7a8fa82..2a07f89 100644 --- a/core/src/photos.rs +++ b/core/src/photos.rs @@ -6,6 +6,7 @@ use crate::util::{ext_matches, is_hidden, move_file, NameFilter}; use crate::{layout, Error, Result}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] use std::process::Command; use std::time::Duration; @@ -162,6 +163,14 @@ pub fn album_groups( groups } +/// Apple Photos only exists on macOS; elsewhere an import fails loudly so a Photos +/// rule can never silently strand files (the caller surfaces the error). +#[cfg(not(target_os = "macos"))] +fn run_osascript(_script: &str) -> Result<()> { + Err(Error::PhotosUnsupported) +} + +#[cfg(target_os = "macos")] fn run_osascript(script: &str) -> Result<()> { let out = Command::new("osascript").arg("-e").arg(script).output()?; if !out.status.success() { diff --git a/core/src/windows.rs b/core/src/windows.rs new file mode 100644 index 0000000..f3176d2 --- /dev/null +++ b/core/src/windows.rs @@ -0,0 +1,132 @@ +//! Windows platform helpers β€” pure functions only, so they compile and test on +//! every platform (the shell runs the actual PowerShell/Explorer commands). +//! +//! Volume discovery parses `Get-CimInstance Win32_Volume … | ConvertTo-Json` rather +//! than scraping text, mirroring the macOS choice of `diskutil -plist`: the volume +//! GUID decides which card rule fires (and thus whether a card is wiped), so it must +//! come from a structured source. + +use serde::Deserialize; +use std::path::PathBuf; + +/// What kind of drive a volume sits on (from Win32_Volume's `DriveType`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriveKind { + /// SD cards, USB sticks (DriveType 2). + Removable, + /// Internal/fixed disks (DriveType 3). + Fixed, + /// Anything else (network, CD-ROM, RAM disk, unknown). + Other, +} + +/// One mounted (or letter-less) volume as reported by Win32_Volume. +#[derive(Debug, Clone)] +pub struct WinVolume { + /// `E:` β€” absent for letter-less volumes (recovery/EFI partitions). + pub drive_letter: Option, + /// The volume GUID from `DeviceID` (`\\?\Volume{GUID}\`), lowercased braces stripped. + pub uuid: Option, + pub label: Option, + pub kind: DriveKind, +} + +impl WinVolume { + /// The volume's mount root (`E:\`), if it has a drive letter. + pub fn mount_root(&self) -> Option { + self.drive_letter.as_ref().map(|l| PathBuf::from(format!("{l}\\"))) + } +} + +/// Raw JSON row β€” `Select-Object DriveLetter, DeviceID, Label, DriveType`. +#[derive(Deserialize)] +struct RawVolume { + #[serde(rename = "DriveLetter")] + drive_letter: Option, + #[serde(rename = "DeviceID")] + device_id: Option, + #[serde(rename = "Label")] + label: Option, + #[serde(rename = "DriveType")] + drive_type: Option, +} + +/// The PowerShell command whose output [`parse_win32_volumes`] understands. +pub const LIST_VOLUMES_COMMAND: &str = "Get-CimInstance -ClassName Win32_Volume | Select-Object DriveLetter, DeviceID, Label, DriveType | ConvertTo-Json -Compress"; + +/// Extract the GUID from a `\\?\Volume{GUID}\` device path. +fn guid_of(device_id: &str) -> Option { + let start = device_id.find('{')? + 1; + let end = device_id.find('}')?; + (start < end).then(|| device_id[start..end].to_ascii_lowercase()) +} + +/// Parse the JSON printed by [`LIST_VOLUMES_COMMAND`]. Accepts both a JSON array and +/// the bare object `ConvertTo-Json` emits when exactly one volume matches. +pub fn parse_win32_volumes(json: &str) -> Result, String> { + let raws: Vec = match serde_json::from_str::>(json) { + Ok(list) => list, + Err(_) => vec![serde_json::from_str::(json).map_err(|e| e.to_string())?], + }; + Ok(raws + .into_iter() + .map(|r| WinVolume { + uuid: r.device_id.as_deref().and_then(guid_of), + drive_letter: r.drive_letter.filter(|l| !l.is_empty()), + label: r.label.filter(|l| !l.is_empty()), + kind: match r.drive_type { + Some(2) => DriveKind::Removable, + Some(3) => DriveKind::Fixed, + _ => DriveKind::Other, + }, + }) + .collect()) +} + +/// PowerShell script that ejects the drive at `letter` (`E:`) via the Shell.Application +/// COM object β€” the same mechanism as Explorer's Eject menu item, so it flushes and +/// dismounts safely. Returns `None` unless `letter` is exactly one ASCII letter + `:`, +/// because the value is interpolated into the script (never inject free text). +pub fn eject_script(letter: &str) -> Option { + let bytes = letter.as_bytes(); + if bytes.len() != 2 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' { + return None; + } + Some(format!( + r#"(New-Object -ComObject Shell.Application).Namespace(17).ParseName("{letter}").InvokeVerb("Eject")"# + )) +} + +/// The single argument Explorer expects to open a folder with `path` selected. +pub fn explorer_select_arg(path: &str) -> String { + format!("/select,{path}") +} + +/// Names Windows reserves at any extension (CON.txt is as invalid as CON). +const RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +/// Make one path segment legal on Windows/NTFS/exFAT: +/// - `< > : " | ? * \` and control chars become `-` (the `/` separator never reaches +/// here β€” layout rendering splits on it first); +/// - trailing dots and spaces are trimmed (Windows strips them silently, which would +/// desync the path we verify against the path that actually exists); +/// - exact reserved device names (`CON`, `COM1`, …) get a `_` suffix. +pub fn sanitize_segment(segment: &str) -> String { + let cleaned: String = segment + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '|' | '?' | '*' | '\\' => '-', + c if (c as u32) < 0x20 => '-', + c => c, + }) + .collect(); + let cleaned = cleaned.trim_end_matches(['.', ' ']).to_string(); + if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(&cleaned)) { + format!("{cleaned}_") + } else { + cleaned + } +} diff --git a/core/tests/bundle_conf_tests.rs b/core/tests/bundle_conf_tests.rs new file mode 100644 index 0000000..ae994ca --- /dev/null +++ b/core/tests/bundle_conf_tests.rs @@ -0,0 +1,69 @@ +//! Guards on the Tauri bundle configuration for the Windows MSI installer. +//! +//! These live in `fileflow-core` (not `src-tauri`) deliberately: core's tests run on +//! every platform without the Tauri toolchain, so a broken installer config fails CI +//! everywhere β€” not only on the one job that actually builds the MSI. + +use serde_json::Value; +use std::path::Path; + +fn tauri_conf() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../src-tauri/tauri.conf.json"); + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + +fn package_json() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../package.json"); + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + +/// The MSI's WiX upgrade code is what lets a newer installer replace an older install +/// instead of appearing beside it. It must be an explicit, stable GUID β€” deriving it +/// implicitly (from productName) risks silently changing if the name ever does. +#[test] +fn msi_has_a_stable_wix_upgrade_code() { + let conf = tauri_conf(); + let code = conf["bundle"]["windows"]["wix"]["upgradeCode"] + .as_str() + .expect("bundle.windows.wix.upgradeCode must be set"); + let parts: Vec<&str> = code.split('-').collect(); + let lens: Vec = parts.iter().map(|p| p.len()).collect(); + assert_eq!(lens, [8, 4, 4, 4, 12], "upgradeCode must be a GUID, got {code}"); + assert!( + code.chars().all(|c| c.is_ascii_hexdigit() || c == '-'), + "upgradeCode must be hex, got {code}" + ); +} + +/// Users without WebView2 (fresh Windows 10 installs) need the installer to fetch it, +/// or FileFlow starts to a blank window. +#[test] +fn msi_installs_webview2_when_missing() { + let conf = tauri_conf(); + let mode = conf["bundle"]["windows"]["webviewInstallMode"]["type"] + .as_str() + .expect("bundle.windows.webviewInstallMode.type must be set"); + assert_eq!(mode, "downloadBootstrapper"); +} + +/// The Windows bundler requires an .ico icon; the config must keep listing one. +#[test] +fn bundle_icons_include_an_ico() { + let conf = tauri_conf(); + let icons = conf["bundle"]["icon"].as_array().expect("bundle.icon must be a list"); + assert!( + icons.iter().any(|i| i.as_str().is_some_and(|s| s.ends_with(".ico"))), + "bundle.icon must include a .ico for the Windows installer" + ); +} + +/// The MSI version comes from tauri.conf.json; keep it in lockstep with package.json +/// so a release bump can't ship two different versions. +#[test] +fn app_versions_agree() { + assert_eq!( + tauri_conf()["version"].as_str().unwrap(), + package_json()["version"].as_str().unwrap(), + "tauri.conf.json and package.json versions must match" + ); +} diff --git a/core/tests/platform_tests.rs b/core/tests/platform_tests.rs new file mode 100644 index 0000000..b112d1c --- /dev/null +++ b/core/tests/platform_tests.rs @@ -0,0 +1,189 @@ +//! Cross-platform behaviour: `~` expansion, Windows volume discovery (pure parsing of +//! PowerShell output), Windows eject/reveal command building, and Windows-safe names. +//! +//! Everything here is pure, so these tests run on every platform β€” the Windows code +//! paths are exercised without needing a Windows host. + +use fileflow_core::ingest::expand_with_home; +use fileflow_core::layout; +use fileflow_core::windows::{ + eject_script, explorer_select_arg, parse_win32_volumes, sanitize_segment, DriveKind, +}; + +// --- ~ expansion ----------------------------------------------------------- + +#[test] +fn expand_substitutes_home_on_unix_style_paths() { + assert_eq!( + expand_with_home("~/Pictures/Import", Some("/Users/g")), + std::path::PathBuf::from("/Users/g/Pictures/Import") + ); +} + +#[test] +fn expand_substitutes_home_on_windows_style_home() { + // Windows accepts both separators, so `~/…` under a `C:\` home stays valid. + assert_eq!( + expand_with_home("~/Pictures", Some(r"C:\Users\g")), + std::path::PathBuf::from(r"C:\Users\g/Pictures") + ); +} + +#[test] +fn expand_without_home_or_tilde_is_identity() { + assert_eq!(expand_with_home("~/x", None), std::path::PathBuf::from("~/x")); + assert_eq!( + expand_with_home(r"D:\Photos", Some(r"C:\Users\g")), + std::path::PathBuf::from(r"D:\Photos") + ); + assert_eq!(expand_with_home("/abs/path", Some("/home/g")), std::path::PathBuf::from("/abs/path")); +} + +// --- Win32_Volume JSON parsing ---------------------------------------------- + +// Real shape of `Get-CimInstance Win32_Volume | Select-Object … | ConvertTo-Json`. +const TWO_VOLUMES: &str = r#"[ + { + "DriveLetter": "C:", + "DeviceID": "\\\\?\\Volume{11111111-2222-3333-4444-555555555555}\\", + "Label": null, + "DriveType": 3 + }, + { + "DriveLetter": "E:", + "DeviceID": "\\\\?\\Volume{aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}\\", + "Label": "SONY_SD", + "DriveType": 2 + } +]"#; + +#[test] +fn parses_a_volume_list() { + let vols = parse_win32_volumes(TWO_VOLUMES).unwrap(); + assert_eq!(vols.len(), 2); + let sd = &vols[1]; + assert_eq!(sd.drive_letter.as_deref(), Some("E:")); + assert_eq!(sd.uuid.as_deref(), Some("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); + assert_eq!(sd.label.as_deref(), Some("SONY_SD")); + assert_eq!(sd.kind, DriveKind::Removable); + assert_eq!(sd.mount_root(), Some(std::path::PathBuf::from(r"E:\"))); +} + +#[test] +fn parses_a_single_volume_object() { + // PowerShell's ConvertTo-Json collapses a 1-element list to a bare object. + let one = r#"{ + "DriveLetter": "D:", + "DeviceID": "\\\\?\\Volume{aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}\\", + "Label": "USB", + "DriveType": 2 + }"#; + let vols = parse_win32_volumes(one).unwrap(); + assert_eq!(vols.len(), 1); + assert_eq!(vols[0].drive_letter.as_deref(), Some("D:")); +} + +#[test] +fn volume_without_a_letter_has_no_mount_root() { + // Recovery/EFI partitions surface with DriveLetter = null; they must be kept + // parseable but unmountable, never crashing the poller. + let json = r#"[{ "DriveLetter": null, "DeviceID": "\\\\?\\Volume{11111111-2222-3333-4444-555555555555}\\", "Label": null, "DriveType": 3 }]"#; + let vols = parse_win32_volumes(json).unwrap(); + assert_eq!(vols[0].drive_letter, None); + assert_eq!(vols[0].mount_root(), None); +} + +#[test] +fn garbage_or_empty_json_is_an_error_not_a_panic() { + assert!(parse_win32_volumes("not json").is_err()); + // An empty result set (no volumes) parses to an empty list. + assert_eq!(parse_win32_volumes("[]").unwrap().len(), 0); +} + +#[test] +fn unknown_drive_type_maps_to_other() { + let json = r#"[{ "DriveLetter": "Z:", "DeviceID": "\\\\?\\Volume{11111111-2222-3333-4444-555555555555}\\", "Label": "NET", "DriveType": 4 }]"#; + assert_eq!(parse_win32_volumes(json).unwrap()[0].kind, DriveKind::Other); +} + +// --- eject / reveal command building ---------------------------------------- + +#[test] +fn eject_script_targets_the_drive_letter() { + let script = eject_script("E:").unwrap(); + assert!(script.contains(r#"ParseName("E:")"#), "script: {script}"); + assert!(script.contains("InvokeVerb"), "script: {script}"); +} + +#[test] +fn eject_script_rejects_anything_but_a_drive_letter() { + // The letter is interpolated into a PowerShell string β€” never accept free text. + assert!(eject_script("E").is_none()); + assert!(eject_script(r"E:\").is_none()); + assert!(eject_script("EE:").is_none()); + assert!(eject_script("\"; Remove-Item -Recurse C:\\").is_none()); +} + +#[test] +fn explorer_select_arg_wraps_the_path() { + assert_eq!(explorer_select_arg(r"C:\Users\g\Pictures\a.jpg"), r"/select,C:\Users\g\Pictures\a.jpg"); +} + +// --- Windows-safe folder / file names ---------------------------------------- + +#[test] +fn sanitize_replaces_windows_invalid_characters() { + assert_eq!(sanitize_segment("Trip: day 1?"), "Trip- day 1-"); + assert_eq!(sanitize_segment(r#"ac"d|e*f"#), "a-b-c-d-e-f"); + assert_eq!(sanitize_segment("plain name"), "plain name"); +} + +#[test] +fn sanitize_trims_trailing_dots_and_spaces() { + // Windows silently strips trailing dots/spaces, which would desync verify paths. + assert_eq!(sanitize_segment("name. "), "name"); + assert_eq!(sanitize_segment("name..."), "name"); +} + +#[test] +fn sanitize_guards_reserved_device_names() { + assert_eq!(sanitize_segment("CON"), "CON_"); + assert_eq!(sanitize_segment("aux"), "aux_"); + assert_eq!(sanitize_segment("COM1"), "COM1_"); + assert_eq!(sanitize_segment("console"), "console"); // only exact names are reserved +} + +#[test] +fn layout_render_windows_safe_keeps_separators_but_cleans_segments() { + // The `/` between template segments must survive; invalid chars inside a + // segment (here the user-typed name) must not. + let got = layout::render_windows_safe("{year}/{date} {name}", "2026", "2026-06-20", "Trip: A*"); + assert_eq!(got, "2026/2026-06-20 Trip- A-"); +} + +// --- platform gating of the two OS-integration points ------------------------- + +/// Off macOS, a Photos import must fail loudly (never silently drop files). +#[cfg(not(target_os = "macos"))] +#[test] +fn photos_import_is_unsupported_off_macos() { + use fileflow_core::photos::{import_to_photos, AlbumTarget}; + let err = import_to_photos( + &[std::path::PathBuf::from("x.jpg")], + &AlbumTarget::Library, + true, + ) + .unwrap_err(); + assert!(matches!(err, fileflow_core::Error::PhotosUnsupported)); +} + +/// On a platform with no eject backend (e.g. Linux dev builds), an explicit eject +/// request errors instead of pretending to succeed. Policy `Never` stays a no-op. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[test] +fn eject_is_unsupported_where_theres_no_backend() { + use fileflow_core::config::EjectPolicy; + use fileflow_core::ingest::eject; + assert!(!eject(std::path::Path::new("/tmp"), EjectPolicy::Never).unwrap()); + assert!(eject(std::path::Path::new("/tmp"), EjectPolicy::Always).is_err()); +} diff --git a/package.json b/package.json index 9721d3b..3f35245 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "build:dmg": "tauri build --bundles app,dmg" + "build:dmg": "tauri build --bundles app,dmg", + "build:msi": "tauri build --bundles msi" }, "dependencies": { "react": "^19.1.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2e9ace3..2fe0c38 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,6 @@ tauri-plugin-notification = "2" tauri-plugin-dialog = "2" fileflow-core = { path = "../core" } notify = "6" -plist = "1" chrono.workspace = true tracing = "0.1" tracing-subscriber = "0.3" @@ -29,5 +28,9 @@ tracing-appender = "0.2" serde.workspace = true serde_json.workspace = true +# diskutil plist parsing only exists on the macOS volume path. +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1" + [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e3b781e..76d8ed2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -173,16 +173,39 @@ pub fn set_paused(state: State, paused: bool) { state.set_paused(paused); } -/// Reveal a path in Finder (`~` expanded). Used by Settings' "Open config". +/// Reveal a path in the platform file manager (`~` expanded) β€” Finder on macOS, +/// Explorer on Windows. Used by Settings' "Open config". (The command keeps its +/// historical name: it's the wire key the frontend invokes.) #[tauri::command] pub fn reveal_in_finder(path: String) -> Result<(), String> { let p = ingest::expand(&path); - std::process::Command::new("open") - .arg("-R") - .arg(&p) + reveal(&p).map(|_| ()).map_err(|e| e.to_string()) +} + +#[cfg(target_os = "macos")] +fn reveal(p: &std::path::Path) -> std::io::Result { + std::process::Command::new("open").arg("-R").arg(p).spawn() +} + +#[cfg(target_os = "windows")] +fn reveal(p: &std::path::Path) -> std::io::Result { + std::process::Command::new("explorer") + .arg(fileflow_core::windows::explorer_select_arg(&p.to_string_lossy())) .spawn() - .map(|_| ()) - .map_err(|e| e.to_string()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn reveal(p: &std::path::Path) -> std::io::Result { + // No "select" verb portable across Linux file managers; open the containing dir. + let dir = p.parent().unwrap_or(p); + std::process::Command::new("xdg-open").arg(dir).spawn() +} + +/// The OS the shell was built for (`macos` | `windows` | `linux`), so the UI can hide +/// platform-only flows (Apple Photos) and use the right file-manager wording. +#[tauri::command] +pub fn get_platform() -> &'static str { + std::env::consts::OS } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09c5941..fcb2b7d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,13 +54,19 @@ pub fn run() { app.set_activation_policy(tauri::ActivationPolicy::Accessory); let show = MenuItem::with_id(app, "show", "Open FileFlow", true, None::<&str>)?; - let import_lr = - MenuItem::with_id(app, "import_lr", "Import to Photos now", true, None::<&str>)?; let pause = MenuItem::with_id(app, "toggle_pause", "Pause / Resume watchers", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let sep = PredefinedMenuItem::separator(app)?; - let menu = Menu::with_items(app, &[&show, &import_lr, &pause, &sep, &quit])?; + // Apple Photos only exists on macOS; don't offer the import elsewhere. + #[cfg(target_os = "macos")] + let menu = { + let import_lr = + MenuItem::with_id(app, "import_lr", "Import to Photos now", true, None::<&str>)?; + Menu::with_items(app, &[&show, &import_lr, &pause, &sep, &quit])? + }; + #[cfg(not(target_os = "macos"))] + let menu = Menu::with_items(app, &[&show, &pause, &sep, &quit])?; TrayIconBuilder::with_id("main-tray") .icon(tauri::include_image!("icons/tray.png")) @@ -167,6 +173,7 @@ pub fn run() { commands::set_paused, commands::reveal_in_finder, commands::log_path, + commands::get_platform, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/volume.rs b/src-tauri/src/volume.rs index da7697b..fbb9071 100644 --- a/src-tauri/src/volume.rs +++ b/src-tauri/src/volume.rs @@ -1,56 +1,138 @@ -//! macOS volume helpers β€” listing mounts and resolving a volume UUID. +//! Volume helpers β€” listing mounted drives and resolving a volume UUID, per platform. //! //! UUID resolution matters: it's the key that decides which card rule fires (and thus -//! where files go and whether the card is wiped). We parse `diskutil`'s plist output -//! rather than scraping text, so a macOS format tweak can't silently mis-match a card. - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// Top-level entries under `/Volumes` that are directories (mounted volumes). -pub fn mounted_volumes() -> HashSet { - let mut out = HashSet::new(); - if let Ok(rd) = std::fs::read_dir("/Volumes") { - for e in rd.flatten() { - let p = e.path(); - // `/Volumes` entries are mount points (incl. a symlink to the boot volume). - if p.is_dir() { - out.insert(p); +//! where files go and whether the card is wiped). Both platforms therefore parse +//! *structured* output β€” `diskutil`'s plist on macOS, `Win32_Volume` JSON on Windows β€” +//! so an OS format tweak can't silently mis-match a card. + +pub use imp::{find_volume_by_uuid, is_boot_volume, mounted_volumes, volume_uuid}; + +#[cfg(target_os = "macos")] +mod imp { + use std::collections::HashSet; + use std::path::{Path, PathBuf}; + use std::process::Command; + + /// Top-level entries under `/Volumes` that are directories (mounted volumes). + pub fn mounted_volumes() -> HashSet { + let mut out = HashSet::new(); + if let Ok(rd) = std::fs::read_dir("/Volumes") { + for e in rd.flatten() { + let p = e.path(); + // `/Volumes` entries are mount points (incl. a symlink to the boot volume). + if p.is_dir() { + out.insert(p); + } } } + out + } + + /// True if `path` resolves to the boot volume (`/`). The boot disk surfaces in + /// `/Volumes` as a symlink, so we canonicalize before comparing. Never eject it. + pub fn is_boot_volume(path: &Path) -> bool { + std::fs::canonicalize(path).map(|p| p == Path::new("/")).unwrap_or(false) + } + + /// Find a currently-mounted volume whose UUID matches `uuid` (case-insensitive). + pub fn find_volume_by_uuid(uuid: &str) -> Option { + mounted_volumes() + .into_iter() + .find(|p| volume_uuid(p).map(|u| u.eq_ignore_ascii_case(uuid)).unwrap_or(false)) } - out -} -/// True if `path` resolves to the boot volume (`/`). The boot disk surfaces in -/// `/Volumes` as a symlink, so we canonicalize before comparing. Never eject it. -pub fn is_boot_volume(path: &Path) -> bool { - std::fs::canonicalize(path).map(|p| p == Path::new("/")).unwrap_or(false) + /// Resolve the volume UUID for a mount path via `diskutil info -plist`. + pub fn volume_uuid(path: &Path) -> Option { + let out = Command::new("diskutil") + .arg("info") + .arg("-plist") + .arg(path) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let value: plist::Value = plist::from_bytes(&out.stdout).ok()?; + value + .as_dictionary()? + .get("VolumeUUID")? + .as_string() + .map(|s| s.to_string()) + } } -/// Find a currently-mounted volume whose UUID matches `uuid` (case-insensitive). -pub fn find_volume_by_uuid(uuid: &str) -> Option { - mounted_volumes() - .into_iter() - .find(|p| volume_uuid(p).map(|u| u.eq_ignore_ascii_case(uuid)).unwrap_or(false)) +#[cfg(target_os = "windows")] +mod imp { + use fileflow_core::windows::{parse_win32_volumes, WinVolume, LIST_VOLUMES_COMMAND}; + use std::collections::HashSet; + use std::os::windows::process::CommandExt; + use std::path::{Path, PathBuf}; + use std::process::Command; + + /// Don't flash a console window when a GUI app spawns PowerShell. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + /// All lettered volumes from `Win32_Volume` (letter-less recovery/EFI partitions + /// are dropped β€” they can't hold a card's files). The parsing itself is pure and + /// unit-tested in `fileflow-core`. + fn list_volumes() -> Vec { + let out = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", LIST_VOLUMES_COMMAND]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + let Ok(out) = out else { return Vec::new() }; + if !out.status.success() { + return Vec::new(); + } + parse_win32_volumes(&String::from_utf8_lossy(&out.stdout)).unwrap_or_default() + } + + /// Mount roots (`E:\`) of every lettered volume β€” the Windows analogue of `/Volumes`. + pub fn mounted_volumes() -> HashSet { + list_volumes().iter().filter_map(WinVolume::mount_root).collect() + } + + /// True for the Windows system drive (`%SystemDrive%`, normally `C:`). Never eject it. + pub fn is_boot_volume(path: &Path) -> bool { + let sys = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".into()); + let p = path.to_string_lossy(); + p.trim_end_matches(['\\', '/']).eq_ignore_ascii_case(&sys) + } + + /// Find a currently-mounted volume whose GUID matches `uuid` (case-insensitive). + pub fn find_volume_by_uuid(uuid: &str) -> Option { + list_volumes() + .iter() + .find(|v| v.uuid.as_deref().is_some_and(|u| u.eq_ignore_ascii_case(uuid))) + .and_then(WinVolume::mount_root) + } + + /// The volume GUID for a mount path (`E:\` β†’ the `\\?\Volume{GUID}\` GUID). + pub fn volume_uuid(path: &Path) -> Option { + let want = path.to_string_lossy().trim_end_matches(['\\', '/']).to_ascii_uppercase(); + list_volumes() + .iter() + .find(|v| v.drive_letter.as_deref().is_some_and(|l| l.eq_ignore_ascii_case(&want))) + .and_then(|v| v.uuid.clone()) + } } -/// Resolve the volume UUID for a mount path via `diskutil info -plist`. -pub fn volume_uuid(path: &Path) -> Option { - let out = Command::new("diskutil") - .arg("info") - .arg("-plist") - .arg(path) - .output() - .ok()?; - if !out.status.success() { - return None; - } - let value: plist::Value = plist::from_bytes(&out.stdout).ok()?; - value - .as_dictionary()? - .get("VolumeUUID")? - .as_string() - .map(|s| s.to_string()) +// Stubs so dev builds on other platforms (e.g. Linux) still compile; no drives surface. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +mod imp { + use std::collections::HashSet; + use std::path::{Path, PathBuf}; + + pub fn mounted_volumes() -> HashSet { + HashSet::new() + } + pub fn is_boot_volume(_path: &Path) -> bool { + false + } + pub fn find_volume_by_uuid(_uuid: &str) -> Option { + None + } + pub fn volume_uuid(_path: &Path) -> Option { + None + } } diff --git a/src-tauri/src/watchers.rs b/src-tauri/src/watchers.rs index 3d0af56..61b4e2e 100644 --- a/src-tauri/src/watchers.rs +++ b/src-tauri/src/watchers.rs @@ -21,8 +21,9 @@ use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_notification::NotificationExt; /// Kept in managed state purely to keep the watchers alive for the app's lifetime. +/// `_volumes` is `None` on Windows, where drive arrivals are polled instead of watched. struct WatcherHandles { - _volumes: notify::RecommendedWatcher, + _volumes: Option, _folders: Vec, } @@ -63,12 +64,37 @@ fn emit_progress(app: &AppHandle, flow: &'static str, label: &str, done: usize, pub fn start(app: &tauri::App) -> Result<(), Box> { let handle = app.handle().clone(); - // --- /Volumes watcher: poke a worker that diffs the mount list --- + // Drive-arrival detection feeds the same diffing worker on every platform; only + // the poke source differs. macOS watches `/Volumes` (FSEvents); Windows has no + // mount directory to watch, so a thread polls the drive list on a short interval. let (vtx, vrx) = mpsc::channel::<()>(); - let mut vol_watcher = notify::recommended_watcher(move |_res| { - let _ = vtx.send(()); - })?; - vol_watcher.watch(Path::new("/Volumes"), RecursiveMode::NonRecursive)?; + + #[cfg(target_os = "macos")] + let vol_watcher = { + let mut w = notify::recommended_watcher(move |_res| { + let _ = vtx.send(()); + })?; + w.watch(Path::new("/Volumes"), RecursiveMode::NonRecursive)?; + Some(w) + }; + + #[cfg(not(target_os = "macos"))] + let vol_watcher = { + #[cfg(target_os = "windows")] + std::thread::spawn(move || { + // Exits when the receiver is dropped (app shutdown). + while vtx.send(()).is_ok() { + // Each poke shells out to PowerShell for the volume list, so poll + // gently β€” a card inserted between polls is picked up a few seconds + // later, which is invisible next to the copy itself. + std::thread::sleep(Duration::from_secs(5)); + } + }); + #[cfg(not(target_os = "windows"))] + drop(vtx); // no drive source on this platform; the worker idles forever + None + }; + { let h = handle.clone(); std::thread::spawn(move || volume_worker(h, vrx)); @@ -265,6 +291,8 @@ fn handle_volume(app: &AppHandle, volume_root: &Path) { } if dates.is_empty() { // Distinguish "empty card" from "macOS blocked us reading the card". + // (Full Disk Access is a macOS-only concept; Windows reads cards directly.) + #[cfg(target_os = "macos")] if fda_blocked(volume_root) { notify( app, @@ -488,6 +516,7 @@ fn do_photos_import( /// Heuristic: a `PermissionDenied` reading the card root or its DCIM folder means /// macOS is withholding Full Disk Access, not that the card is empty. +#[cfg(target_os = "macos")] fn fda_blocked(volume_root: &Path) -> bool { [volume_root.to_path_buf(), volume_root.join("DCIM")] .iter() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 80350e0..71cec0e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -31,6 +31,15 @@ "minimumSystemVersion": "11.0", "entitlements": "entitlements.plist" }, + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper" + }, + "wix": { + "upgradeCode": "fa0c1b45-ebcc-4a41-9791-70bb8312731e", + "language": ["en-US"] + } + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.tsx b/src/App.tsx index 903faff..b39f40c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -227,10 +227,12 @@ export default function App() { const [activity, setActivity] = useState([]); const [naming, setNaming] = useState(null); const [progress, setProgress] = useState(null); + const [platform, setPlatform] = useState("macos"); useEffect(() => { api.getConfig().then(setConfig); api.getActivity(100).then(setActivity); + api.getPlatform().then(setPlatform); const unlisten = [ // Keep the bar while files remain; the final (total, total) event clears it. @@ -302,9 +304,13 @@ export default function App() { /> )} {tab === "cards" && } - {tab === "folders" && } + {tab === "folders" && ( + + )} {tab === "activity" && } - {tab === "settings" && } + {tab === "settings" && ( + + )} {naming && ( @@ -1127,7 +1133,15 @@ function DestField({ ); } -function FoldersView({ config, patch }: { config: Config; patch: (p: Partial) => void }) { +function FoldersView({ + config, + patch, + isMac, +}: { + config: Config; + patch: (p: Partial) => void; + isMac: boolean; +}) { const update = (i: number, p: Partial) => patch({ folder: config.folder.map((r, j) => (j === i ? { ...r, ...p } : r)) }); const remove = (i: number) => patch({ folder: config.folder.filter((_, j) => j !== i) }); @@ -1146,12 +1160,14 @@ function FoldersView({ config, patch }: { config: Config; patch: (p: Partial

Folders

- Watch a folder and route new files β€” move them into a dated folder, or import them into Apple Photos. + {isMac + ? "Watch a folder and route new files β€” move them into a dated folder, or import them into Apple Photos." + : "Watch a folder and route new files β€” move them into a dated folder."}

- + {isMac && }
@@ -1159,7 +1175,9 @@ function FoldersView({ config, patch }: { config: Config; patch: (p: Partial

No folder rules yet.

- Add a rule to auto-sort a folder, or import a Lightroom export into Photos. + {isMac + ? "Add a rule to auto-sort a folder, or import a Lightroom export into Photos." + : "Add a rule to auto-sort a folder."}

)} @@ -1218,7 +1236,12 @@ function FoldersView({ config, patch }: { config: Config; patch: (p: Partial @@ -1427,9 +1450,18 @@ function ActivityView({ activity }: { activity: ActivityEntry[] }) { ); } -function SettingsView({ config, patch }: { config: Config; patch: (p: Partial) => void }) { +function SettingsView({ + config, + patch, + isMac, +}: { + config: Config; + patch: (p: Partial) => void; + isMac: boolean; +}) { const [autostart, setAutostart] = useState(null); const [version, setVersion] = useState(""); + const trayName = isMac ? "menu bar" : "system tray"; useEffect(() => { isEnabled().then(setAutostart).catch(() => setAutostart(false)); import("@tauri-apps/api/app").then((m) => m.getVersion()).then(setVersion).catch(() => {}); @@ -1459,7 +1491,7 @@ function SettingsView({ config, patch }: { config: Config; patch: (p: Partial Launch at login -

Start FileFlow automatically and keep it in the menu bar.

+

Start FileFlow automatically and keep it in the {trayName}.

When off, closing the window quits FileFlow.

- + {/* The Dock (and hiding the tray icon in its favour) is macOS-only; elsewhere + the tray icon is the app's one surface and always stays on. */} + {isMac && ( + <> + - -

Keep at least one of the menu-bar or Dock icon visible.

+ +

Keep at least one of the menu-bar or Dock icon visible.

+ + )}