Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
52 changes: 37 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
</p>

<p align="center">
<img src="https://img.shields.io/badge/platform-macOS-000000?logo=apple&logoColor=white" alt="Platform: macOS">
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows-000000?logo=apple&logoColor=white" alt="Platform: macOS | Windows">
<a href="https://tauri.app"><img src="https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white" alt="Tauri 2"></a>
<a href="https://www.rust-lang.org"><img src="https://img.shields.io/badge/Rust-1.77+-DEA584?logo=rust&logoColor=white" alt="Rust 1.77+"></a>
<a href="https://react.dev"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React 19"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-3B6FE0" alt="License: MIT"></a>
</p>

<p align="center">
A quiet macOS menu-bar app that moves your files where they belong — automatically, and safely.<br>
Ingest a <b>drive into dated folders</b>, route <b>one folder into another</b>, or import straight <b>into Apple Photos</b>.
A quiet menu-bar / tray app that moves your files where they belong — automatically, and safely.<br>
Ingest a <b>drive into dated folders</b>, route <b>one folder into another</b>, or import straight <b>into Apple Photos</b>.<br>
Native on <b>macOS</b>, and on <b>Windows</b> via an MSI installer (Photos import is macOS-only).
</p>

<p align="center">
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 |
|---|---|---|
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
50 changes: 45 additions & 5 deletions core/src/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -414,10 +422,18 @@ pub fn cleanup(report: &IngestReport) -> Result<CleanupReport> {

/// 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<bool> {
if policy == EjectPolicy::Never {
return Ok(false);
}
eject_backend(volume_root)
}

#[cfg(target_os = "macos")]
fn eject_backend(volume_root: &Path) -> Result<bool> {
let status = Command::new("diskutil").arg("eject").arg(volume_root).status()?;
if status.success() {
Ok(true)
Expand All @@ -426,6 +442,30 @@ pub fn eject(volume_root: &Path, policy: EjectPolicy) -> Result<bool> {
}
}

#[cfg(target_os = "windows")]
fn eject_backend(volume_root: &Path) -> Result<bool> {
// `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<bool> {
Err(Error::EjectUnsupported)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
32 changes: 30 additions & 2 deletions core/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.join("/")
}

/// Substitute tokens and split into cleaned segments (shared by both renderers).
fn render_segments(template: &str, year: &str, date: &str, name: &str) -> Vec<String> {
let raw = substitute(template, &[("{year}", year), ("{date}", date), ("{name}", name)]);
raw.split('/')
.map(str::trim)
.filter(|s| !s.is_empty() && *s != "." && *s != "..")
.collect::<Vec<_>>()
.join("/")
.map(str::to_string)
.collect()
}

/// Render a filename template like `{date}_{seq}` into a single filename.
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ pub mod ingest;
pub mod layout;
pub mod photos;
pub mod util;
pub mod windows;

pub use error::{Error, Result};
9 changes: 9 additions & 0 deletions core/src/photos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading