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 @@
-
+
@@ -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