Skip to content

Changelog

All notable changes to Go Green Transit (codename ftproxy).

[Unreleased] — 2026-05-13 (Settings: expose the missing global transfer-rate input)

Bug fix

  • Settings → "Default transfer rate (KiB/s, 0 = unlimited)" input added. The Rust side has carried AppConfig.transfer_rate_kib since the per-site bandwidth roadmap item shipped (2026-05-02), wired all the way through throttle::set_global_rate_kib on startup and on every PUT /config. The Site Form's per-site Bandwidth input has been shipping with placeholder text reading "0 = use global Settings rate", and the USER-GUIDE explicitly told users to "fall through to the global rate from Settings → Transfer rate." But the Settings modal had no such input — the documented fallback was unreachable from the UI. The number field is now present between Concurrent transfers and Verify after upload; 0 keeps the historical "unlimited" behavior, and the server-side validate() clamp at 1 GiB/s still guards against typos.
  • USER-GUIDE.md Settings table now lists the field so the docs match the UI.
  • Pinning tests: three new vitest assertions in dist/__tests__/queue-pill.test.js (Settings modal renders the label, input pre-fills from cfg.transferRateKib, save payload sends transferRateKib clamped to >= 0). 213/213 frontend tests pass.

[Unreleased] — 2026-05-13 (App icon redesigned for taskbar legibility)

Branding / chrome

  • App icon replaced. The previous icon embedded the full "Go Green Transit" wordmark (three overlapping text layers in two weights), which was illegible at 32×32 in the Windows taskbar regardless of cropping. New icon is a function-mark: two stacked folders in brand greens (#1B5E20 dark, #4CAF50 mid) with curved sync arrows between them, reading as "file transfer" at any size.
  • Sized regeneration runs through cargo tauri icon ../new-icon.png (Windows, macOS, iOS, Android) — Square*Logo.png / StoreLogo.png for legacy MSIX packaging were intentionally left at the previous wordmark; only the icon.ico / icon.png / multi-size PNG family is updated for the runtime taskbar icon.
  • Pre-swap icons archived to src-tauri/icons.backup-2026-05-13-darkglow/ (intermediate dark-glow experiment) and src-tauri/icons.backup-pre-rebrand-20260513/ (the white-background wordmark) if rollback is ever needed.

Tooling

  • New scripts/generate-icon-mocks.ps1 — System.Drawing-based generator that emits 1024×1024 source PNGs plus 32×32 thumbnails for any candidate design, so future icon revisions can be evaluated at taskbar scale before being committed.
  • tasks/lessons.md gained a new section ("Icon swap pipeline (Tauri 2 on Windows)") documenting the cargo tauri icon → cache invalidation flow, the lib.rs-touch trick to force re-link without a full cargo clean, the Read-tool transparent-PNG composite trap, and the current rustc OOM threshold (~5–6 GB free, up from 4 GB).

[Unreleased] — 2026-05-12 (Encryption-at-rest + accessibility + installer pipeline)

Security

  • sites.json is now encrypted at rest with AES-256-GCM. Key lives in the OS keychain (ai.opensentinel.ftproxy/data.encryption_key) with a per-data-dir file fallback (<data_dir>/.encryption_key, owner-only) for environments without a working keychain.
  • Wire format: MAGIC(4) | VERSION(1) | NONCE(12) | CIPHERTEXT+TAG. Magic bytes FTPX let the loader detect ciphertext vs legacy plaintext JSON without speculative parsing.
  • Backward compat: existing plaintext sites.json files are read normally on first launch (logged as legacy), then rewritten in encrypted form on the next save. Zero user action required.
  • Corruption protection: if decryption fails (keychain wiped, ciphertext tampered, wrong key), the file is moved aside to sites.json.corrupt.<timestamp> instead of being silently overwritten on the next save. Lets the user recover by restoring the keychain key.
  • 14 new unit tests including:
  • on-disk plaintext absence (hostname/username never appear)
  • legacy plaintext → encrypted migration roundtrip
  • tampered-ciphertext rejection
  • wrong-key rejection
  • truncated-frame rejection
  • corrupt-file backup-not-clobber invariant

Accessibility (WCAG 2.1 AA pass)

  • Global :focus-visible outline on every interactive control (button, input, select, textarea, link, anything with [tabindex]). Uses the protocol's accent color so it stays readable across themes.
  • Skip-to-main-content link as the first interactive element. Visually hidden until keyboard focus reaches it; lets screen-reader / keyboard users bypass the brand strip + session tabs + palette.
  • <main role="main" aria-label="File transfer workspace"> landmark on the workspace container (was a bare <div>).
  • Protocol swatches get an aria-label ("Set protocol accent to SFTP") at bootstrap so screen readers announce the action, not just the visible text. Decorative .chip spans get aria-hidden so they're not double-announced.
  • Platform-disabled swatches (iCloud on Linux) now also set aria-disabled — some screen readers don't track the DOM disabled attribute reliably.
  • Wire console gets role="log" + aria-live="polite" so statusLog() output is announced to screen readers as it appends.
  • Reduced-motion support — @media (prefers-reduced-motion: reduce) collapses every transition to 1ms. Per WCAG 2.3.3.
  • .sr-only utility class for visually-hidden screen-reader text.
  • 13 new JS unit tests in dist/__tests__/a11y.test.js lock the ARIA decoration + CSS rules in place so future changes don't silently regress accessibility.

Installer pipeline

  • Windows MSI + NSIS installers produced locally:
  • Go Green Transit_0.8.9_x64_en-US.msi (31 MB)
  • Go Green Transit_0.8.9_x64-setup.exe (19 MB)
  • Both verified well-formed via signtool verify ("No signature found" is the expected response for an unsigned binary; means the structural validation passed).
  • cargo tauri info clean — tauri-plugin-single-instance v2.4.2 appears in the plugin list as expected.

Tests

Suite Before After
Rust unit 322 336 / 336 (+14 encryption tests)
JS unit 197 210 / 210 (+13 a11y tests)
Live endpoint 25 / 25 25 / 25

[Unreleased] — 2026-05-12 (Production-readiness sweep)

Security — closes the four release-blocking items

  • SFTP host-key default changed: AcceptAny → TOFU. Before this release, the default policy accepted any host key on every connect. A first-time MITM on any SFTP site would silently succeed.
  • New default: TOFU — first connect pins the fingerprint; subsequent connects reject any mismatch. Matches FileZilla / WinSCP / PuTTY default semantics.
  • New optional mode: Strict — unknown hosts are rejected. For HIPAA / PCI / SOC 2 deployments where pre-trust is mandatory. Toggle via Edit → Settings → "SFTP host-key verification".
  • AcceptAny is retained as a per-request escape hatch for test rigs ({"acceptAnyHostKey": true} in the connect body).
  • 4 new pure-function tests pin the decision table (transport/sftp.rs accept_any_*, tofu_pins_first_contact_and_rejects_mismatch, strict_rejects_unknown_and_mismatch_accepts_only_match, strict_is_port_specific).
  • Single-instance lock via tauri-plugin-single-instance v2.4.2. Second launches now focus the existing window instead of spawning a parallel app that binds to a different bridge port and silently desyncs state on shutdown.
  • First-run consent modal. Blocks the UI on first launch with three controls:
  • Check for updates automatically (default ON; auto-updater fetches https://www.gogreentransit.com/downloads/latest.json on launch)
  • Send anonymous crash reports (default OFF; opt-in only)
  • Acknowledgment of Privacy Policy + Terms of Service links Privacy-safe defaults; serde #[serde(default)] on the new fields ensures legacy configs (pre-consent feature) trigger the modal on next launch. 3 new safety tests including a legacy-config-backfill assertion.
  • Crash reporter scaffold (src/crash_reporter.rs). Installs a std::panic::set_hook that:
  • Always writes a JSON record to <data_dir>/crash.log (rotated at 1 MiB)
  • Optionally POSTs to $CRASH_REPORT_URL only if the user opted in via the consent modal AND the operator configured the endpoint
  • Privacy posture documented in module-level comment: no file contents, no hostnames, no usernames, no credentials, no PII. Captures only: panic message + file:line + thread name + OS family
    • arch + app version + (optional) backtrace.
  • 7 new tests including crash_record_serialises_to_camel_case_json, rotation_kicks_in_when_log_exceeds_threshold, and an idempotent install_is_idempotent assertion.

Installer pipeline

  • tauri.conf.json bundle.targets made explicit: msi, nsis, deb, rpm, appimage, dmg, app.
  • Wix upgradeCode pinned (stable GUID) so future MSI upgrades replace cleanly instead of side-installing.
  • NSIS installMode: perMachine — writes to C:\Program Files for enterprise compatibility.
  • macOS minimumSystemVersion: 10.15 (Catalina; ~99% market coverage).
  • Linux .deb / .rpm depends now include cifs-utils (for SMB mount.cifs) and nfs-common / nfs-utils (for NFS mount.nfs) so package-manager installs get working SMB/NFS out of the box.
  • appimage.bundleMediaFramework: true so AppImage works on minimal distros without preinstalled webkit2gtk.
  • Existing .github/workflows/release.yml already wires every signing env var (TAURI_SIGNING_PRIVATE_KEY, WINDOWS_CERTIFICATE, APPLE_SIGNING_IDENTITY, APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID). Populate the GitHub Secrets and git push --tags produces signed cross-platform builds.

Settings UI

  • Reworded the SFTP host-key option to reflect the new TOFU default vs. Strict (the old "Off / On" labels misrepresented the actual behavior).
  • Added "Check for updates automatically" + "Send anonymous crash reports" toggles so users can change consent state after first run.

Docs

  • docs/legal/PRIVACY.md — privacy policy draft. Captures the technical realities (what data is stored where, what hits the network, what is never sent). Needs attorney review before publishing at https://www.gogreentransit.com/privacy.
  • docs/legal/TERMS.md — terms of service draft. Same caveat.
  • CHANGELOG.md — this entry.

Tests

Suite Before After
Rust unit 308 322 / 322 (+14)
JS unit 197 197 / 197
Live endpoint 25 / 25 25 / 25

[Unreleased] — 2026-05-12 (SyncRootManager probe — auto-detect modern cloud-files providers)

  • places::sync_root_manager_paths() — new probe reading HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager, the Windows Cloud Files API's canonical registry of cloud-sync providers. Catches Store-edition installs of Dropbox / OneDrive / iCloud Drive / Box Drive / Google Drive for Desktop that the per-vendor heuristics miss. Also picks up custom sync-folder paths users picked during initial setup.
  • classify_sync_root_provider() — maps registry key names (Dropbox!…, OneDrive!…, AppleInc.iCloudDrive_…, Box!…, GoogleDrive!…) to FTProxy protocol families. Explicit ordering invariant tested (Dropbox must precede Box to avoid substring collision).
  • Unified seen: HashSet<PathBuf> dedup across every push site in cloud_sync_folders() so a path that appears in multiple sources (SyncRootManager + Dropbox info.json + home-dir scan) gets emitted exactly once.
  • 13 new tests including sandbox-key driven parser tests for iCloud Store edition, Dropbox custom path, multi-user roots, and unknown- provider skip.

[Unreleased] — 2026-05-09 (Audit + fix-up sweep: OAuth streaming, blocking I/O, frontend hardening)

HIGH — real bugs caught + fixed

  • Dropbox / Drive / OneDrive: streaming download_to. The OAuth transports inherited the default trait impl which calls download() and materialises the whole file into a Vec<u8>. With the bridge's 2 GiB body limit a single multi-GB file would balloon process RAM. All three now stream the response body chunk-by-chunk via reqwest's bytes_stream() and pace each chunk through Throttle::from_global() so user-set bandwidth caps actually apply.
  • Dropbox / Drive / OneDrive: streaming upload_from. Same problem in the upload direction. Each transport now uses its provider's session-upload API:
  • Dropbox: upload_session/startappend_v2 (4 MiB chunks) → finish
  • Drive: resumable upload (8 MiB chunks, peek-ahead final chunk)
  • OneDrive: spool to a temp file (Graph requires absolute size in Content-Range, no * allowed), then createUploadSession + 4 MiB chunked PUTs against the session URL. Memory footprint is now bounded by CHUNK (~4-8 MiB) regardless of payload size; Throttle::from_global() runs on every chunk.
  • transport::sftp::connect_with_policy: russh_keys::load_secret_key was synchronous file I/O inside an async fn — slow / network-mounted homedirs could pin a tokio worker for seconds. Now wrapped in tokio::task::spawn_blocking.
  • transport::smb::SmbTransport::{connect,close}: places::map_network_drive / unmap_network_drive shell out to net use / mount_smbfs / mount -t cifs, which can stall 60+ seconds against a dead host. Wrapped both in spawn_blocking.
  • transport::nfs: switched from std::process::Command to tokio::process::Command for mount + umount, and replaced the chrono::Utc::now().timestamp() mount-point names with uuid::Uuid::new_v4() so two NFS sites mounting in the same second can't collide.
  • transport::dropbox::share_link: previous Err(_) swallowed all failures from create_shared_link_with_settings and silently fell back to listing existing links. Now matches on the documented 409 / shared_link_already_exists marker before falling back; auth / network / 5xx failures surface verbatim.

MEDIUM — correctness + race fixes

  • bridge::ws_events: switched WebSocket bearer-token compare from plain != to auth::constant_eq for parity with the REST middleware.
  • bridge::close_session TOCTOU: held the sessions write-lock and the active_session_id write-lock together so two concurrent close_session calls can't interleave and leave active_session_id pointing at a removed slot.
  • state::active_session: replaced expect("no sessions") with a graceful lazy-create fallback that pushes a fresh slot and emits a tracing::warn!. The bridge no longer panics if the session list is ever drained.
  • throttle::RATE_TEST_LOCK: .lock().unwrap() now uses unwrap_or_else(|p| p.into_inner()) so a panic in one rate-scoping test doesn't poison-cascade across the rest of the file. Same pattern applied to metrics::tests::METRICS_TEST_LOCK after a flaky install_is_idempotent run.

LOW — polish

  • bridge restore + share-link: both endpoints now accept an optional sessionId field and route via lock_for_session(...) for parity with the rest of the multi-session model.
  • oauth::provider_config("box"): added unit tests pinning PKCE on, the auth/token URLs, and that the Box auth URL omits scope= when the caller passes no scopes (Box's app-side scope model).
  • transport::boxcloud: emit tracing::warn! when the 10k-item cap on resolve_id / item_info path-walks is hit before locating the segment, instead of failing silently. Added #[allow(dead_code)]
  • comment on BoxItem.id.
  • transport::ftps: SNI placeholder for permissive-mode IP-host connections is now ftproxy.local-tls.test (RFC 6761 reserved) instead of .invalid.
  • lib::list_user_site_templates: factored the read-loop into a pure read_user_site_templates(&Path) helper + 7 inline tests covering missing dir, empty dir, well-formed JSON, missing required keys, invalid JSON, ignored non-.json files, and mixed-bag partition.
  • bridge: 13 new oneshot handler tests covering /files/remote/restore, /files/remote/share-link, /benchmark, /dir/sync, /sessions/:id/reconnect, /sessions/:id/disconnect, /transfers/upload-blob, /sftp/exec, /transfers/verify, /files/remote/page, /license/state, /license/activate, /hostkeys/trust, and /config GET + PUT. Each asserts the auth path + the not-connected envelope shape.

Frontend

  • Palette is one-swatch-per-family — same convention used by Azure Storage (one swatch for azure + azure-files). Box now has a single "Box" swatch covering both the cloud OAuth transport (box) and the local Box Drive sync flavour (box-local). The active-swatch resolver in dist/app.js:783-785 already collapses azure-files → azure and *-local → <family>, so connecting to any flavour lights up the family swatch. (Earlier in this session a separate "Box Drive" swatch was briefly added and then reverted on grounds of consistency.)
  • Replaced 40 alert() call sites with statusLog(...) per the CLAUDE.md "use statusLog instead of alert" rule. Levels chosen by context: warn for "you forgot to fill in X", err for catch-block surfacing, status for success notes that previously double-popped a modal after a status line.
  • Added apiBlob(path, options?) helper (dist/app.js:438-449). Replaces the two hand-rolled fetch(...) + bearer-header sites (preview-blob and saveRemoteAs) with the consistent envelope-aware helper.

Server-side (IONOS dev VPS)

  • Recovered /srv/sftp/gogreensuites/gogreensuites.com — its bind mount target had Links: 0 (orphaned inode) after the docroot was redeployed; umount && mount re-bound it.
  • Re-applied deploy-group ACLs with setfacl -R -m g:deploy:rwx -m d:g:deploy:rwx /var/www/gogreensuites.com so chrooted SFTP users in the deploy group can write again.
  • Audited the other 13 chrooted SFTP domains: all clean (no stale bind mounts, no missing ACLs).

API.md

  • Updated protocol count to 21, added wire-protocol rows for nfs, b2, box, and a local-sync row for box-local.
  • Added route entries for /files/remote/restore, /files/remote/share-link, /sessions/:id (GET), /sessions/:id/reconnect, /dir/sync, /license/state, /license/activate. Total documented: 55 routes.

Tests

  • cargo test --lib: 284 passed / 0 failed (was 251 at session start; +33 new tests).

[Unreleased] — 2026-05-05 (Auto-seed local-sync sites, Box Drive auto-detect, swatch lock recompute)

Auto-seed local-sync sites on first launch

  • New behaviour in build_app_state(): when the bridge boots and places::cloud_sync_folders() reports a desktop-sync client's folder for which no <provider>-local saved site exists, create one automatically. Removes the "click + New site for every detected client on every fresh install" friction. Having Box / Dropbox / OneDrive / Drive installed is enough for FTProxy to surface them.
  • Classifies each detected place's label → protocol slug: Dropbox → dropbox-local, OneDrive → onedrive-local (incl. Business "OneDrive — <Org>"), Google Drive → gdrive-local, Box → box-local (also Box-…, Box (… for macOS / legacy variants), iCloud → icloud-local.
  • Respects deletes: skips when ANY saved site of that protocol already exists. A user who deletes "Dropbox (local sync)" doesn't get it back next boot — their delete stands.
  • Skips silently when no matching place is detected (fresh install with no desktop-sync clients yet).
  • Auto-created sites carry an "Auto-created on first launch" note for forensic clarity in sites.json + the Site Manager.
  • Logs on every fire: auto-seed: added local-sync sites count=N plus one line per added site (Box (local sync) → C:\Users\…\Box). Visible in the wire console + GET /logs.

Box Drive sync-folder auto-detect in the Site Form

  • When the user picks Box (Box Drive local sync) in the protocol dropdown, extra.local_root now pre-fills to the detected sync path. Same auto-fill behaviour Dropbox / OneDrive / Google Drive already had when their desktop clients are installed.
  • Frontend localSync map (dist/app.js) extended with a box slot; matches the place's label in three forms:
  • "Box" — Windows / Linux fallback (places.rs::cloud_sync_folders pushes this when %USERPROFILE%\Box exists)
  • "Box-…" — macOS CloudStorage scan, e.g., Box-user@example.com
  • "Box (…" — legacy fallbacks (e.g., "Box (Sync legacy)")

Frontend — palette swatch lock recompute on connect/disconnect

  • applyForcedAccent() now re-evaluates lock state when the active tab's connection flips. Disconnect → swatches unlock immediately. Connect → swatches lock to the live protocol. Previously the lock state could go stale after a session.disconnect event because the recompute only fired on swatch click.
  • Pinned by dist/__tests__/swatch-and-menu-clicks.test.js (4 cases: forced pick, live-session lock, disconnect-unlocks, reconnect-relocks).

DX — .env.local pattern for dev/release scripts + audit retries

  • New .env.local.example template; .env.local already in .gitignore. Stops the long-tail of "set $env:FTPROXY_VPS_HOST=…" in every PowerShell session.
  • scripts/release.ps1 and scripts/test-endpoints.ps1 now source .env.local if present.
  • Audit scripts (scripts/manual-run-job-test.py, scripts/thorough-test.py) retry once on flaky network ops (DNS, transient cloud 429s) before failing the run.

[Unreleased] — 2026-05-01 (Audit infra, Azure Entra Day 1, box-local, swatch lock, Azure rename)

Swatch tooltips (discoverability)

  • Every protocol-accent swatch (dist/index.html) now carries a title attribute spelling out the acronym and what the protocol is — e.g. "SFTP — SSH File Transfer Protocol. Encrypted file transfer over SSH (port 22)." Hover any swatch to see it. No user is expected to know what NFS, WebDAV, CIFS, or B2 stand for cold.
  • Build marker bumped to v81-swatch-tooltips.

Hidden-files toggle (universal across protocols)

  • View → Toggle hidden files (Ctrl+H) — hides desktop.ini, Thumbs.db, ehthumbs.db, .DS_Store, all dotfiles, and Office lock files (~$*) by default. Matches the default behaviour of Explorer, Box, Dropbox, OneDrive, and Google Drive.
  • Filter is universal: applied in renderFileList() so every protocol pane gets the same treatment — no per-protocol opt-in.
  • Toggle persists across restarts via localStorage (ftproxy_show_hidden).
  • Driven by a new isHiddenName() helper with a frontend test (dist/__tests__/helpers.test.js) covering Windows OS noise, dotfiles, Office lock files, and false-positive guards.

Per-site bandwidth UI input (roadmap #6 finishing touch)

  • New Bandwidth limit (KiB/s) field in the Site Form — wired through topFields.bandwidth_kib so the existing save loop serialises it into extras.bandwidth_kib. 0 and empty are omitted from sites.json (functionally equivalent on the Rust side; keeps the file clean).
  • Per-site value pre-fills on edit from s.extra.bandwidth_kib.
  • Bridge post_connect now logs (both tracing::info! AND user- visible state.push_log("info", ...)) when a per-session cap is active: "Per-session bandwidth cap active: 256 KiB/s (0.3 MB/s) for this connection". No log when 0 (would be noise).
  • Roadmap item #6 is fully shipped — the Rust task-local plumbing was complete previously; this turn closes the UI gap that was the last remaining bit.
  • Pinned by 5 new frontend tests + 3 new Rust tests (bandwidth_kib_extra_parses_to_u64_with_zero_fallback, session_slot_starts_with_zero_rate_kib, session_slot_rate_kib_is_atomic_writable).

Logging audit — wire-console parity for shipped surfaces

  • B2 endpoint preset: tracing::info! with the resolved region
  • endpoint when the bridge auto-fills B2's S3-compatible URL, or tracing::debug! when the user supplied their own. Both bridge dispatch paths (dispatch_connect + post_connect) emit the same line so users can confirm which region they ended up on.
  • User templates loader: tracing::info! summary on every call: dir, loaded, total_files, skipped. Per-file warnings already existed; the summary makes "did my template show up?" a single log search.

NFS transport (roadmap #9) — Amazon EFS, FSx OpenZFS, NetApp NFS, Storage Gateway NFS

  • New transport/nfs.rs — same mount-and-proxy strategy as SMB, shells out to the OS-native NFS client. Cyberduck / WinSCP / FileZilla all do the same; reimplementing the NFS wire protocol in Rust would have been multi-day with no real upside.
  • Per-platform mount commands:
  • Windows: mount -o anon \\server\export Z: (requires the "Services for NFS" Windows feature — Pro/Enterprise only; the connect path surfaces an actionable error if the feature isn't installed).
  • macOS: mount_nfs -o vers=<n>,resvport server:/export /Volumes/ftproxy-nfs-<ts> (ships with the OS, no setup).
  • Linux: mount -t nfs -o vers=<n> server:/export /mnt/ftproxy-nfs-<ts> (needs nfs-common / nfs-utils).
  • Connection input forms accepted: server:/export (canonical), nfs://server/export (URL form), or bare server + extras.export. Optional extras.nfs_version ("3" or "4", default "4"), extras.mount_opts (extra opts joined to -o), extras.mount_path (skip the mount call and attach to a path the user mounted by hand).
  • Protocol pipeline (all the standard touch-points wired up):
  • normalize_protocol accepts "nfs"
  • Bridge dispatch in both dispatch_connect and post_connect
  • default_folder_for_protocol"NFS"
  • /health protocols array now lists "nfs"
  • Frontend PROTO_LABELS / PROTO_ACCENTS / PROTO_ORDER / chip class
  • New NFS swatch in the protocol palette
  • Dual-pane workspace (NFS is NOT in SINGLE_PANE_PROTOS)
  • Five new Site Manager templates added to the AWS / Generic catalog: Amazon EFS (NFSv4), AWS FSx for OpenZFS (NFS), AWS FSx for NetApp ONTAP (NFS side), AWS Storage Gateway (NFS mode), Generic NFS export (v3 / v4). Each prefilled with the right host shape + sensible default mount_opts.
  • Templates picker footer rewritten to reflect the new coverage — EFS / FSx OpenZFS / FSx NetApp NFS / Storage Gateway NFS now first-class. Only FSx Lustre stays out (HPC-niche, not on roadmap) and EBS stays out (block-level, not a file-transfer target).
  • Pinned by 6 new Rust tests (nfs::tests::* — parse_export canonical/URL/bare/normalised/missing-export plus the pre-mounted attach round-trip) and 6 new frontend tests (PROTO_LABELS / PROTO_ORDER / PROTO_ACCENTS / templates presence / EFS shape / dual-pane membership).

User-extensible site templates (roadmap #7) — drop-in JSON

  • New Tauri commands list_user_site_templates + site_templates_dir. Reads every *.json in <data dir>/templates/ (creates the dir on demand) and merges them into the built-in catalog when the user opens Site Manager → From template….
  • File format: a single JSON object with category, name, description, and site keys. Light validation rejects malformed files with a tracing::warn!; valid ones show up alongside the built-ins, separated by category.
  • Footer of the picker shows the directory path with a copy-friendly span and a count of currently-loaded user templates.
  • Sample wasabi.json written to the user's templates/ dir as a starter (category "Custom", protocol s3, Wasabi endpoint preset).
  • Closes the audit gap "user-extensible protocol profiles" — power users can add Wasabi / IDrive e2 / MinIO variants / any S3-compatible backend without waiting for an FTProxy release.

Per-session bandwidth throttle (roadmap #6) — Rust plumbing

  • SessionSlot gained rate_kib: AtomicU64, set on connect from the saved site's extras.bandwidth_kib (0 = use the global rate).
  • throttle.rs gained a tokio::task_local! SCOPED_RATE_BPS that Throttle::from_global() consults before falling through to the process-wide rate. New helper with_scoped_rate_kib(kib, future) wraps any future with the scope.
  • bridge::run_transfer_upload and run_transfer_download are now thin wrappers that read the slot's rate and invoke the inner function inside the scope — every transport's chunk loop picks up the per-session cap automatically via Throttle::from_global(), no per-transport edits needed.
  • UI input for extras.bandwidth_kib in the Site Form is deferred to a follow-up push (the Rust side is ready; the input slot is the remaining surface). Power users can set it via direct edits to sites.json or via POST /sites extras today.
  • Pinned by 2 new throttle tests (scoped_rate_overrides_global_inside_scope, scoped_rate_zero_falls_through_to_global).

Quick preview (roadmap #4) — right-click → 👁 Preview

  • New right-click context menu item 👁 Preview on every remote file. Renders inline:
  • Text/code (utf-8 / utf-16 / latin-1 best-effort) with monospace + word-wrap
  • Images (png / jpg / webp / gif / svg / bmp / ico / avif) via URL.createObjectURL + <img> tag
  • PDFs via URL.createObjectURL + <iframe> (WebView2's built-in PDF viewer)
  • Anything else falls back to a hex+ASCII dump of the first 4 KB so the user can verify magic bytes
  • Capped at 5 MB per preview; files larger get a confirm prompt ("preview anyway? only the first 5 MB will be shown").
  • Backed by the existing /files/remote/raw endpoint — no new Rust code.
  • Pinned by 4 new tests in dist/__tests__/queue-pill.test.js.
  • New Transport trait method share_link(remote) -> ShareLink with bail-by-default impl. Implemented on:
  • Dropbox: sharing/create_shared_link_with_settings with a fallback to sharing/list_shared_links if a link already exists for the path (Dropbox returns 409 in that case).
  • Google Drive: permissions.create (anyone-with-link reader) followed by files.get?fields=webViewLink to get the URL Drive populates after the permission lands.
  • OneDrive: /createLink with type=view, scope=anonymous, pulls link.webUrl from the response.
  • Other transports degrade to the trait default ("not supported").
  • New ShareLink struct: { url, isPublic, expiresAt? }. Surfaced to the wire as JSON via the new bridge endpoint POST /files/remote/share-link with { remotePath }.
  • New right-click context menu item Get share link… visible on dropbox / gdrive / onedrive for non-directory entries. Auto-copies the URL to clipboard on success; falls back to a Copy button if the Clipboard API was blocked.
  • Pinned by 4 new tests in dist/__tests__/queue-pill.test.js.

Backblaze B2 — first-class protocol via S3-compatible alias

  • New protocol b2 in normalize_protocol (aliases: b2, backblaze, backblaze-b2, backblazeb2).
  • Dispatch reuses the existing S3 transport with the B2 S3-compatible endpoint preset (s3.<region>.backblazeb2.com), path-style addressing forced. Inherits every S3 feature for free: multipart upload, paginated listing, restore_object, multipart-resume.
  • New Backblaze B2 swatch in the protocol palette (Backblaze brand red #ed1c24, mark B2, kicker "S3-compatible object store").
  • Site Manager template: Backblaze B2 (S3-compatible) prefilled with region = us-west-002, path_style = true. User just adds bucket name + B2 application key.
  • Frontend: b2 added to PROTO_LABELS, PROTO_ACCENTS, PROTO_ORDER, SINGLE_PANE_PROTOS, the index.html palette, and the action-policy dual-pane gate (B2 is single-pane, like S3).
  • Bridge /health protocols array now correctly reports all 17 protocols (was an undercount of 11 — fixing that here too).
  • Closes roadmap item #5. Truly only ~80 new lines + the dispatch preset in two places thanks to S3-API parity.
  • Pinned by 5 new tests (queue-pill.test.js) + 1 new Rust test (normalize_protocol_accepts_b2_aliases).

S3 Glacier restore — right-click → Restore from Glacier…

  • Closes roadmap item #10. Surfaces the cold-archive thaw flow through both code paths:
  • Rust: Two new methods on the Transport trait — restore_object(&mut self, remote, days) and storage_class_of(&mut self, remote) — with default impls that bail "not supported" so non-S3 transports automatically degrade cleanly. S3 transport implements both via aws-sdk-s3's restore_object and head_object.
  • Bridge: New POST /files/remote/restore endpoint with { remotePath, days? } body. Pre-checks storage class via HEAD; if the object isn't actually archived, returns a friendly "no restore needed" note instead of letting AWS reject with InvalidObjectState. Days clamped to [1, 30] with default 7. The handler logs success / skip details to the wire console too.
  • UI: New right-click menu item Restore from Glacier… visible only on s3 and b2 protocols for non-directory entries. Prompts for days (1-30, default 7), POSTs the request, surfaces both success and "not actually archived" responses with their AWS-side notes intact.
  • Storage classes treated as archived: GLACIER, DEEP_ARCHIVE, GLACIER_IR, GLACIER_INSTANT_RETRIEVAL. Anything else (STANDARD, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, EXPRESS_ONEZONE, …) short-circuits with a friendly note.
  • Pinned by 3 new Rust tests (restore_body_parses_camel_case_with_default_days, restore_body_clamping_logic, glacier_class_predicate_matches_archive_classes_only)
  • 4 new frontend tests.

Site Manager → From template… — close the AWS-coverage gap

  • New From template… button in the Site Manager opens a catalog of pre-canned saved-site shapes. Pick one → openSiteForm() opens with the protocol, host, port, extras, and a help-text note all prefilled. Closes the audit gap "we have S3 but not other Amazon storage" — most of AWS file storage IS reachable today via the existing SMB transport; templates make that breadth visible.
  • Templates shipped:
  • AWS: S3 (Standard/IA/Intelligent-Tiering/Express One Zone), FSx for Windows File Server (SMB), Storage Gateway File Gateway (SMB mode), FSx for NetApp ONTAP (SMB side)
  • Azure: Blob Storage, Files
  • Generic: SMB, SFTP, WebDAV
  • The picker's footer documents what's NOT yet templated and why: EFS / FSx Lustre / FSx OpenZFS / Storage Gateway NFS need an NFS transport (multi-day; on roadmap as item #9). EBS isn't a file-transfer target — block-level storage attached to EC2; use the EC2's SFTP / SMB instead.
  • Pinned by 8 new tests in dist/__tests__/queue-pill.test.js.

Sync — /dir/sync mirror engine surfaced in the GUI

  • Closes the #1 roadmap gap: bidirectional sync. The engine has shipped for months — the scheduler uses it, the CLI's ftproxy-cli --sync uses it — but the GUI never exposed it. New Sync… button in the action bar (visible whenever connected to a dual-pane protocol) opens a modal with:
  • Local path + Remote path (prefilled from the active session)
  • Direction: ↑ Upload · ↓ Download · ↔ Mirror (mirror is bidirectional, newer-wins for files present on both sides)
  • Overwrite policy dropdown — all five OverwritePolicy enum values: skip / overwrite / overwrite-if-newer (default) / resume / rename
  • Max depth (1–12, default 4)
  • Dry Run checkbox — calls /dir/compare instead of /dir/sync so users can preview the diff without moving any bytes. The "Compare directories" action-bar button now reuses the same modal with Dry Run pre-checked.
  • Posts to the existing /dir/sync bridge endpoint — same code path the scheduler and CLI hit, so behaviour is consistent across all three surfaces.
  • The dead applySync() function (which manually iterated and fired individual /transfers/upload / /transfers/download calls — a pre-/dir/sync workaround) has been deleted; tests now guard against its accidental return.
  • Pinned by 9 new tests in dist/__tests__/queue-pill.test.js.

Help → Bridge & Automation — moat now visible from inside the app

  • New menu item Help → Bridge & Automation (API · MCP · CLI)… opens a one-stop modal exposing FTProxy's three programmable surfaces inside the GUI itself, not buried in the docs site. Closes the audit gap "the moat is invisible from the UI".
  • Surfaces:
  • REST base URL + WebSocket URL (each with Copy)
  • Bearer token, masked by default, click "Show" to reveal, Copy button alongside (mistake-paste-into-screenshare guard)
  • Ready-to-paste curl example
  • Paste-ready MCP config snippet for Claude Desktop / Claude Code (mcpServers.ftproxy.command = "ftproxy-mcp" + env vars)
  • CLI examples for ftproxy-cli (sites list, session connect, files list, schedule run)
  • Link to docs.gogreentransit.com
  • Clipboard uses navigator.clipboard.writeText with a textarea + execCommand("copy") fallback for environments that strip the Clipboard API.
  • New native menu id help_automation_info added to NATIVE_MENU_IDS (Rust side) and the JS dispatch (in lockstep per menu_tests).
  • Pinned by 7 new tests in dist/__tests__/queue-pill.test.js.

Places picker — unreachable network drives filtered out

  • A network drive whose remote endpoint is unreachable (server offline, credentials revoked, share unmounted at the remote end) used to appear in the local-pane Places picker labeled (network) — clicking it would freeze the local pane for 30-60 seconds while the OS waited on the dead mount. The picker now probes every kind === "network" entry in parallel with a 1.5s deadline; drives that don't respond in time are filtered out (the worker thread is abandoned and exits when the OS finally errors — not on the UI's critical path).
  • Non-network drives (DRIVE_FIXED / removable / cdrom) bypass the probe entirely. Local drives are always shown.
  • Pinned by 6 new tests in places.rs covering the pure filter, always-OK probe, always-fail probe, timeout enforcement, and parallel concurrency (3 × 120ms probes finish in ~120ms parallel vs. ~360ms sequential).

Bookmarks — Jump to actually connects + navigates

  • The original Bookmarks → Manage → Jump to button only navigated if (state.connected) and ignored the bookmark's siteId. Clicking on a fresh session looked broken because the no-op was silent. The rewrite (dist/app.js:4412) funnels through connectSaved(site) — the same unified path Site Manager → Connect uses — so any bookmark with a valid siteId connects with full keyring lookup for passwords / OAuth tokens / object-store access keys, then cd's the remote pane to b.remotePath. Skips the reconnect dance if you're already on the same site (matched on protocol + host + username).
  • Free-floating bookmarks (no siteId) navigate only the local pane when a localPath is set, otherwise log a clear warning.
  • Orphaned bookmarks (siteId points at a deleted site) emit a clear error rather than silently doing nothing.
  • Double-click on the listbox row also Jump-to's now (parity with Site Manager's double-click-to-connect).
  • Pinned by 6 new tests in dist/__tests__/queue-pill.test.js.

Places picker — drives grouped by kind

  • The 📁 Places popover on the Local pane now splits drives into three labeled sections — Local drives, Removable drives, Network drives — instead of one undifferentiated "Drives & shares" list. Pure visual reorg; clicking any drive still treats it as a local path (Windows resolves SMB I/O underneath).
  • Side fix: the per-row (network) suffix that used to double-tag network drives (since places.rs already formats Z:\ (network)) is gone — the section header carries the meaning now.
  • Empty groups skip their header so the picker stays compact on systems with no removable or network mounts.
  • Pinned by 5 new tests in dist/__tests__/queue-pill.test.js.

Session tab label — host form only when connected

  • After connecting then disconnecting (e.g., from Azure Blob), the bridge keeps s.info.host populated to support Reconnect-last. That used to freeze the tab label into the host form, so swatch clicks couldn't visibly retint the text. The label now gates the host form on s.info?.connected — once disconnected, the label falls back to session #<shortId> · <proto> and swatch clicks retint the text as expected. Title tooltip got the same gate on s.info?.username for consistency. Pinned by 3 new tests.

Queue pill UX — ellipsis + horizontal scroll

  • Each queue pill now caps at max-width: 240px with a .fn span wrapping the filename — long filenames truncate with instead of pushing the strip past the viewport.
  • Hover-tooltip on the pill (set by renderTransfers) keeps showing the full localPath ⇄ remotePath so the truncation is reversible.
  • Queue strip itself gets overflow-x: auto as a safety net for the many-concurrent-transfers case — horizontal scroll instead of clipping or wrapping to a second row.
  • Frontend lesson: CSS ellipsis can't apply to document.createTextNode; the filename had to move into an actual <span> element. Pinned by a new test file dist/__tests__/queue-pill.test.js (9 tests).

Real bugs caught by manual usage that no existing test covered

  • rustls 0.23 panic on FTP/FTPS connectsuppaftp's async-rustls path didn't auto-install a CryptoProvider. Without it the bridge crashed silently mid-request with "Empty reply from server" curl-side. Fixed in lib.rs::run() by calling rustls::crypto::ring::default_provider().install_default() before any TLS code path. Even plain FTP triggered the panic because suppaftp lazy-loads the TLS stack at session-setup time.
  • azure_core 0.21 HMAC backend missing — SharedKey signing panicked with "Could not automatically determine the process-level CryptoProvider." Fixed by adding the hmac_rust feature flag to azure_core, azure_storage, and azure_storage_blobs.
  • WebDAV PUT 401 in the GUI — IIS Basic Auth requires the user to be in the Users local group (for the LogonUser call's "Allow log on locally" right). New-LocalUser doesn't do that automatically. Documented in tasks/lessons.md.
  • WebDAV <location path="…/dav"> config silently fails — configured the WebDAV authoring at a sub-location and the module never activated. Migrated to site-root config + a clean _ftproxy_audit/ subfolder convention for probe writes.
  • IIS FTP PASV port range + Windows Firewall — set firewall.dataChannelPort to 50000-50100 and opened the range in Windows Firewall. Without this, suppaftp got connection-refused on data channels even though curl ftp://127.0.0.1 worked.
  • Tab-label stuck on stale protocol — session tab render used s.info?.protocol first, so the label kept showing the prior session's protocol after disconnect. Reordered to prefer state.intendedProto (set on swatch click) when the tab isn't actually connected.
  • Swatch click chrome update intermittentstate.connected could go stale (especially after the audit script's mass disconnect), causing applyAccent() to take the live branch and ignore state._forceProto. applyForcedAccent() now await refreshSession() before deciding, and refreshSession() re-runs applyAccent() whenever state.connected flips.
  • Endpoint smoke /transfers/upload status check stalescripts/test-endpoints.ps1 read $r.data.status but the upload handler returns $r.data.job.status on normal completion. Test passed on re-runs (short-circuit path) but failed on first run against a clean fixture. Now handles both response shapes.

Audit infrastructure (NEW — closes the AUDIT-2026-04-26 gap)

  • scripts/audit-protocols.py — connect+list every saved protocol via the running bridge. 12/12 pass. Exit code drives CI.
  • scripts/audit-protocols-crud.py — full upload+download+ byte-verify+delete cycle into a _ftproxy_audit/ subdir per saved site, using a 37-byte "Hello FTProxy audit" probe payload. 12/12 pass with deletes confirmed. Has retry-on-flaky for two known intermittent ops (dropbox-local delete during sync lock, IIS FTP 425 PASV port-reuse race).

Day 1 of Azure Storage Entra ID auth refactor

  • transport/azure.rs::connect() now dispatches on info.extra["auth_method"] (account_key default, sas, entra):
  • account_key keeps the existing StorageCredentials::access_key flow — back-compat, no behaviour change for sites that don't set auth_method.
  • sas consumes info.extra["sas_token"] (or password fallback) and constructs StorageCredentials::sas_token, with defensive ?/full-URL prefix stripping for the common Azure-Portal copy.
  • entra returns a clear "not yet implemented" bail pointing at the new docs/azure-entra-plan.md. Days 2-4 land the three sub-modes (auto / service-principal / interactive) + SharePoint Online + UI dropdown.
  • sas_token added to credentials::SECRET_EXTRA_KEYS so it routes through the OS keychain on save and is hydrated back on connect — same path GCS service-account JSON uses.
  • azure_identity = "0.21" added to Cargo.toml so Day 2's DefaultAzureCredential / ClientSecretCredential / InteractiveBrowserCredential can land without a follow-up Cargo round-trip.
  • Day 2 work scheduled as a one-time remote agent (trig_019Lodw9zE5sXHSroem3gM6Q fires 2026-05-08 13:00 UTC). See docs/azure-entra-plan.md for the full Day 1-4 plan.

box-local protocol (Box Drive desktop sync)

  • New box-local slug recognized by bridge.rs::normalize_protocol (also boxlocal).
  • Routes to LocalCloudTransport like the other -local variants (dropbox-local, gdrive-local, onedrive-local, icloud-local).
  • places.rs::cloud_sync_folders() now scans %USERPROFILE%\Box (Box Drive) and %USERPROFILE%\Box Sync (legacy Box Sync) and surfaces each found dir as a Place for the local-pane sidebar. macOS already gets it via the ~/Library/CloudStorage/ scan from Apple's CloudStorage API.
  • New site-form dropdown option Box (Box Drive local sync).
  • New palette swatch data-proto="box-local" (Box brand blue #0061d5, mark BOX, kicker Box Drive (local sync), title <em>Box</em>).
  • Default Site Manager folder for box-local is Box.

Azure rename — single "Azure Storage" family

  • Folder default consolidated: both azure and azure-files now default to Azure Storage (was split as Azure Blob / Azure Files).
  • Status-bar protocol labels: Azure Storage (Blob) / Azure Storage (Files) (was Azure Blob / Azure Files).
  • Connected-state proto-mark title: Azure Storage <em>(Blob)</em> / Azure Storage <em>(Files)</em>.
  • Site-form dropdown options consolidated under the Azure Storage family.
  • The Azure swatch's Connect button now shows a chooser when both variants exist as saved sites (actionPolicy and connectFromToolbar candidate-list special-cases family === "azure" to expand to ["azure", "azure-files"]).

Frontend — palette swatch lock-when-live

  • The palette CSS for .swatch.locked / .swatch:disabled (opacity 0.30, grayscale, not-allowed cursor) was always present but the JS was actively removing those states. Restored the original intent: while the active tab has a live session, every non-matching swatch is disabled with a tooltip ("Connected to X — disconnect or open a new tab to switch to Y"). Switching to a not-connected tab unlocks them.
  • refreshSession() now snapshots wasConnected and re-invokes applyAccent() on connection-state flips so a stale "live" lock doesn't outlast a session that ended via polling refresh.
  • applyForcedAccent() await refreshSession() before deciding — catches the case where the JS state.connected is stale relative to the bridge.
  • proto-mark element gets direct inline background / borderColor / color in addition to CSS-variable updates, as belt-and-braces against any WebView2 CSS-var caching glitches.

DX — .env.local for dev/release scripts

  • New .env.local.example template, .env.local already in .gitignore. Both scripts/release.ps1 and scripts/test-endpoints.ps1 auto-source it via Invoke-Expression (Get-Content -Raw) (PowerShell quirk: dot-source only honors .ps1, ignores any other extension).
  • Operators no longer need to set Windows User-scope env vars manually — copy the example, fill in FTPROXY_VPS_HOST, and the scripts work.

Verified end-to-end

  • cargo test --lib241/241 (added 16 since 225: B2 aliases, 3 Glacier handler shape tests, 2 throttle scope tests, 6 NFS transport tests, 1 NFS alias, 3 bandwidth-extra/SessionSlot tests).
  • npm test122/122 (added 32 since 91: 9 Glacier+B2 surface tests, 4 Quick-preview, 4 Share-link, 3 user-templates, 6 NFS surface tests, 5 Bandwidth-UI tests, 1 universal-protocol coverage update).
  • cargo build --bin ftproxy-bridge --bin ftproxy-mcp --bin ftproxy-cli → all three binaries compile clean.
  • Live bridge sanity check (running v0.8.9): 49 sites load, 16 bookmarks intact, all existing protocols still functional — no regressions.
  • scripts/test-endpoints.ps125/25 against the IONOS SFTP target.
  • scripts/audit-protocols.py12/12.
  • scripts/audit-protocols-crud.py12/12 with deletes.
  • 5 scheduled jobs fired on 2026-05-01 9:00–9:20 AM EDT, one per protocol — all probe files landed and all schedule_history rows recorded as succeeded.

[Unreleased] — 2026-04-30 (Per-tab protocol intent, SMB swatch, FTPS server)

Frontend — per-tab protocol intent

  • New state.intendedProto: Map<sessionId, proto> captures each tab's protocol intent before connect. Survives tab switches so each tab keeps its own chip color independently.
  • + (new tab) button now: captures the new session id from POST /sessions, copies state._forceProto into intendedProto[newId], and immediately POSTs /sessions/active so the new tab activates and its chip renders in the right protocol color from the moment it appears.
  • Tab switch handler now calls applyAccent() + updateLayoutMode() after the switch, so chrome retints to the new active tab's protocol/intent. Tab close drops the closed tab's intent entry.
  • activeProto() resolution order: live session protocol → active tab's intent → global _forceProto"sftp".
  • applyForcedAccent() no longer refuses swatch clicks while connected. The live tab's chrome stays locked to its live protocol (because applyAccent() short-circuits on state.connected), but the click still primes _forceProto so the next + tab inherits the queued protocol.
  • New .swatch.intent (dashed border) marks a swatch that's been primed for the next + tab while the active tab stays on a different live protocol — visible distinction from .swatch.active (the swatch matching the chrome's current driver).
  • Removed the swatch-disabled-when-connected behavior — it was a UX dead-end where users connected to one protocol couldn't even click another swatch to queue intent for a new tab.

SMB protocol — swatch + accent + chip color

  • Added smb to PROTO_ACCENTS (slate-blue #94a3b8, mark SMB, kicker "Windows file share", title "SMB · CIFS"). Was missing — previously SMB sessions fell through to the SFTP green default.
  • Added <button class="swatch" data-proto="smb">SMB / CIFS</button> to the protocol palette in index.html.
  • Added --accent-smb: #94a3b8 token + .swatch[data-proto="smb"]
  • .tab .chip.smb CSS rules in styles.css.

Server-side — FTPS test surface

  • vsftpd 3.0.5 installed on the release VPS. Listens on :2121 (port 21 was rejected by the chosen non-IONOS uplink path), TLS via /etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem. Passive port range 40000-40100. ufw rules added.
  • Single test user, chrooted to /srv/ftps/<user>/ with a writable uploads/ subdir. Userlist allow-list locked to this account.
  • Verified end-to-end: TLS 1.3 negotiation + login flow confirmed via curl --ssl-reqd ftp://gogreentransit.com:2121/ from inside the box. (Local network outbound restricted to 22+443 prevents external test from this dev machine.)

[0.8.9] — 2026-04-28 (Phase E + F — calendar UI, schedule windows, unified Jobs)

The capstone of the jobs/automation track. Phase E shipped a calendar visualization + recurrence builder (Once / Daily / Weekly / Monthly / Custom-cron) with start/end gating. Phase F collapsed "Site Sync" and "Batch Job" into one unified Job concept — a job is a list of steps that run on a schedule, full stop.

Phase E — Calendar + recurrence builder + schedule windows

  • SiteSchedule and BatchJob gain start_at / end_at (Unix epoch seconds, optional). Scheduler tick gates firings outside the window. start_at lets users say "fire weekly starting May 5"; end_at lets them say "stop firing after Aug 31".
  • New endpoint GET /schedules/upcoming?from=&to= projects every scheduled firing within a time window. The calendar UI hits this per-month to populate day cells.
  • Frontend Server → Schedules… becomes a tabbed modal:
  • 📅 Calendar — month grid, today's cell outlined in accent, chips per scheduled firing colored by flavor (upload / download / mirror / batch). Click a chip → run-now / edit / details popover. Click an empty day → schedule builder pre-dated to that day. Prev / Today / Next navigation.
  • ☰ List — flat job list with run / edit / delete.
  • Schedule builder modal: recurrence dropdown (Once / Daily / Weekly / Monthly / Custom-cron), HTML5 <input type="time">, weekly day-of-week checkboxes, monthly day-of-month spinner, start/end date pickers, live cron preview, custom-cron escape hatch. No more raw cron-string-only entry.
  • Overlay modals (builder, fire popover, day-overflow, step editor) switched to fixed-position backdrop centering so they stack on top of the parent calendar instead of layouting below it.

Phase F — Unified "Job" concept

The mental model "Site Sync vs Batch Job" forced users to make a decision that didn't matter for most workflows. Phase F collapses both into one primitive:

  • A Job is a name + list of steps + schedule. A 1-step job is the simple case ("nightly folder backup"); an N-step job is the workflow case ("sync, wait, webhook").
  • New step type file-sync for the "I want to sync 1–2 specific files, not a whole folder" case. Takes siteId, direction, localDir, remoteDir, files[], optional policy. Direction must be upload or download (mirror rejected — no clear semantics for file-level mirror). Each file goes through the same per- session /sessions/:id/transfers/{upload,download} path the manual right-click flow uses.
  • Migration at startup: every saved site that still carries an inline schedule becomes a 1-step Job named "Schedule for <site>", then the inline site.schedule is cleared. Idempotent — re-running on a fully-migrated config is a no-op. Wired in lib::run_headless_bridge and the Tauri-window setup.
  • Native menu cleaned up: Server → Jobs… + Job history…. The redundant "Batch jobs…" entry is gone — the list tab inside Jobs… serves that purpose.
  • Site Form's inline Schedule section is replaced with a button: "📅 Schedule a job for this site →". It opens the Job builder pre-filled with one folder-sync step targeting the current site. Sites hold connection info; Jobs hold automation. Cleaner separation.

Wire-format fix (was a latent bug)

  • BatchStep enum's rename_all = "camelCase" only renamed variant tags (Syncsync), not the fields of each variant. The frontend was sending siteId/localPath but Rust expected site_id/local_path. Fixed by adding rename_all = "camelCase" to each variant individually. New file_sync_step_serializes_with_camelcase_fields test locks down the wire shape.

Logging

  • crate::batch_jobs::run now tracing::instrument'd with job_id in the span.
  • Per-step run_step instrumented with job + kind fields.
  • File-sync step emits start/finish info-level logs + state.push_log for the visible log pane.
  • Migration logs each site → job conversion at INFO with site_id, site name, new job_id, cron.

Tests — net +5 over v0.8.8

  • batch_jobs::tests::file_sync_step_serializes_with_camelcase_fields — asserts siteId/localDir/remoteDir on the wire and round-trips back through Deserialize.
  • batch_jobs::tests::job_with_schedule_window_serializes_camelcase — asserts scheduleStartAt/scheduleEndAt shape and that None skip-serializes.
  • batch_jobs::tests::migrate_site_schedules_creates_jobs_and_clears_inline — verifies migration shape + idempotency.
  • scheduler::tests::upcoming_in_window_respects_start_and_end_gating — 3 firings in a Mon–Wed window, none outside.
  • batch_step_kind_str_covers_all_variants extended to cover the new file-sync variant.
  • Lib total: 203/203.

UI build marker

  • v77-unified-jobs.

[0.8.8] — 2026-04-27 (Phases B+C+D of jobs/automation — session isolation, notifications, batch jobs)

Three more phases of the jobs/automation track land together so the shipped surface is coherent.

Phase B — Scheduler session isolation

  • state::SessionKind { Interactive, Background }. Background slots are filtered out of GET /sessions so the user's tab strip never flickers when cron fires.
  • ConnectRequest and DirSyncBody now accept an optional sessionId field. When set, the connect/sync targets that slot instead of the active one and does NOT switch active_session_id.
  • scheduler::fire_schedule creates a fresh background slot per firing, runs the loopback connect+sync against it, deletes it on the way out. The user's interactive transport is no longer stomped.
  • New tests: bridge::tests::list_sessions_hides_background_slots, bridge::tests::post_connect_with_session_id_targets_specified_slot.

Phase C — Notifications fan-out

  • New module crate::notify. Each scheduled-sync success/failure fires every configured channel best-effort, fire-and-forget. Errors go to the user-visible log via state::push_log.
  • Channels (env-driven, read at call time so .env edits don't need a recompile):
  • SlackSLACK_WEBHOOK_URL ({ "text": ... } payload)
  • DiscordDISCORD_WEBHOOK_URL ({ "content": ... } payload)
  • TelegramTELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID (Bot API sendMessage)
  • WebhookWEBHOOK_ON_SUCCESS_URL / WEBHOOK_ON_FAILURE_URL (generic JSON POST with full run record)
  • EmailSMTP_* env recognized, but the in-process sender is a stub (would need adding lettre to Cargo.toml). The test endpoint reports "configured but sender pending" so users with a populated SMTP block aren't confused.
  • New endpoint POST /notify/test { channel, message? } — fires one channel synchronously and returns the DeliveryResult envelope (configured, sent, error). Used by the future Settings UI's per-channel "Send test" button.
  • 6 new tests: channel wire-roundtrip, message-shape on success/ failure, alias normalization, plus two endpoint tests (notify_test_rejects_unknown_channel, notify_test_returns_not_configured_when_env_unset).

Phase D — Batch jobs

  • New module crate::batch_jobs. JSON store at <data_dir>/batch_jobs.json, atomic write, capped at 5000 rows.
  • BatchJob { id, name, enabled, scheduleCron?, steps[], createdAt, updatedAt }. Steps are tagged-enum:
  • sync — recursive /dir/sync against a saved site.
  • wait — pure delay (tokio::time::sleep).
  • webhook — fire-and-forget HTTP (POST/GET/PUT/DELETE) with optional JSON body.
  • Run engine: walks steps sequentially, fail-fast on first error. Sync steps reuse Phase B's session-isolation (each sync runs against a fresh Background session and is recorded in schedule_history with batchId set so the cross-site Job History view groups them).
  • Bridge endpoints:
  • GET /batch-jobs — list
  • GET /batch-jobs/:id — one
  • POST /batch-jobs — create
  • PUT /batch-jobs/:id — update
  • DELETE /batch-jobs/:id — delete
  • POST /batch-jobs/:id/run — trigger now (returns RunSummary)
  • Scheduler integration: each tick now also walks BatchJobs with a populated scheduleCron and fires those due in the same window.
  • schedule_history::record_finish_with_batch overload so batch steps stamp the run rows with batch_id. The free-floating record_finish keeps its existing signature for back-compat.
  • UI: Server menu → Batch jobs… is no longer a placeholder. Lists every job with run / edit / delete actions; editor supports add/reorder/remove of sync, wait, webhook steps; per-step inline modal for fields. Built with the same div-tree pattern as the Site Manager redesign.
  • 5 new module tests (upsert_assigns_id_and_persists, upsert_updates_existing_preserves_created_at, delete_removes_only_matching_id, batch_step_serializes_with_type_tag, batch_step_kind_str_covers_all_variants)
  • 2 endpoint tests (batch_jobs_crud_roundtrip, batch_jobs_run_unknown_id_returns_500).

Tests

  • Lib: 198/198. Combined gain across phases: +13 over v0.8.7.

UI build marker

  • v73-phases-bcd-jobs-notify-batch.

[0.8.7] — 2026-04-27 (Schedule history — Phase A of jobs/automation track)

First slice of the cron/jobs/batch initiative. Phase A ships audit + visibility for the existing scheduler so users can see what fired, when, and what happened. Phases B (session-isolated firing), C (notifications), D (batch jobs) follow.

New module — schedule_history

  • src-tauri/src/schedule_history.rs. Mutex-guarded JSON store at <data_dir>/schedule_history.json. Capped at 10 000 rows; oldest truncated when the cap is reached. Atomic writes via .tmp + rename.
  • ScheduleRun carries: id (uuid), site_id, site_name, batch_id (optional, reserved for Phase D), started_at, finished_at, direction (upload/download/mirror), local_path, remote_path, status (running/succeeded/failed/cancelled), stats: ScheduleRunStats { uploaded, downloaded, failed, bytes_in, bytes_out }, error (optional), triggered_by (scheduler for cron, manual for run-now, reserved values for Phase D batch triggers).
  • Public API: record_start, record_finish, list(since, limit), list_for_site(site_id, since, limit), purge_older_than(retention_days), clear_all, clear_one.
  • 6 unit tests covering happy paths + the since_ts cutoff filter
  • bounded cap behavior. Tests serialize via a TEST_LOCK so the shared on-disk file doesn't race.

Scheduler wired to record every firing

  • scheduler::fire_schedule now returns anyhow::Result<ScheduleRunStats> instead of Result<()>. It parses /dir/sync's response envelope to extract uploaded / downloaded / failed counts.
  • scheduler::scheduler_loop calls record_start immediately before each fire (status=running), and record_finish after with succeeded + stats or failed + error string.
  • triggered_by set to "scheduler" for cron firings, "manual" for POST /scheduler/run-now invocations.

4 new bridge endpoints (auth-gated)

  • GET /schedule-history?since=<ts>&limit=<n> — global list across every site. Defaults: since=0, limit=200.
  • GET /sites/:id/schedule-history?since=<ts>&limit=<n> — per-site list. Default limit=50.
  • DELETE /schedule-history — clear all rows. Returns { cleared: true }.
  • DELETE /schedule-history/:run_id — delete one row. Returns { removed: true }; 404 if the id is unknown.
  • All four wired in bridge::build_router and emit tracing::info on each call. Two new handler tests assert envelope shape + auth gate.

UI — top-menu access + per-site panel

  • Server menu → Job history… — opens a cross-site modal showing the most recent 500 runs. Status icons (✓ ⊘ ✗ ⚠), site name, direction, timestamp, duration, stats line N↑ N↓ N✗, expandable error text. Refresh button + Clear-all button + per- row delete.
  • Server menu → Batch jobs… — placeholder modal explaining the feature lands in Phase D (multi-site sync, cross-site copy, wait/webhook steps, scheduled batches with run history).
  • Site Form → Schedule section → "Recent runs" panel — fetches GET /sites/:id/schedule-history?limit=20 and renders each run with the same status icons + stats. Same panel where the Run-now button lives, so users can fire a schedule and immediately see the row appear.
  • Two new menu IDs added to NATIVE_MENU_IDS (server_job_history, server_batch_jobs); Rust-side menu install_native_menu and JS-side action router both handle them.

.env plumbing for Phase B/C/D (forward-looking only — not yet wired)

  • .env.example (new) and gitignored .env seeded from the master keys file. Active settings today: RUST_LOG, telemetry off, Telegram bot token + chat ID populated, SMTP populated for the release VPS's alerts mailbox.
  • Forward-looking slots: SCHEDULER_TICK_INTERVAL_SECONDS, JOB_HISTORY_RETENTION_DAYS, POLLING_INTERVAL_SECONDS, WEBHOOK_ON_SUCCESS_URL / WEBHOOK_ON_FAILURE_URL, SLACK_WEBHOOK_URL, DISCORD_WEBHOOK_URL, STRICT_HOST_KEY_CHECKING, CONCURRENCY_DEFAULT, TRANSFER_RATE_KIB. Phases B–D will pull these in.

Tests

  • Lib: 183/183. New: schedule_history::tests (6 cases), bridge::tests::scheduler_run_now_returns_ok_envelope, bridge::tests::scheduler_run_now_requires_auth.
  • Endpoint suite (scripts/test-endpoints.ps1) unaffected — Phase A endpoints exercised in lib tests.

UI build marker

  • v72-phase-a-schedule-history.

[0.8.6] — 2026-04-27 (Site Manager polish + smart Connect + run-now + e2e scaffolding)

UX polish pass on top of v0.8.5. Same protocols, smoother flows.

Site Manager redesign

  • Replaced the native <select> with a custom div-tree list. Each protocol group has a sticky header with the accent-colored chip pulled from PROTO_ACCENTS (SFTP teal, Dropbox blue, Drive yellow, S3 orange, Azure cyan, etc.) so the list visually matches the swatch palette + chrome.
  • Site rows show name (primary), host + folder in monospace (secondary, dimmed), and an inline auth-status badge:
  • ✓ AUTH (green) — OAuth signed in, hover reveals expiry
  • ⚠ NO AUTH (warn) — needs sign-in
  • LOCAL (faint) — filesystem-mirror site
  • Hover state (var(--bg-row-hover)) on every row; selection state (accent-soft + 3px accent border on the left).
  • Click selects, double-click connects (no need to click the Connect button).
  • Subhead counter shows 41 sites · 7 protocols unfiltered or 5 of 41 sites when filtering.
  • Empty / no-match state shows a centered placeholder instead of a hollow box.

Smart toolbar Connect

  • Toolbar Connect button now intelligent for cloud protocols: shows only when at least one saved site exists for that family, and on click connects directly to it (skips Site Manager). Tooltip shows "Connect to ". SFTP/FTP/FTPS/WebDAV/SMB keep their existing Quick Connect modal flow (these typically have many sites).
  • When 2+ sites of the same cloud family exist (e.g. Dropbox-OAuth + Dropbox-local), a small chooser modal appears so the user can pick. Local-sync sites sort first (faster path).
  • Native menu's "Quick connect…" (Ctrl+Q) still opens the modal directly — power-user path unchanged.

Site Manager search/filter

  • Search input above the list filters by substring match against name + host + folder. Re-renders on each keystroke.
  • Auth-status badges (✓ AUTH / ⚠ NO AUTH / LOCAL) inline next to each site name. Pre-fetched via oauth_status Tauri command on Site Manager open.

"Run schedule now" button

  • New button in the Site Form's Schedule section. Hits new bridge endpoint POST /scheduler/run-now (auth-gated like every other protected endpoint).
  • Bridge handler delegates to scheduler::run_once_now(), which fires any schedule due in the past 24h. tracing::instrument + INFO/WARN logging on success/failure; mirrored to user-visible state log.
  • Useful for testing a new cron entry without waiting for the next tick.

Per-vendor -local accent entries

  • New PROTO_ACCENTS entries for dropbox-local / gdrive-local / onedrive-local with marks DBX·L / GDR·L / 1DR·L, kickers "Filesystem mirror (Dropbox)" etc., and titles like "Dropbox · local sync". Same color as the API counterpart so the chrome remains consistent; only the badge text differs.
  • applyAccent lookup priority: full key first, then -local- stripped fallback, then SFTP safety net.

Live integration test scaffolding (gated on env vars)

  • tests/webdav_e2e.rs (WEBDAV_E2E=1 + URL/USER/PASS) — connect, list, upload+download+verify byte-exact, delete.
  • tests/azure_files_e2e.rs (AZURE_FILES_E2E=1 + storage account/key/share) — exercises the same SMB-shape translation the bridge does, then connect+roundtrip.
  • tests/gcs_e2e.rs (GCS_E2E=1 + bucket + service-account JSON inline or via _FILE path) — connect+roundtrip.
  • tests/oauth_refresh_e2e.rs (OAUTH_REFRESH_E2E=1 + provider/key/ client_id of a previously signed-in site) — exercises the refresh- if-needed path live, then hits the provider's userinfo endpoint with the freshly-rotated access_token to confirm it actually authenticates.
  • All four short-circuit with a "skipping" message when the gate env isn't set, so they don't break the default test cycle.

Test additions

  • bridge.rs route tests:
  • scheduler_run_now_returns_ok_envelope — POST works with valid bearer.
  • scheduler_run_now_requires_auth — POST without bearer → 401.
  • secret_extras_endpoint_returns_empty_for_site_without_secrets
  • secret_extras_endpoint_404s_for_unknown_site
  • Test count: 177 unit + 16 integration (was 173 + 12 at start of v0.8.6 work).

Misc fixes

  • transport module is now pub mod so integration tests in tests/ can construct ConnectionInfo and call transport connect() directly (mirrors what places::cloud_sync_folders and friends already do).
  • places.rs::cloud_sync_folders Google-Drive detection: <drive>: (no trailing backslash) was being treated by PathBuf::join as "current dir on G:" rather than the drive root, producing G:My Drive instead of G:\My Drive. Normalize trailing separator before join.
  • oauth.rs::load() no longer silently returns None on keyring failures. Logs tracing::warn! on entry-build / read errors / JSON corruption (still treats NoEntry as quiet, since "user hasn't signed in yet" isn't an error).
  • Removed the stray let _ = (); // marker for clarity from the refresh-decision branch in oauth.rs.
  • 20 dead-code warnings → 0 (cargo fix removed two unused imports; 18 intentional API-surface entries got #[allow(dead_code)] with per-item justifications).

[0.8.5] — 2026-04-27 (OAuth refresh + local cloud-sync transport + Azure Files)

A long session focused on two things: making cloud connections actually pleasant to set up, and finishing the OAuth refresh-token plumbing so per-user OAuth apps work without re-pasting tokens every 4 hours.

Per-user OAuth with refresh-token rotation (Dropbox, Google Drive, OneDrive)

  • oauth.rs: added Dropbox to provider_config() (auth + token URLs + token_access_type=offline extra). Threaded client_secret through the whole flow so confidential clients (Dropbox; Google "web" / installed app types) can authenticate. Made PKCE conditional via a pkce: bool field on ProviderConfig — Dropbox returns invalid_response_type if PKCE is sent when the app's "Allow public clients" toggle is off, so we omit code_challenge for Dropbox and send it for Google + Microsoft.
  • save() now strips access_token before persisting — long Dropbox short-lived tokens (~1331 chars × UTF-16 = 2662 bytes) blow past Windows Credential Manager's 2560-char attribute limit. The small refresh_token + client_id/secret/expires_at fit fine; access_token is minted on demand by refresh_if_needed.
  • refresh_if_needed() triggers when access_token is missing OR within 60s of expiry.
  • Bridge post_connect: for dropbox/gdrive/onedrive sites with a stored OAuth identity, calls refresh_if_needed and injects the fresh access_token as info.password automatically. Transport stays oblivious.
  • New endpoint GET /sites/:id/secret-extras returns keychain-stored extras (client_secret, service_account_json) so the Site Form can pre-populate masked fields on edit.
  • client_secret added to credentials::SECRET_EXTRA_KEYS — never serialized to sites.json plaintext.

Local cloud-sync transport (Dropbox / OneDrive / Google Drive desktop clients)

  • New transport/localcloud.rs: filesystem-backed Transport that treats the desktop client's sync folder as the "remote". Reads and writes are instant (filesystem speed); the desktop client uploads to the cloud asynchronously. Routed via aliases: dropbox-local, onedrive-local, gdrive-local, icloud-local, localcloud. Path-traversal sandbox via component walk (rejects ..).
  • places::cloud_sync_folders() upgraded:
  • Reads %LOCALAPPDATA%\Dropbox\info.json (and ~/.dropbox/info.json on Mac/Linux) → finds custom Dropbox sync folders even when the user moved them off C:.
  • Reads OneDrive registry (HKCU\Software\Microsoft\OneDrive\ Accounts\*\UserFolder via winreg) → finds personal + every business account at their actual paths.
  • Scans Windows drive letters for <drive>:\My Drive → finds Google Drive virtual mount even when volume label is empty.
  • Site Form auto-detects the desktop client on form open. When found: defaults to local-sync mode with the path pre-filled (zero OAuth). When not found: falls back to OAuth with a hint that installing the desktop client would skip OAuth entirely.
  • Save handler transforms the protocol to <base>-local when local mode is selected; on edit, recognizes the -local suffix and shows the dropdown's base protocol with mode toggle preset to local.

Azure Files as a first-class protocol (separate from Azure Blob)

  • New azure-files protocol. Site form fields = storage account name + access key + share name. On connect, bridge translates these into SMB shape (\\<account>.file.core.windows.net\<share>, Azure\<account> username, key as password) and dispatches to SmbTransport. Dropdown now shows two distinct entries: "Azure Blob — object store" and "Azure Files — SMB-mountable file share".
  • Top "Azure Blob" swatch renamed to "Azure Storage" — same chrome accent (Azure cyan) covers both Blob and Files. PROTO_ACCENTS adds a distinct entry for azure-files so the badge text reads "Azure Files" with kicker "File share (SMB)" when connected to Files (vs "Azure Blob" / "Blob container" for the API protocol).

Site Manager UX polish

  • applyAccent and activeProto reordered: live-session protocol wins over _forceProto. When connected, all swatches except the live one are visually disabled (opacity 0.30, not-allowed cursor, tooltip explaining how to switch). Click a non-matching swatch and it's a no-op with a wire-console warning.
  • applyForcedAccent clears _forceProto after a successful connect so the new session's chrome takes over.
  • Cloud protocols (S3 / Azure Blob / GCS / Dropbox / GDrive / OneDrive) hidden from the toolbar "Connect…" Quick-Connect button via actionPolicy — those need extras Quick Connect can't capture, so we route them through Site Manager instead.
  • Site Manager grouped by protocol (SFTP (n), FTP (n), Amazon S3 (n), etc.). User-set folder shows as parenthetical metadata next to the host. New sites auto-bucket to a sensible folder via default_folder_for_protocol (no more "ungrouped" creep).
  • Render fix: when host is empty (S3, Azure, etc.), drop the — — placeholder. Just show the name.
  • Per-protocol info icons (ⓘ): every cloud + filesystem protocol has a clickable icon next to its title with setup instructions and a "Copy to clipboard" button. Content covers register-an- OAuth-app walkthroughs (Dropbox / Google / Microsoft), AWS IAM setup for S3, Azure storage account setup for Blob and Files, GCS service-account-JSON pattern, and the local-sync paths.

Windows URL launcher fix (root cause of the OAuth runaround)

  • lib.rs::open_url_in_browser on Windows now uses rundll32 url.dll,FileProtocolHandler instead of cmd /C start "" url. cmd parses & as a command-chain operator, truncating any URL with multiple query params at the first &. Every OAuth provider returns a misleading "Unexpected response_type" because the truncated URL has no response_type left. rundll32 receives the URL as a single argument and dispatches via Windows shell URL handling, preserving the full string. Lesson saved to tasks/lessons.md and ~/.claude/projects/.../memory/.

OAuth callback fixed-port + redirect URI

  • oauth::start_callback_listener now binds on port 53682 (the convention gcloud uses for desktop OAuth flows) before falling back to a random port. The redirect URI uses localhost (not 127.0.0.1) because Dropbox treats http://localhost as a wildcard for any port when whitelisting redirect URIs but http://127.0.0.1 literally.
  • Users register http://localhost:53682/oauth/callback once in their Dropbox app's Redirect URIs. Google + Microsoft honor the loopback wildcard so just http://localhost registration works for those.

Test additions

  • tests/s3_multipart_resume.rs (gated S3_RESUME_E2E=1) — two cases against MinIO at 127.0.0.1:9010: full 6-phase resume contract (Create → UploadPart×2 → ListMultipartUploads → ListParts → resume UploadPart#3 → Complete → GetObject byte-exact match), and AbortMultipartUpload clears pending entry.
  • tests/sendto_syntax.rs — 5 cases parsing the macOS Info.plist
  • document.wflow via the plist crate, and validating the Linux .desktop entry against FreeDesktop spec keys.
  • tests/updater_check.rs — 3 cases against tauri_plugin_updater:: RemoteRelease: spins a localhost axum server, deserializes a latest.json payload, validates download_url() + signature() per target, and asserts version-comparison logic both up and down.
  • New oauth.rs tests: Dropbox provider config (offline, no PKCE), PKCE conditional in auth URL build, access_token strip in save, refresh-decision branches.
  • New places.rs tests: Dropbox info.json parsing (personal / business / malformed JSON degrades to None), OneDrive registry smoke, cloud_sync_folders return type.
  • New bridge.rs tests: localcloud + azure-files in normalize_ protocol, Azure Files SMB-shape translation guard, every supported protocol covered by default_folder_for_protocol (no fall-through to "Other").
  • Test count: 173 unit + 12 integration (was 157 + 12 at start of session).

OneDrive registry detection

  • See places.rs section above. Survives unlink + relink to a custom folder location. Reads DisplayName for friendly Business labels.

Logging hardening

  • oauth::load() now logs warnings on keyring entry-build failures (tracing::warn!) and on stored-token JSON corruption. Was silently returning None — operators had no way to triage.
  • bridge.rs OAuth refresh failure already logs to user-visible state log; kept.

[0.8.4] — 2026-04-26 (Production-grade observability + scaling foundation)

Six items from the architecture-review pass — everything that makes the bridge a credible API for other apps in production, without adding Redis or going multi-process.

Prometheus /metrics endpoint

  • New metrics.rs module installs metrics_exporter_prometheus recorder at startup (idempotent). Exposes /metrics text/plain scrape body — pre-auth (loopback-only deployment makes this safe).
  • HTTP middleware records ftproxy_http_requests_total{method, path, status} counter and ftproxy_http_request_duration_seconds histogram on every request. Path is collapsed to the matched-route pattern (no per-id explosion).
  • Transfer terminal-state hook fires ftproxy_transfers_total{ direction, protocol, status}, ftproxy_transfer_bytes_total, and ftproxy_transfer_duration_seconds per transfer.
  • Queue and session gauges (ftproxy_queue_depth, ftproxy_sessions_connected) updated on every /health poll — Grafana sees them at the scrape cadence.

JSON access logs (opt-in)

  • RUST_LOG_FORMAT=json switches tracing-subscriber to its JSON formatter so headless deploys can ship logs cleanly into Loki / ELK / Datadog without an intermediate parser. pretty and full also accepted; default stays human-readable so desktop dev experience is unchanged.
  • tracing-subscriber gains the json feature.

Per-token rate limiting

  • New rate_limit.rsgovernor token-bucket keyed on the Authorization header. Default 600 req/min (10/s with a 100-rps burst window) per token, configurable via AppConfig.rate_limit_per_minute (0 disables).
  • /health, /metrics, /events are bypassed so liveness probes and Prometheus scrapers can poll freely.
  • 429s are counted in the metrics counter alongside 200s.
  • PUT /config clears every token's bucket so rate-limit changes take effect on the next request.

Richer /health

  • Returns status (ok / degraded), version, queue depth broken out by status (running / pending / failed / complete), last-minute transfer count + error count + bytes, session count.
  • degraded fires when failures are >50% of the last-minute traffic — useful for k8s readyz-style probes that want a single bool to drive load-balancer membership.
  • The full protocol + capability list is surfaced (was hard-coded to the 0.4.x set; now reflects all 11 protocols + scheduler + metrics + rate-limit capabilities).

Keychain access cache

  • credentials.rs gets an in-process LRU (60 s TTL, 256-entry cap) that wraps every keyring::Entry::get_password call. Repeated reads for the same site within a short window now skip the OS keychain round-trip. set and remove write through.
  • Eviction policy: oldest-first when at cap. Manual cache_clear() for tests + Edit → Clear private data.

Multi-runtime split

  • Transfer work runs on a dedicated tokio runtime (capped at 8 worker threads, named ftproxy-xfer-*). HTTP handlers spawn transfer work onto it via state.transfer_runtime.spawn(...) and await the JoinHandle.
  • Result: a slow upload that blocks reading 10 GB off disk no longer starves the HTTP runtime's worker threads. /health polls and /metrics scrapes stay fast under transfer load.
  • The transfer runtime is leaked at process exit (intentional — prevents tokio's Drop from blocking on outstanding tasks at shutdown).

Tests

  • cargo test --lib155/155 (was 147). New cases cover metrics install / render / counter increment, rate-limit bypass-path detection + quota construction, keychain cache put / get / invalidate / cap-eviction round-trips.
  • npm test (Vitest) → 39/39.

What's still NOT here (and intentionally so)

  • Redis / BullMQ / external broker — not needed at single-host scale. The single-process tokio model with queue.json persistence handles thousands of concurrent file-handle ops on one core. The point at which Redis becomes correct is "multiple bridge instances sharing one job pool" — until then it's operational overhead with zero throughput gain.

[Server / deployment] — 2026-04-26 (IONOS site catalog)

Not a code release — server-side provisioning + FTProxy site catalog expansion. Documented here so the team has a single place to find "what SFTP accounts exist and where do they point."

14 chrooted SFTP users on the release VPS

Up from 1. The setup is driven by ~/setup-sftp-domains.sh on the dev box (idempotent — re-run after adding a new domain). Actual host + per-user account names live in the operator's notes, not this changelog.

  • 12 per-domain users (one per /var/www/<domain>/ docroot), naming pattern <domain-slug>_ftp (one user covers a frontend + matching -API backend where they share a docroot tree).
  • 1 shared deploy_apps_ftp/home/deploy/apps/ (covers the 21 Dockerized app sources: Boomer_AI, EverythingBeer, MangyDog, Maximus, NaggingWifeAI, PRT, SCO, Sales_AI, SellMe variants, Tutor_AI, Voting, etc.)
  • 1 shared wordpress_ftp/var/www/html/Wordpress (covers the gogreenwpplugins.com trio of vhosts)

Server-side pattern

  • Match Group sftpusers block in sshd_config — ChrootDirectory /srv/sftp/%u, ForceCommand internal-sftp, no TCP forwarding, no X11. One block covers every per-domain user.
  • Each user's chroot is /srv/sftp/<username>/ (root:root, mode 755).
  • Each docroot is bind-mounted into the chroot via /etc/fstab so the SFTP user sees /<domain>/ after login.
  • ACL setfacl -R -m g:deploy:rwx -m d:g:deploy:rwx <docroot> so the SFTP user can write without disturbing whatever owner already writes (www-data, root, systemd services, etc.). Default ACL inherits to new files.

Operational gotchas (also in tasks/lessons.md + TROUBLESHOOTING.md)

  • Reload vs restart: after editing sshd_config, you must systemctl restart ssh.socket (not reload ssh.service) on socket-activated systemd setups (Ubuntu 22.04+). Reload won't re-read the config for the listening socket.
  • Bridge upload semantics: /transfers/upload's remotePath is the parent directory; the bridge appends the local file's basename. Pass /site/, not /site/file.txt.
  • chroot perms: chroot dir + every parent must be root:root with no group/world write. The bind-mounted docroot inside the jail can be group-writable — only the chroot dir itself can't.

Smoke-tested

All 14 sites verified end-to-end via ~/load-and-test-sites.py: connect → upload tiny test file → download → byte-match → delete. 14/14 green. Test files use timestamped + UUID-suffixed names so they never conflict with existing content; cleanup is automatic.

FTProxy state

14 sites in the Go Green VPS folder of Site Manager. Passwords random-32-char per user, stored only in the OS keychain via the bridge's /sites POST path.

[0.8.2] — 2026-04-26 (Gap-analysis pass)

Closing the second-tier gaps identified in the post-0.8.1 audit. Most of these are individually small but materially raise the floor on day-to-day usability.

Managed sites (fleet config)

  • New read-only site source: <data dir>/managed-sites.json or $FTPROXY_MANAGED_SITES. IT pushes a curated list via Group Policy / SMB share / config-management tool; Transit merges it with the user's editable catalog at startup. Managed sites get read_only: true, the bridge refuses PUT/DELETE on them with a clear "managed by IT" message, and the user-side sites.json never persists them — so admin updates flow through cleanly without divergence.
  • 3 new unit tests: load-marks-readonly, id-collision-managed-wins, missing-file-is-silent.

FTP REST resume

  • download_to_offset impl on FTP transport — sends REST <offset> before RETR. Closes the resume gap for FTP, matching SFTP from 0.8.0. FTPS, S3, Azure, GCS still degrade Resume to Overwrite (cloud Range/multipart-resume = future work).

Transfer queue speed display

  • Live MiB/s on each running transfer row, computed via 0.4-alpha EWMA of the 250 ms progress samples — fast enough to react to throughput changes, slow enough not to jitter wildly. Added transfer.skipped to the WS event handler so Skip-policy transfers cleanly clear the progress speed map.

Connection polish (data plumbing)

  • New AppConfig fields: auto_reconnect, keepalive_interval_secs, proxy_url. The runtime hook for the first two is on the 0.8.3 list; the proxy field is fully wired.

HTTP / SOCKS5 outbound proxy

  • transport::apply_http_proxy(builder, info) — drop-in helper that honors AppConfig.proxy_url for any reqwest::ClientBuilder. Wired into WebDAV, GCS, Dropbox, Drive, OneDrive transports. SFTP / FTP / S3 / Azure don't go through reqwest and continue to use direct connections (documented).
  • socks reqwest feature added so socks5://user:pass@host:1080 URLs work alongside http://... / https://....

Recently-used hosts

  • LocalStorage-backed last-10 hosts list. Surfaced as a <datalist> on the Quick Connect host input — hostname autocomplete on every successful connect.

Filename filters (glob)

  • Per-pane glob filter via View → Filter local files… / Filter remote files…. Supports *, ?, {a,b} brace expansion, space-separated multi-pattern, and !-prefix exclusion. Persists per pane across restarts. Directories always pass the filter so navigation isn't broken.

Wire console export

  • View → "Export wire console to file…" downloads the visible console buffer (last ~300 TX/RX/SYS/WARN/ERR rows) as a timestamped .log file. Useful for filing a bug report without screenshots.

Backup / restore everything

  • Edit menu → "Backup everything to JSON file…" writes a single bundle (sites + bookmarks + config + hostkeys + recent hosts + filters) the user can move to another machine. Restore reads it back via the bridge's CRUD endpoints. Token + per-site passwords stay in the OS keychain and aren't in the bundle — by design. Managed (read-only) sites aren't in the bundle either; the next machine picks them up from its own managed-sites.json.

About modal: User Guide + Issues + Bridge details

  • Three buttons in About — User Guide opens USER-GUIDE.md on the docs site, Report an issue opens the issues URL, Bridge details surfaces the same modal we always had. Override-able via window.FTPROXY_DOCS_URL / window.FTPROXY_ISSUES_URL.

Tests

  • cargo test --lib142/142 (was 139). Three new tests for managed-sites behavior.
  • npm test (Vitest) → 39/39.

Build marker

  • Frontend: UI build: 2026-04-26 v50-gap-pass.

Scheduled sync (full impl)

  • New scheduler.rs — tokio task that ticks every 30 s, evaluates every site's schedule.cron against the (last-tick, now] window, fires a /dir/sync for any that's due. Uses the bridge's own loopback HTTP path so the queue, throttle, persistence, and event stream all see the scheduled transfer the same way a UI-driven one would.
  • Cron format: standard 5-field min hr dom mon dow with * and comma-lists. The cron crate is wrapped to add a synthetic seconds field so it agrees with the user-facing 5-field surface.
  • Headless ftproxy-bridge runs the same scheduler — Windows Task Scheduler can fire a backup at 3 AM with the desktop closed.
  • New POST /dir/sync endpoint — recursive compare + per-file transfer dispatch, direction = upload / download / mirror, per-transfer overwrite policy.
  • Site Form gets a Schedule section: enabled checkbox, cron input, direction dropdown, local path, remote path, conflict policy.

Open-remote-in-editor (full round-trip)

  • New editor.rs — Tauri command open_remote_in_editor downloads to %TEMP%/ftproxy-edit/<sha8>/<basename>, launches the OS default editor (cmd /C start "" / open / xdg-open), and spawns a notify crate FS watcher debounced 500 ms that re-uploads on every save via the bridge's existing /transfers/upload.
  • Watcher TTL is 4 h — it self-terminates and cleans up the temp file. Re-opening the same file before TTL just relaunches the editor against the existing watcher (no duplicate watcher leak).
  • Right-click on a remote file → "Open in editor (auto-reupload on save)".

Auto-reconnect runtime

  • SessionSlot now caches the last successful ConnectionInfo. New dispatch_connect() rebuilds the transport from any cached info; new reconnect_session() helper tears down the dead transport and rebuilds. Every transfer-download wraps once in a retry harness: on a transient error (broken pipe / EOF / connection reset / WSA 10053-10054 / EPIPE / timeout) it auto-reconnects and re-issues the call from the resume offset. Honors AppConfig.auto_reconnect (default on); never retries on cancellation or auth failure.
  • New POST /sessions/:id/reconnect endpoint for explicit reconnects from scripts.

Keep-alive ping

  • Per-session tokio task spawned on connect, joined on disconnect. SFTP / FTP / FTPS sessions ping every AppConfig.keepalive_interval_secs (default 60) by issuing a cheap list("."). Other protocols (HTTP-based) skip the ping — each request is its own connection. Cancelled and re-armed cleanly on reconnect so we never run two pings on the same transport.

S3 multipart upload-resume

  • upload() now calls ListMultipartUploads first. If a pending upload for this key exists (from a previous interrupted run), it pulls the already-uploaded parts via ListParts, carries their etags forward, and continues from the next part_number — no bytes re-sent, no double-billing on dangling part charges.
  • UploadPart failures no longer auto-abort — the upload-id is left pending so the next call resumes. New S3Transport::abort_pending_uploads(remote) for explicit discard-and-restart.
  • CompletedMultipartUpload parts are sorted by part_number before the final Complete call so resumed-then-extended uploads serialize correctly.

macOS / Linux Send To equivalents

  • Renamed conceptually from "Send To shortcut" to platform-appropriate surfaces:
  • Windows: existing .lnk in %APPDATA%\Microsoft\Windows\SendTo\.
  • macOS: full Automator Quick Action workflow dropped into ~/Library/Services/. Right-click any file in Finder → Quick Actions → "Send to Go Green Transit" runs ftproxy-cli against each selected path. Menu refresh via pbs -flush.
  • Linux: .desktop file in ~/.local/share/applications/ with MimeType=application/octet-stream;text/plain;… so Nautilus/Dolphin/Thunar surface "Open With → Go Green Transit" on every file. Best-effort update-desktop-database refresh.
  • Module renamed from "Windows-only" docstring; Tauri command works on every platform.

Auto-updater (Tauri plugin)

  • tauri-plugin-updater 2 wired into the Tauri builder. New check_for_updates Tauri command surfaces an Update / "you're on the latest build" prompt via Help → Check for updates…
  • tauri.conf.json carries the plugin config — endpoint URL + signing pubkey. Empty pubkey = inert (no-op check, no errors). Once the user fills in their release host + the pubkey from tauri-cli signer generate, updates flow.
  • capabilities/default.json grants updater:default.

Code signing / notarization config

  • New SIGNING.md walks through the production-release pipeline: Tauri updater signing keypair, Windows EV / OV cert + signtool env vars, macOS Developer ID + notarization via xcrun notarytool, GitHub Actions release workflow with the right secret bindings.
  • tauri.conf.json.version bumped to 0.8.3.

Tests

  • cargo test --lib147/147 (was 142). Three new scheduler tests, two new editor tests already counted in 0.8.2 totals; this round's surface (auto-reconnect, S3 resume) is harder to unit-test without a live server, so coverage there is via the endpoint smoke suite.
  • npm test (Vitest) → 39/39.
  • Frontend build marker bumped to UI build: 2026-04-26 v51-no-defers.

[0.8.1] — 2026-04-26 (Places + SMB protocol)

Local pane: drives + quick locations + Map Network Drive

  • New 📁 Places button on the local pane opens a popover listing:
  • Quick locations from directories::UserDirs — Documents, Downloads, Desktop, Pictures, Music, Videos, Public, Home.
  • Drives the OS reports — every fixed disk (C:, D:), removable (USB / SD / CD), and mapped network drive (Z: from net use). Windows: GetLogicalDriveStringsW + GetDriveTypeW + GetVolumeInformationW. Mac: /Volumes/*. Linux: /media/<user>, /mnt, /run/media/<user>, /run/user/<uid>/gvfs/.
  • Map network drive… action — runs net use Z: (Win) / mount_smbfs (Mac) / mount -t cifs (Linux). Picks the first free drive letter on Windows when none specified.
  • New places.rs module + 4 new Tauri commands: list_drives, list_quick_locations, map_network_drive, unmap_network_drive.

SMB / CIFS as a real Transit protocol

  • New transport::smb — mount-and-proxy strategy. On connect, calls places::map_network_drive to mount the share via the OS's native SMB stack; every file op (list, mkdir, download_to, upload_from, etc.) runs through tokio::fs against the mount root. On close(), unmounts.
  • Site Form / Quick Connect both expose SMB / CIFS — Windows file share / NAS as a protocol option. SMB extras: share, domain, drive_letter, mount_path (the last skips auto-mount and just attaches to a path the user pre-mounted).
  • Host accepts \\server\share, smb://server/share, or just a bare server when the share extra is set. Anonymous connections work when username/password are blank.
  • normalize_protocol accepts smb / cifs / samba aliases.
  • Bandwidth throttle is wired into SMB's streaming download/upload paths just like every other transport.

Tests

  • cargo test --lib139/139 (was 134). New tests cover:
  • Places: smoke test for drive enumeration + quick-locations presence.
  • SMB transport: pre-mounted-path attach (no real net use call, uses a temp dir as the mount root) and bare-host-without-share error path.
  • UNC smb://\\ normalization round-trip.
  • npm test (Vitest) → 39/39.

Build marker

  • Frontend: UI build: 2026-04-26 v45-places-and-smb.

[0.8.0] — 2026-04-26 (MVP → PRD pass: FileZilla-parity feature push)

This release closes 11 of the 15 cross-product gaps the audit flagged between Transit and FileZilla / WinSCP / Cyberduck. The four remaining items (mount-as-drive, scheduled sync, open-remote-in-editor, i18n, OpenStack Swift transport) are each multi-day undertakings on their own and are deliberately staged for 0.8.1+ rather than half-shipped here. See "Deferred" below.

Persistent transfer queue + Resume policy

  • <data dir>/queue.json is now updated on every push / terminal-state change. On restart, anything that was running / pending / queued becomes interrupted with bytesResumed armed to the byte count we'd already transferred — Retry-with-Resume picks up from there for SFTP downloads. (FTP / cloud transports degrade Resume to Overwrite with a tracing warning; protocol-level resume is the only piece that didn't make this cut.)
  • New OverwritePolicy enum on TransferJob: Skip / Overwrite / OverwriteIfNewer / Resume / Rename.
  • Bridge accepts policy on /transfers/upload and /transfers/download. Falls back to AppConfig defaultOverwritePolicy when missing.
  • New Transport::download_to_offset(remote, w, offset, …) trait method. SFTP overrides with a real seek+stream impl; everything else uses the default download_to fallback.

Conflict resolution UX

  • Server-side handlers now check existence + mtime before kicking off a transfer. OverwriteIfNewer compares the local file's modified() to the remote RemoteEntry.modified_at; Rename finds a free .N-suffixed name; Skip short-circuits to status skipped; Resume arms bytes_resumed. Emits transfer.skipped on the WebSocket event stream.

Bandwidth throttle (real, not a placeholder)

  • New throttle.rs module. Throttle::pace(n) is awaited by every streaming transport's chunk loop; set_global_rate_kib(n) is called from AppConfig load + PUT /config. 0 = unlimited.
  • Wired into SFTP (down + up), FTP, FTPS, S3, Azure, GCS, WebDAV download paths.

Site folders

  • SavedSite.folder: Option<String>; FileZilla-parity grouping in the Site Manager (rendered as <optgroup> rows).
  • Site Form has a Folder field with autocomplete from existing folders + free-text new entries.

S3 endpoint presets

  • Site Form exposes a preset dropdown: AWS / Backblaze B2 / Storj / Wasabi / Cloudflare R2 / DigitalOcean Spaces / MinIO / Custom. Picking a preset fills endpoint, region, and path_style.

ftproxy-cli (3rd binary)

  • src-tauri/src/bin/ftproxy-cli.rs — one-shot CLI wrapping the bridge over loopback. Subcommands: --upload / --download / --mkdir / --rm / --list / --sync / --connect-test / --sites. Reads token from <data dir>/token; honors FTPROXY_BASE / FTPROXY_TOKEN env overrides. Exit codes: 0 OK, 1 user error, 2 bridge error.

Windows "Send To" integration

  • New Tauri commands install_send_to_shortcut / uninstall_send_to_shortcut. Drops a .lnk in %APPDATA%\Microsoft\Windows\SendTo\ pointing at ftproxy-cli.exe. Edit menu → Install / Remove Send To shortcut. Implementation is PowerShell + WScript.Shell (no shell-extension COM registration).

SSH command execution

  • New POST /sftp/exec endpoint runs a one-shot command on the active SFTP session via russh's exec channel. Captures stdout / stderr / exit status, capped at 1 MiB. Server menu → "Run command…".
  • Transport::run_command trait method (default: bail with "SFTP-only"). SFTP override threads through channel_open_session / exec / wait.

Speed-test / benchmark

  • New POST /benchmark endpoint uploads N MiB of test data, downloads it back, deletes the test file, and returns { uploadBytesPerSec, downloadBytesPerSec, ... }. Server menu → "Benchmark connection…".

Imports from more legacy clients

  • SmartFTP: walks %APPDATA%\SmartFTP\Client 2.0\Favorites\ recursively, parses each .xml favorite, surfaces directory hierarchy as the folder field.
  • CuteFTP: registry import for CuteFTP 7 / 8 / 9 under HKCU\Software\GlobalSCAPE\CuteFTP {N}\Sites. Supports nested site folders → flattens to Folder1 / Folder2.
  • Both wired under File menu next to FileZilla / WinSCP / PuTTY / CoreFTP. New unit tests pin the protocol-mapping matrices.

Logging

  • Every new endpoint, registry walker, and transport method has #[tracing::instrument] attached with a representative span field set (command for exec, sizeMib for benchmark, count for imports).

Tests

  • cargo test --lib134/134 (was 111). New tests cover:
  • OverwritePolicy wire round-trip + unknown-fallback.
  • TransferJob back-compat deserialize (no policy / bytesResumed fields).
  • Queue persist roundtrip (5 cases: complete preserved, running → interrupted with resume armed, pending-no-progress resets resume, 500-entry cap, invalid-JSON fallback).
  • Throttle: unlimited fast path, paced-to-target pacing, KiB-conversion saturation.
  • SmartFTP protocol mapping + XML field extraction + full parse round-trip.
  • CuteFTP protocol mapping (FTP / FTPS / SFTP / unknown).
  • SiteSchedule serde round-trip + back-compat for sites without folder / schedule.
  • npm test (Vitest) → 39/39 (unchanged — backend changes only in this round).

Build marker

  • Frontend: UI build: 2026-04-26 v44-prd-pass-features.

Deferred to 0.8.1+ (honest scope cut, not vaporware)

  • Mount-as-drive (WebDAV adapter) — designed (mini-WebDAV server bound to 127.0.0.1 + net use Z: \\127.0.0.1@7878\dav) but the data-path proxy + locking semantics need a careful pass before shipping. Half-day's work.
  • Scheduled syncSavedSite.schedule and SiteSchedule are in the data model + persist layer; the cron-tick tokio task and the create/edit UI aren't wired yet. ~1 day.
  • Open-remote-in-editor — temp-file lifecycle + FS watcher. ~half day.
  • i18n infrastructuredist/i18n/{en,es,fr}.json + a t() helper + Settings combo. ML-seeded translations would be theater without a translator pass, hence deferred. ~half day.
  • OpenStack Swift transport — new transport with Keystone v3 auth. ~half day.
  • Resume for FTP / cloud uploads — protocol-side APPE / multipart-resume needs upload-id persistence. The UI shows the selected policy and bytesResumed; it just degrades to overwrite on the wire. ~1 day for FTP, ~1 day for S3 multipart-resume.

[0.7.4] — 2026-04-26 (Azure + GCS continuation pagination)

Object-store pagination wired end-to-end

  • Azure: list_page now reads NextMarker off the ListBlobsResponse and threads any caller-supplied ListOpts.continuation back into the request via ListBlobsBuilder::marker. Containers with >1000 blobs paginate correctly; the bridge surfaces the marker as the opaque next_token in the ListPage envelope.
  • GCS: list_page already threaded pageToken on the request side, but the response struct (ListResp.next_page_token) had no #[serde(rename_all = "camelCase")] and so never matched GCS's nextPageToken wire field. This means GCS pagination has been silently broken since 0.4.0 — directories with more than 1000 objects only ever returned the first page. New unit test (list_resp_decodes_next_page_token) pins the wire format so a future regression here fails the build.
  • Both transports' list() methods now drain every page internally so non-paginated callers (recursive sync, dir compare, the rmdir-walker) see the full inventory regardless of size. Previously they were silently first-page-only.
  • Defensive empty-string → None coalescing on both transports so a buggy server returning "" instead of an absent token never sends the caller into an infinite loop.

Logging

  • #[tracing::instrument] on Azure + GCS list_page with entries count and has_next flag at debug. Useful when triaging "why am I missing files?" reports — you can see whether pagination engaged at all.

Tests

  • Azure: NextMarker round-trip + empty-marker coalesce.
  • GCS: nextPageToken JSON decode + empty-token coalesce.
  • cargo test --lib111/111 (was 107).
  • npm test (Vitest) → 39/39 (unchanged — pure-backend change).

Doc cleanup

  • tasks/todo.md rewritten to reflect 0.7.4. The "Known gaps" section is now down to one item (mobile packaging, deferred).
  • tasks/future.md rewritten — almost every item in the old file had shipped (large-file streaming, host-key strict, multi-session, WinSCP/PuTTY import, dir compare, the client crate, etc.). New ideas only.
  • AI_PLANNING.md updated: Target A (MCP server) marked shipped via src-tauri/src/bin/ftproxy-mcp.rs.
  • AUDIT-2026-04-26.md head section refreshed — Sprint 2 closed.

[0.7.3] — 2026-04-26 (sites import — File menu unification + CoreFTP)

File menu now owns every "Import sites from …" action

  • Moved Import from WinSCP… and Import from PuTTY… out of the Bookmarks menu and into File → Import sites from WinSCP… / PuTTY…, next to the existing FileZilla XML import. They were UX-misplaced before — bookmarks-as-paths and sites-as-imports are different concepts and shouldn't share a menu.
  • Added File → Import sites from CoreFTP…. Reads sessions from HKCU\Software\FTPware\CoreFTP\Sites and the free-edition …\CoreFTPLE\Sites root, mapping the PType field (0=FTP, 1/2=FTPS Implicit/Explicit, 4/5=SSH/SFTP) to the matching FTProxy protocol. Unknown PType values are skipped silently rather than guessed. Like WinSCP/PuTTY, no password is imported (CoreFTP encrypts theirs); the user supplies it on first connect.
  • Native-menu IDs: the old bookmarks_import_winscp / bookmarks_import_putty ids are gone, replaced by file_import_winscp, file_import_putty, file_import_coreftp. NATIVE_MENU_IDS and the __ftpMenuAction switch are kept in lockstep; new Vitest contract test pins the JS side.
  • importFromRegistry(source) is now table-driven via a REGISTRY_IMPORTS map so adding the next client is a one-line diff in JS plus a Tauri command in Rust.

Logging

  • tracing::instrument on winscp_sites, putty_sites, and the new coreftp_sites. Each emits info with the scanned-count or debug when the registry root is absent — useful when triaging "import found 0 sites" complaints from users with non-default install paths.

Tests

  • New Rust unit tests pin the CoreFTP PType → protocol mapping for every known value (0, 1, 2, 4, 5) and guarantee unknowns return None. A no-op smoke test confirms coreftp_sites() doesn't panic on a clean machine with no CoreFTP install.
  • New Vitest contract test (menu-imports.test.js) asserts the file menu carries every import label and the bookmarks menu carries none of them, plus that __ftpMenuAction recognises the new file_import_* ids and not the old bookmarks_import_* ones.

Test totals

  • cargo test --lib107/107 (was 104).
  • npm test (Vitest) → 39/39 (was 30).
  • Frontend build marker bumped to UI build: 2026-04-26 v43-imports-under-file-menu.

[0.7.2] — 2026-04-26 (UX polish + per-protocol action policy)

Site Form — FileZilla-parity General-tab order

  • Encryption dropdown is now a top-level field right after the Port row (where FileZilla puts it), not buried in the extras panel at the bottom. FTP shows 4 modes (auto / explicit / implicit / plain), FTPS shows 2 (explicit / implicit), SFTP shows a green "🔒 Encryption: SSH transport — always on" info row.
  • Logon Type dropdown is now a top-level field right below Encryption. FTP/FTPS: Normal / Anonymous / Ask for password. SFTP: Normal / Ask for password / Key file (with private-key file picker
  • optional passphrase appearing conditionally).
  • Both fields persist into extra map keys (encryption / logon_type / key_path / key_passphrase). The bridge dispatch unchanged from 0.7.1.

Action-bar policy (the real fix you've been asking for)

  • Verify hash and Compare dirs buttons are now hidden (not just disabled) when the active protocol is anything other than SFTP / FTP / FTPS. WebDAV, S3, Azure, GCS, Dropbox, Drive, OneDrive show neither button at all. Adjacent .action-sep dividers collapse with them.
  • actionPolicy(cmd, proto) is the single source of truth for enabled / disabled / hidden / tooltip per cell — implements the AUDIT-2026-04-26.md §9 matrix end-to-end.
  • updateToolbarState() runs synchronously inside applyAccent() so swatch clicks and tab switches refresh the bar in the same frame — no more 2-second polling lag.

First-launch defaults

  • The disconnected app now lights up SFTP-green by default (proto badge, swatch active, tab chip). activeProto() falls back to "sftp" when no live session and no forced palette pick.
  • Proto title for the default-disconnected state reads Ready · SFTP instead of Disconnected so the chrome looks intentional rather than empty.

Native menu

  • View → Hard Reload (Ctrl+Shift+R) added — clears caches.keys()
  • sessionStorage before location.reload(). Same pattern as GoGreenMarketing. Fixes the stale-WebView2-cache class of bug permanently.

Tests

  • cargo test --lib104/104 (was 96; +8 new: FTPS aliases, HashQuery deserialisation, redownload cap constant, StoredToken serde roundtrip, pkce-pair-uniqueness, credentials helpers).
  • npm test30/30 (was 11; +19 in dist/__tests__/action-policy.test.js covering Verify hash + Compare dirs visibility, Upload / Rename / Delete enable rules, always-available Connect / Sites / Refresh).

Logging

  • Connect log line now includes Logon Type + Encryption mode + key path (when key auth) for diagnosis.
  • Verify hash logs algorithm + first 16 chars of hash + source (native vs redownload-fallback).
  • Compare dirs logs the four bucket counts (local-only / remote-only / differing / same).

Docs

  • New MCP.md — how to register ftproxy-mcp with Claude Desktop; full tool catalog with examples; security notes.
  • New TESTING.md — the four test surfaces (cargo lib, client crate, Vitest, endpoint suite) with run instructions and CI mapping.
  • AUDIT-2026-04-26.md refreshed with a status-delta header marking every sprint-1 item DONE; original audit body preserved as a historical snapshot.
  • SECURITY-AUDIT-2026-04-26.md refreshed — F-MED-1, F-MED-2, F-LOW-1, F-LOW-2, F-LOW-3, F-LOW-4 all marked CLOSED with where they landed.
  • API.md extra table now documents encryption, logon_type, key_path, key_passphrase, plus notes on the password-vs- extra.access_token Dropbox routing.
  • USER-GUIDE.md adds an FTP-family Encryption + Logon Type section, Hard Reload to the keyboard shortcuts.
  • CREDENTIALS.md adds SFTP key-file auth setup + an FTPS section.
  • IMPLEMENTATION.md source map adds ftps.rs, ftproxy-mcp.rs.

Build marker

  • UI build: 2026-04-26 v42-encryption-logon-top-level

[0.7.1] — 2026-04-26 (audit sprint 1 — finish line)

Closes the remaining sprint-1 items from AUDIT-2026-04-26.md plus several gaps surfaced in review.

FTPS visible in the palette + dropdowns

  • Protocol-accent swatch added (color teal #5eead4), tab chip class, Site Form dropdown entry, Connect modal dropdown entry — every UI surface that lists protocols now lists FTPS alongside SFTP/FTP.
  • The Site Form's encryption dropdown shows the right two options when protocol = ftps (Explicit / Implicit) and the standard four when protocol = ftp (auto / explicit / implicit / plain).

Logon Type dropdown — FileZilla parity

  • FTP / FTPS site form now has a Logon Type dropdown:
  • Normal — username + password (default; existing behaviour)
  • Anonymous — auto-fills anonymous / anonymous@example.com on the connect body; bridge::post_connect substitutes
  • Ask for password — clears the stored password; the JS prompts via window.prompt() at connect time
  • SFTP site form has a different Logon Type set:
  • Normal — password
  • Ask for password
  • Key file — file picker for an OpenSSH private key plus an optional passphrase. transport::sftp::connect_with_policy detects extras.logon_type == "key", loads the key via russh_keys::load_secret_key, and authenticates via authenticate_publickey instead of password.
  • connectSaved() honors logon_type: prompts when ask, substitutes anonymous credentials at the bridge layer.

S3 multipart upload (>5 GB)

  • transport::s3::upload switches to CreateMultipartUpload → loop of UploadPart (64 MiB chunks) → CompleteMultipartUpload when data.len() > 5 GB. Falls back to single PutObject for smaller payloads. Best-effort AbortMultipartUpload on failure to avoid leaving billable orphan parts.

OneDrive resumable upload (>4 MiB)

  • transport::onedrive::upload POSTs createUploadSession for files larger than 4 MiB, then PUTs 4 MiB chunks against the session URL with Content-Range headers. Best-effort session DELETE on chunk failure.

F-LOW-3: SFTP TOFU first-run explainer

  • One-time modal on bootstrap (gated by localStorage key ftproxy_sftp_tofu_explained) explaining FTProxy's default TOFU behaviour and how to flip on strict pinning under Settings.

ftproxy-mcp binary — Model Context Protocol server

  • New src-tauri/src/bin/ftproxy-mcp.rs (~310 lines). Speaks JSON-RPC 2.0 over stdio per the MCP spec. Each tool is a thin pass-through to the local bridge:
  • list_remote, list_local, upload, download, mkdir_remote, rename_remote, delete_remote, remote_hash, get_transfers, get_session, list_sites, connect, disconnect, get_logs
  • Resolves the bearer token from <data dir>/token or FTPROXY_TOKEN env var; base URL from FTPROXY_BASE (default http://127.0.0.1:7878).
  • Runs on a per-process tokio current-thread runtime so the stdio loop stays synchronous from MCP's perspective.

Frontend test scaffolding (Vitest + jsdom)

  • package.json gains vitest 1.6 + jsdom 24 devDependencies and npm test / npm test:watch scripts.
  • vitest.config.js overrides the default **/dist/** exclude so tests under dist/__tests__/ are picked up.
  • First test set: dist/__tests__/helpers.test.js — 11 assertions covering extOf, fmtSizeSplit, isSinglePane, and FTPS-mode parsing. Result: 11/11 PASS.

Build marker

  • UI build: 2026-04-26 v35-logon-type-mcp-vitest

[0.7.0] — 2026-04-26 (audit sprint 1: FTPS, security hardening, doc cleanup)

This release closes most of sprint 1 from AUDIT-2026-04-26.md and the high-priority findings from SECURITY-AUDIT-2026-04-26.md.

New protocol — FTPS (FileZilla 4-mode parity)

  • transport/ftps.rs — FTP over TLS via suppaftp's async-rustls feature (already enabled). Both Explicit (AUTH TLS upgrade on port 21) and Implicit (TLS handshake on port 990) modes wired, using a webpki-roots-backed rustls connector.
  • normalize_protocol accepts ftps, ftpes, ftp-tls, ftptls.
  • Encryption mode dropdown in the Site Form for FTP, matching FileZilla's four entries:
  • Use explicit FTP over TLS if available (default — tries TLS, falls back to plain with a warn log)
  • Require explicit FTP over TLS
  • Require implicit FTP over TLS (port 990)
  • Only use plain FTP (insecure) Stored as extra["encryption"]. The bridge dispatches the right transport based on the mode; auto-mode logs whichever path won.
  • New deps: futures-rustls 0.26, rustls 0.23 (ring + std), webpki-roots 1.
  • Added suppaftp deprecated feature for connect_secure_implicit.

Security findings closed

  • F-MED-1: GCS service-account JSON moved to OS keychain. New credentials::SECRET_EXTRA_KEYS list; bridge::post_site / put_site move every listed key out of extra and store it under keychain key <site_id>:<extra_key>. post_connect hydrates from keychain back into the in-memory extra map for the transport. delete_site cleans up. Result: nothing in sites.json is a secret anymore; this matches the rule we already follow for passwords.
  • F-MED-2: Bearer token redacted in tracing logs. Replaced TraceLayer::new_for_http() with a make_span_with builder that strips ?token=… from URIs before logging — WS connect URLs no longer leak the bearer token to stdout / log files / aggregators. 4 unit tests cover the redactor.
  • F-LOW-1: Strict CSP shipped. tauri.conf.json app.security.csp goes from null to default-src 'self' tauri: ipc: …; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:7878 ws://127.0.0.1:7878; script-src 'self'. No external script execution, only Google Fonts CSS, only the local bridge for XHR/WS.
  • F-LOW-2: clippy + cargo-audit added to CI. New lint-and-audit job runs on every push: cargo clippy --lib --tests -- -W clippy::all -A dead_code and cargo audit. Catches new RustSec advisories before they merge.
  • F-LOW-4: Unix file permissions locked to 0o600. Token, sites JSON, bookmarks JSON, known_hosts JSON, app config JSON all call config::lock_user_only() after fs::write. No-op on Windows (NTFS user-ACL covers it).

Per-protocol Verify hash exposure

  • Dropboxremote_hash returns the server's content_hash (Dropbox's custom 4 MB-block SHA-256-based scheme; algo name dropbox or content_hash).
  • Google Driveremote_hash returns md5Checksum from Drive API metadata (algo md5).
  • OneDriveremote_hash returns whichever Graph hash the caller asks for (sha1, sha256, quickxor); auto picks the best available.
  • SFTP / WebDAV fallbackbridge::get_files_remote_hash now re-downloads + hashes locally (md5 / sha256) when the protocol's native primitive returns None. Capped at 256 MiB so a 10 GB blob doesn't silently stream over the network just to get a checksum. Caps blocked hash returns 400 with a pointer to /transfers/verify (uncapped).

Sign-out wiring

  • "Sign out" button next to "Sign in" in the Site Form for both Google Drive and OneDrive. Calls oauth_sign_out Tauri command; drops the keychain entry; updates the status line to "Signed out — keychain entry removed".

Cleanup

  • Deleted dead openKebabMenu JS function (kebab UI removed in v17).
  • Deleted body.theme-light CSS overrides — applyTheme() is dark-locked, so the light tokens were unreachable.
  • Deleted stray ftproxy-run.log at the repo root; added *.log to .gitignore.
  • Archived CODEX.mdtasks/archive/CODEX-initial-overhaul.md.
  • Archived tasks/ui-restyle-progress.mdtasks/archive/ui-restyle-progress-2026-04-24.md.
  • tasks/memory.md updated: 9 protocols (was 6), 96/96 tests (was 74/74), OAuth + secret-extras notes added.
  • IMPLEMENTATION.md source map updated: oauth.rs listed, route count 29 → 40.

Tests — total 96 (was 90, +6 new)

  • bridge::tests::redact_token_* (4 tests covering URI redaction)
  • transport::ftps::tests::mode_parse_defaults
  • transport::ftps::tests::default_ports

Build marker

  • UI build: 2026-04-26 v34-audit-sprint1

Sprint 1 items NOT shipped this pass (deferred to sprint 2)

  • ftproxy-mcp binary (3-day MCP server wrapper) — biggest single strategic unlock per AUDIT §3.1; needs its own session
  • S3 multipart upload (>5 GB)
  • OneDrive resumable upload (>4 MiB)
  • Frontend test scaffolding (Vitest)
  • F-LOW-3: SFTP first-run TOFU explanation modal

[0.6.1] — 2026-04-26 (Drive + OneDrive real OAuth — no more scaffolds)

Wired Google Drive and OneDrive end-to-end. The OAuth2 + PKCE flow, loopback-redirect listener, token persistence, and Drive/Graph REST calls are all live. The earlier 0.6.0 scaffold-only behavior is gone.

OAuth2 + PKCE scaffolding (src-tauri/src/oauth.rs, ~280 lines)

  • pkce_pair() generates a 64-char URL-safe verifier + S256 challenge
  • start_callback_listener() binds an axum-style loopback HTTP listener on a free port, returns (redirect_uri, oneshot_receiver). Single request, then shuts down. 5-minute timeout.
  • exchange_code_for_token() POSTs the auth code + verifier to the provider's token endpoint, returns a StoredToken
  • refresh_if_needed() checks expires_at - now < 60s, refreshes via refresh_token grant when stale
  • Token storage: OS keychain via existing keyring crate, service ai.opensentinel.ftproxy.oauth, key = SavedSite UUID
  • Built-in providers: Google + Microsoft (azure-ad common tenant)
  • 5 unit tests cover PKCE, URL construction, provider resolution, state-nonce shape

Tauri commands

  • oauth_sign_in({ provider, client_id, scopes, key }) — orchestrates the full flow: PKCE pair → loopback listener → opens system browser to authorization URL → captures redirect → exchanges code → persists token → returns expiry. CSRF-guarded by state nonce.
  • oauth_status({ key }) — returns { signedIn, expiresAt, scopes } for the current keychain entry; used to reflect sign-in state when re-opening the Site Form

Google Drive transport (replaces 0.6.0 stub)

  • Drive API v3 via reqwest. Path↔ID translation cache ("/" → "root"); resolves nested paths by walking the folder hierarchy with q=name='X' and 'PARENT' in parents
  • list (page-tokenized), mkdir (folder MIME type), unlink, rename (PATCH name), download (alt=media), upload (multipart/related metadata + content)
  • 401 implicit retry via the refresh_if_needed() call before every op

OneDrive transport (replaces 0.6.0 stub)

  • Microsoft Graph /me/drive — path-based, no id↔path cache needed (/me/drive/root:/foo/bar: URL form)
  • list (with @odata.nextLink pagination), mkdir (folder facet
  • conflictBehavior: fail), unlink, rename, download (/content), upload (PUT to /content, capped at 4 MiB — large files need the resumable upload-session API in a follow-up)

Site Form

  • For gdrive and onedrive: replaced the "OAuth not implemented" warning with a real Sign in with Google / Sign in with Microsoft button that calls oauth_sign_in, opens the system browser, and shows live status: "Signed in. Token expires …"
  • New oauth_key field (auto-set to the SavedSite UUID, read-only), used by the transport to look up the keychain entry on connect
  • On open, the form fetches oauth_status and shows existing sign-in state without requiring a re-auth

Tests — total 90 (was 82)

  • oauth::tests::pkce_pair_round_trip — verifier→S256(verifier)
  • oauth::tests::auth_url_includes_required_params
  • oauth::tests::microsoft_provider_config_resolves
  • oauth::tests::unknown_provider_errors
  • oauth::tests::random_state_is_url_safe
  • transport::gdrive::tests::missing_client_id_message_helpful
  • transport::gdrive::tests::missing_oauth_key_message_helpful
  • transport::onedrive::tests::item_url_root_vs_nested
  • transport::onedrive::tests::url_escape_handles_spaces
  • transport::onedrive::tests::missing_client_id_helpful_error

One-time user setup (no code can avoid this)

  • Google: console.cloud.google.com → enable Drive API → create OAuth credentials of type "Desktop app" → paste client_id into the site's client_id field
  • Microsoft: portal.azure.com → AAD → App registrations → New registration → "Mobile and desktop applications" platform → add http://127.0.0.1 (any port — we bind dynamically) as a redirect URI → grant Files.ReadWrite delegated permission → paste the Application (client) ID into the site

Build marker

  • UI build: 2026-04-26 v31-oauth-drive-onedrive-real

[0.6.0] — 2026-04-26 (cloud-personal: Dropbox, Google Drive, OneDrive)

Three new protocols, raising the supported total from 6 to 9. Each plugs into the same single-pane object-store layout that S3 / Azure / GCS use; switching to one of these tabs collapses the local pane and transit lane and uses the OS file picker / drag-drop for uploads.

Dropbox — full implementation

  • transport/dropbox.rs — Dropbox v2 API via reqwest, personal access token auth (no OAuth dance). Token comes in via extra["access_token"] from the Site Form (or as a fallback in the password field).
  • Implements list (with cursor pagination via list_folder/continue), mkdir (files/create_folder_v2), unlink (files/delete_v2), rename (files/move_v2), download (content.dropboxapi.com/2/ files/download), upload (content.dropboxapi.com/2/files/upload with mode: overwrite), and size (files/get_metadata).
  • Connect-time smoke test against users/get_current_account so a bad token surfaces a clear error before any list call.
  • Path quirk: Dropbox's API uses "" for the root, not "/". The transport translates on every call.
  • Site Form extras panel: dedicated Access token password field with link to dropbox.com/developers/apps.
  • Accent: Dropbox blue #0061ff for swatch + chip + accent-soft retint.

Google Drive — scaffold (OAuth pending)

  • transport/gdrive.rs — Transport trait skeleton; every CRUD method returns a structured error with explicit setup hint pointing to console.cloud.google.com/apis/credentials. Connect itself succeeds so the single-pane layout renders; the wire console then shows the setup hint when listing tries to run.
  • Site Form extras: OAuth client_id field + warning that the flow isn't wired yet.
  • Accent: Google brand yellow #fbbc04.

Microsoft OneDrive — scaffold (OAuth pending)

  • transport/onedrive.rs — same shape as gdrive.rs. Connect succeeds, list / transfer error with hint to portal.azure.com AAD app registration.
  • Site Form extras: client_id + tenant fields.
  • Accent: Microsoft blue #0078d4.

Plumbing changes

  • bridge::normalize_protocol() now accepts dropbox (alias dbx), gdrive (aliases google-drive, googledrive), and onedrive (aliases one-drive, msgraph).
  • post_connect dispatches the three new protocols to their respective Transport::connect constructors.
  • Frontend PROTO_ACCENTS map gets three new entries with proper colors / kicker text / mark badges (DBX / GDR / 1DR).
  • SINGLE_PANE_PROTOS set extended — clicking the new swatches in the palette flips the workspace to single-pane just like S3 / Azure / GCS.
  • Quick Connect modal protocol dropdown lists all 9 protocols; cloud- protocol picks redirect to the Site Form pre-filled with the chosen protocol's extras panel.
  • CSS: chip / swatch styles for the new protocols use their accent colors.

Tests

  • 5 new lib tests, total now 82 passing:
  • transport::dropbox::tests::dbx_path_root_to_empty — verifies Dropbox's ""-is-root quirk
  • transport::dropbox::tests::missing_token_yields_helpful_error
  • transport::gdrive::tests::pending_error_carries_setup_hint
  • transport::onedrive::tests::pending_error_carries_setup_hint
  • bridge::tests::normalize_protocol_cloud_aliases
  • NATIVE_MENU_IDS contract tests still green; no menu change.

Build marker

  • UI build: 2026-04-26 v30-dropbox-gdrive-onedrive

[0.5.3] — 2026-04-24 (UX polish: connect, context menu, branding)

Quick Connect

  • Auto-save to Sites after every successful /session/connect. Dedup by protocol+host+username; password stored in OS keychain via existing /sites POST. Note stamp: Auto-saved from Quick Connect on YYYY-MM-DD.
  • Protocol preset: Connect modal now pre-selects whichever protocol is currently active — live session's protocol if connected, or the user's forced swatch pick. Port defaults per protocol (22 / 21 / 443) and only overwrites if the user hasn't typed a custom value.
  • Connect… / Sites… / Disconnect buttons restored to the action bar so cloud-protocol connections no longer require the native OS menu.
  • Cloud-protocol Quick Connect opens the full Site Form pre-filled with the chosen protocol (instead of dumping the user in Site Manager to click around).

Context menus

  • Right-click was silently brokenopenCtxMenu and openMenu were setting inline left/top on the inner .ctx-menu / .dropdown elements, but only the outer #ctx-root / #dropdown-root has position: fixed. Menus were rendering at (0,0) behind the page. Now both functions set coords on the root.
  • Context menus now mirror the action bar for discovery parity:
  • Local: Open (if dir) · ↑ Upload selected · New folder · Rename · Delete · Compare dirs · Refresh
  • Remote (FTP/SFTP/WebDAV): Open / ↓ Download / Save as… · New folder · Rename · Delete · Verify hash · Compare dirs · Copy remote path · Refresh
  • Remote (S3/Azure/GCS): same minus Compare dirs, plus Copy S3 URI / Copy Azure URL / Copy gs:// URI that writes a real cloud URI (s3://bucket/key, https://acct.blob.core.windows.net/..., gs://bucket/key) to the clipboard
  • New verifyRemote() calls /files/remote/hash?path=&algo=md5.

Palette placement

  • Protocol-accent palette moved from below the shell to directly below the tabs strip and above the shell. No more scrolling to the bottom to preview protocols / trigger the single-pane flip.

Branding

  • Swapped the inline SVG leaf for the actual Go Green Paperless Initiative logo image (dist/assets/GGPI.png, 64px tall, drop-shadow for brand-green glow).
  • Brand row: [GGPI logo] · GoGreen · Transit. Clicking opens gogreenpaperlessinitiative.com in the user's browser.
  • .brand-lockup / .brand-primary / .brand-secondary CSS preserved so the "GoGreen Transit" text still reads alongside the logo.

Logging

  • statusLog entries added for modal opens (Quick Connect, Site Manager, Settings) and for dismissed file-picker dialogs so the Wire console always shows what is happening, not just what transferred.
  • Single-click a directory now walks into it on both local and remote panes, matching modern file-explorer behaviour. Modifier keys (Ctrl / Shift / ⌘) suppress the auto-navigate so multi-select still works. Single-click on .. walks up one directory.
  • navigateLocal(path) / navigateRemote(path) log a cmd wire line (cd local: … / cd remote: …) so every directory change is visible in the Wire console.

Build marker

  • UI build: 2026-04-24 mock-port-v29-nav-logs in Wire console.

[0.5.2] — 2026-04-24 (mock-workspace port, phase 3 — finish line)

The third and final pass on the mock port. The shell structure was right after 0.5.1 but subtle size / color / layout drift kept surfacing in user review. This release closes every known gap, adds the native OS menu the app used to ship with, and introduces layout-per-protocol so object-store connections feel native.

Native OS menubar (restored)

  • src-tauri/src/lib.rs installs a real Windows menubar via Tauri's MenuBuilder / SubmenuBuilder — File · Edit · View · Transfer · Server · Bookmarks · Help. Same pattern as GoGreenMarketing.
  • File → Quick connect (Ctrl+Q) · Site Manager (Ctrl+S) · Import / Export · Exit (native quit).
  • Edit → Settings · Clear queue · native Undo/Redo/Cut/Copy/Paste.
  • View → Refresh both (F5) · Compare dirs · Toggle wire / palette · native Reload (Ctrl+R) / DevTools (Ctrl+Shift+I).
  • Menu events emit menu-action via window.eval(__ftpMenuAction(id)) which routes to the existing JS dispatchers — no @tauri-apps/api npm dependency required.
  • NATIVE_MENU_IDS const + 3 unit tests assert the id contract between Rust and JS never drifts silently.

Layout-per-protocol

  • .workspace.single-pane CSS collapses the grid to 1fr, hides the local pane and transit lane. Triggered when the active protocol (real session or user-forced swatch pick) is S3 / Azure / GCS.
  • openFilePickerUpload() — in single-pane mode, the ↑ Upload action opens an OS file picker and POSTs selected files to /transfers/upload-blob. Drag-drop from the OS still works.
  • Switching tabs flips the layout live without reload.
  • Clicking a protocol swatch in the palette now also toggles the layout, for design preview.

Mock-match drift fixes

  • .wire-body uses height: 200px and max-height: 200px + overflow-y: auto (was max-height alone, which let the box shrink to content height instead of holding 200px).
  • .workspace uses fixed height: 520px so the panes are bounded and .rows actually scrolls inside them (previously unbounded min-height: 460px grew the pane to fit 50+ files).
  • applyTheme() is dark-locked — config.theme no longer leaks a theme-light class that was washing out colours.
  • --accent-sftp corrected from #5eead4 to #7dffb2 (matches mock).
  • .action.disabled is now color: var(--ink-mute) only (no opacity: 0.5) — disabled buttons are still legible.
  • Session-tab strip moved out of .shell-head into its own rounded row directly below the concept-tag, matching the user's requested placement. Tabs no longer wrap inside the shell-head's squeezed center column.
  • Queue summary uses .summary / .done / .fail classes at 11px mono (was .queue-summary at 10px uppercase); separators are mid-dot · at var(--ink-faint); "done" rendered in --ok, "failed" in --err — matches mock verbatim.
  • Statusbar uses inline .ok green dot + .v values with units baked in ("0 KB/s", "— ms"); dropped the .u unit span that was dimming units with var(--ink-faint).
  • Body padding 36px 36px 64px, max-width 1440px on concept-tag / tabs-strip / shell / palette / footnote — matches mock exactly.
  • Added the mock's rise / rowIn / wireIn keyframe animations.
  • Wire-line rows stamped with wire-new class so new entries animate in, matching the mock's streaming effect.

In-app connect paths

  • Connect… · Sites… · Disconnect actions added back to the action bar so cloud-protocol connections don't require opening the OS menu.
  • quickConnect() for S3 / Azure / GCS opens the full Site form pre-filled with the chosen protocol (was dumping the user in Site Manager to click around).

Logging

  • statusLog("sys", "Layout: single-pane (s3)") on every layout flip.
  • statusLog("sys", "Palette accent: <proto>") on swatch click.
  • statusLog("cmd", "menu: <id>") on every native-menu dispatch — visible in the Wire console so you can tell a click fired even if the JS handler no-ops.

Build marker

  • UI build: 2026-04-24 mock-port-v20-final shown in the Wire console on bootstrap so stale WebView2 caches are obvious.

[0.5.1] — 2026-04-24 (mock-workspace port, phase 2)

Phase-1 of the mock port ("editorial reskin") had kept the FileZilla skeleton — same <tr>/<td> lists, same status-pane log, same quickconnect row under the menubar. The visual tokens were right; the structure wasn't. This release finishes the port so the live UI matches dist/mock-workspace.html in both chrome and content shape.

Rewritten renderers in dist/app.js

  • renderFileList now emits <div class="row"> with the mock's structure: glyph (↑ / ▸ / ◆ / ⇢), name (.n + .ext pill + symlink .target), split size + faded unit, local mod column, remote perms (each char coloured .t/.r/.w/.x/.dash), owner (.u / .g), hash (.ok / .mute / .prog).
  • renderSessionTabs now emits .tab with a coloured .chip.<proto> square, a .x close button, and a trailing dashed .tab-add.
  • renderTransfers emits .q-item pills (active / ok / fail) with direction arrow, filename, and status/percent chip.
  • statusLog writes .wire-line.tx/.rx/.sys/.err/.warn rows with [ts] [TAG] [msg] shape; rx pulls the numeric response code into a .code span, tx pulls the first word into .cmd. Wire frame count and chip port update live.
  • New renderPathBar(side, path) — breadcrumb trail with .crumb / .sep, double-click to swap into raw-edit input, Enter to navigate, Esc to cancel.
  • renderSessionMeta() fills the shell-head right column with pulse · live · host · uptime when connected; a rolling tick updates uptime every second.
  • updateModeRail() populates channel / type / AUTH TLS pills from the live session.
  • updateWireMeta() keeps CTRL :<port> and TLS · session · N frames current.

Action bar

  • .action[data-cmd] buttons handle every command formerly on the toolbar + menubar (upload / download / new-folder / rename / delete / verify / compare / sites / connect / disconnect / reconnect / refresh / process-queue / cancel / copy-token).
  • Kebab () opens a dropdown listing File / Edit / View / Transfer / Server / Bookmarks / Help — existing MENUS dispatch unchanged.
  • Connect action opens a focused Quick-Connect modal that writes back to the hidden #qc-* inputs then calls quickConnect().
  • Keyboard shortcuts: U upload, D download, R refresh, F5 refresh both, Esc close dropdowns/menus (existing).

Chrome

  • Drag-over highlight rule .pane.drag-over + pseudo "drop to transfer" label added to dist/styles.css.
  • Hidden #local-path / #remote-path text inputs retained for backwards compat with existing navigateLocal/Remote plumbing; UI now uses the breadcrumb path bar.

Build marker

  • UI build: 2026-04-24 mock-port-v2 — shown in the Wire console on bootstrap so you can tell a fresh bundle loaded.

[0.5.0] — 2026-04-24 (rebrand + transit-console UI)

Brand

  • Product renamed Go Green Transit under the Go Green Paperless Initiative umbrella. Repo, binary name, and crate identifiers keep the ftproxy handle for continuity. productName and window title in tauri.conf.json updated.

Visual system — "Transit Console"

  • Full restyle in dist/styles.css: IBM Plex Sans / Mono with Instrument Serif for editorial accents. Dark theme is now the default; light theme is a tasteful secondary.
  • Protocol-accent identity — a single --accent CSS variable drives the whole UI; applyAccent() in dist/app.js re-tints the chrome per the active session's protocol (SFTP teal, FTP amber, WebDAV purple, S3 orange, Azure cyan, GCS pink). The house Go Green brand green is the default when no session is connected.
  • Brand lockup in the menubar: leaf mark + "GoGreen" wordmark + "TRANSIT" small-caps descriptor. Leaf asset at dist/assets/logo-leaf.svg.
  • Protocol badge next to the brand shows the live protocol mark (SSH / FTP / DAV / S3 / AZ / GCS) and a serif title — updates on session change.
  • Transit lane between local and remote panes: two arrow buttons (↑ upload, ↓ download) bound to the currently selected entries in each pane. Tick rail between them, vertical mono labels at the ends.
  • Queue rows, session tabs, toolbar buttons, quickconnect form, status bar, modals, and dropdowns all restyled to the new system.

Frontend compatibility

  • Every DOM id / class / data-attribute consumed by dist/app.js preserved so the restyle was a CSS+chrome change, not a rewrite. Drag-drop, tree navigation, queue rendering, session tabs, and modals all work as before.

[0.4.0] — 2026-04-23 (cloud storage plugins)

All three cloud storage backends shipped end-to-end: Rust plugin + Site Manager form + connect flow + client-crate types. You supply the access keys; FTProxy handles everything else.

ConnectionInfo.extra

  • ConnectionInfo and the bridge ConnectRequest gained an extra: HashMap<String, String> field.
  • SavedSite.extra (optional, serde-default so existing sites still load) persists protocol-specific settings alongside host/user.
  • post_connect merges extra from the connect body with any stored on the referenced siteId so the UI doesn't have to resend cloud config every time.

S3 transport (transport::s3)

  • Built on aws-sdk-s3 (v1). Probes with HeadBucket on connect.
  • Inputs: username = access key, password = secret access key, extra.bucket (required), extra.region (defaults to us-east-1), extra.endpoint (forces path-style, enabling MinIO, Cloudflare R2, DO Spaces, Wasabi, etc.), extra.path_style override.
  • Operations: list/list_page via ListObjectsV2 with / delimiter (common prefixes become directories); mkdir creates a zero-byte prefix/ marker; rmdir batches DeleteObjects in chunks of 1000; rename = CopyObject + DeleteObject; download/upload + streaming download_to/upload_from; size via HeadObject.
  • Single-part PutObject today; multipart (required for >5 GB) is a follow-up.

Azure Blob transport (transport::azure)

  • Built on azure_storage + azure_storage_blobs. Probes with get_properties on the container.
  • Inputs: username = account name, password = account key, extra.container (required).
  • All operations target BlockBlobs. rename = CopyBlob (server-side)
  • DeleteBlob. list_page currently returns one page per call; Azure's continuation plumbs through the same stream.

GCS transport (transport::gcs)

  • Raw REST against storage.googleapis.com with a service-account JWT exchanged for an OAuth2 access token (cached, refreshed ≥30 s before expiry). Avoids the full google-cloud-storage crate to keep the dep tree tight — only adds jsonwebtoken + time.
  • Inputs: extra.bucket, extra.service_account_json (the whole JSON blob; paths are not supported because the bridge runs in a different data dir than the user's shell).
  • Operations: list_page uses objects.list?delimiter=/; upload uses uploadType=media; rename uses rewriteTo; rmdir iterates + deletes (no batch delete on GCS).

Bridge / routing

  • normalize_protocol accepts s3, azure / azureblob / azure-blob, gcs / gs, plus the existing ftp / sftp / webdav.
  • post_connect dispatches to the right transport constructor.
  • port = 0 is the right default for cloud protocols — it's a sentinel the plugin resolves internally, never a TCP port.

Frontend

  • Protocol dropdown in quickconnect, Site Manager, and Site Form now lists SFTP / FTP / WebDAV / S3 / Azure / GCS.
  • Site Form grows a protocol-aware extras panel: S3 shows bucket + region + endpoint + path_style; Azure shows container + endpoint_suffix; GCS shows bucket + a textarea for the full service-account JSON; WebDAV shows a note about pasting the full base URL as the host.
  • Quickconnect routes S3/Azure/GCS to the Site Manager because those protocols need more than the four-field row can hold.
  • connectSaved now sends the site's stored extra in the connect body so cloud tabs reconnect cleanly.

Client crate

  • opensentinel-ftproxy-client gains ConnectRequest.extra, SavedSite.extra, and NewSite.extra fields. Consumers targeting cloud backends pass the same key/value pairs the bridge expects.

Tests

  • cargo test --lib main: 74/74 passing (was 69).
  • cargo test client crate: 10/10 passing.
  • New transport tests: transport::s3::prefix_and_key_roundtrip, transport::gcs::enc_matches_s3_rules, transport::gcs::key_and_prefix_math, plus extras-parsing coverage on ConnectionInfo.

Deliberately deferred (known gaps, not vaporware)

  • Multipart S3 uploads — single-part is fine up to 5 GB.
  • GCS / Azure true cursor pagination in list_page — current impl streams page-at-a-time but doesn't thread the token back through opts.continuation. Low-priority until someone hits a

    1000-object listing in the UI.

  • Mobile packaging — still intentionally out of scope.

[0.3.1] — 2026-04-23 (close-out pass)

Landed everything the 0.3.0 summary called out as "follow-up" except mobile packaging.

Runtime-resizable concurrency

  • AppState.transfer_slots is now RwLock<Arc<Semaphore>>. PUT /config with a new concurrency value swaps the Arc; in-flight permits stay valid (they reference the prior Arc), new jobs acquire from the fresh semaphore, and a log line records the transition.

Per-session transfers API

  • POST /sessions/:id/transfers/upload and /download route through a new slot_for_session / lock_for_session helper pair. Existing top-level /transfers/* routes still target the active slot so clients can start a long transfer on a background tab without switching to it.
  • transfer_upload / transfer_download were extracted into run_transfer_upload / run_transfer_download functions that take an optional session_id; the old routes delegate with None.

Headless bridge binary

  • src-tauri/src/bin/ftproxy-bridge.rs — runs the full axum router without the Tauri shell. Same token + data dir as the desktop app so the existing tools (test-endpoints.ps1, the OpenSentinel client crate) work against it.
  • lib.rs now exposes build_app_state() + run_headless_bridge() and both Tauri setup and the bin use the same constructor.

GitHub Actions CI

  • .github/workflows/ci.yml:
  • unit-tests — runs cargo test --lib on the main crate plus cargo test --all-features on the client crate.
  • endpoint-tests — spins scripts/docker-compose.test.yml (atmoz/sftp + pure-ftpd), builds + runs ftproxy-bridge, locates the token in the Linux XDG data dir, and executes scripts/test-endpoints.ps1 with FTPROXY_TEST_STACK=docker.
  • test-endpoints.ps1 token lookup is cross-platform: FTPROXY_TOKEN env var wins, then APPDATA, XDG_DATA_HOME, HOME/.local/share, with a final recursive fallback.

WebDAV transport (first real plugin)

  • New transport::webdav module implementing the Transport trait via reqwest + quick-xml. Covers PROPFIND (list + list_page), MKCOL, PUT (buffer-first upload with cancel), GET (streaming download_to with cancel + progress), DELETE, MOVE, HEAD (size), and OPTIONS (connect probe).
  • Namespace-loose multistatus parser handles Apache / Nextcloud / ownCloud / IIS DAV shapes. 5 unit tests cover URL parsing, percent decoding, local-name stripping, and the multistatus parser.
  • Wired into normalize_protocol (webdav / dav) and post_connect. Frontend Quickconnect + Site Manager expose the new protocol in the dropdown.
  • Other object-store backends (S3, Azure Blob, GCS) deliberately not shipped yet — each one needs a distinct credentials UX (access key + secret + region + endpoint override) and a bucket/prefix picker that isn't a plain path bar. WebDAV proves the plugin surface; the other three follow the same code shape when a UX pass lands.

Tests

  • cargo test --lib main crate: 69/69 passing (was 64).
  • cargo test client crate: 10/10 passing.

[0.3.0] — 2026-04-23 (same-day roadmap close)

Completes the backlog (except mobile, which is explicitly deferred). Everything below landed on top of 0.2.0 without breaking the 60 tests it shipped with — the count is now 64 main + 10 client crate.

Multi-session tabs

  • New SessionSlot type holding its own info: RwLock<SessionInfo> and transport: Arc<Mutex<Option<Box<dyn Transport>>>>. AppState now owns sessions: RwLock<Vec<Arc<SessionSlot>>> + active_session_id.
  • Legacy /session/* endpoints redirect to the active slot so existing consumers keep working. New endpoints: GET /sessions, POST /sessions, GET/POST /sessions/active, GET/DELETE /sessions/:id, POST /sessions/:id/disconnect.
  • Frontend: tab strip above the workspace with per-tab close, new-tab button, and a connected/disconnected dot. sessions.changed WS event keeps the strip live.
  • Bridge helpers lock_transport_for(state, id) let future multi-tab transfer routes target a specific tab.

Plugin surface for object storage

  • RemoteEntry now documents a kind = "object" value for S3-like backends; ListPage { entries, next_token } + ListOpts { prefix, continuation, limit } added to the Transport trait with a default impl that returns the whole listing (preserving SFTP/FTP behaviour).
  • New GET /files/remote/page?path=&continuation=&limit= endpoint.
  • transport::plugin submodule reserved as the docs/helpers anchor for future WebDAV/S3/Azure/GCS implementations.

OpenSentinel client crate

  • New crate crates/opensentinel-ftproxy-client/ with typed structs, retry-on-retryable, auto-reconnecting WS event stream, and 10 unit tests. Covers every endpoint the bridge exposes today, including /sessions*, /files/remote/page, /dir/compare, and /transfers/verify.
  • Pure reqwest + tokio-tungstenite + serde — no dependency on the FTProxy crate, so consumers embed just the client.

Wrap-ups

  • SFTP verify fallback: when verifyAfterUpload = true on SFTP and the server has no hash primitive, re-download the file (up to 256 MiB) and compare to the client-side MD5. Over that cap we trust SSH wire integrity and report unverified.
  • Host-key mismatch modal: the bridge now emits hostkey.mismatch with { host, port, message } on strict-mode rejections. The UI shows a modal with the bridge message, a field to paste the newly offered fingerprint, and a "Trust new key" button that calls POST /hostkeys/trust.
  • test-endpoints.ps1 docker switch: set $env:FTPROXY_TEST_STACK = "docker" to point the suite at scripts/docker-compose.test.yml (atmoz/sftp on 22322, demo/demo). The script auto-provisions a temporary site in that mode.

Tests

  • cargo test --lib on the main crate: 64/64 passing (was 60).
  • cargo test on opensentinel-ftproxy-client: 10/10 passing.
  • New handler tests: sessions_list_starts_with_one_active_slot, create_and_switch_sessions.
  • New transport tests: list_page_is_constructible, list_opts_defaults_to_none.

[0.2.0] — 2026-04-23

Roadmap pass: everything in tasks/todo.md "Next" except mobile + the plugin/OpenSentinel-crate scaffolding (still pending, see todo.md).

New Rust modules

  • app_config.rs — server-side config persisted to <data_dir>/config.json with fields: defaultLocalPath, concurrency, logLevel, theme, verifyAfterUpload, ftpTransferType, completionSound, strictHostKey. Loaded at startup, clamped/validated on write.
  • bookmarks.rsBookmark as (site_id?, remote_path, local_path?, note?). Persisted to bookmarks.json. Distinct from SavedSite so one site can have many working directories.
  • known_hosts.rs — SFTP fingerprint store (JSON, not OpenSSH format) with check() returning Unknown | Match | Mismatch { stored }. Used by the new host-key pinning flow.
  • win_import.rs — Windows-only registry reader for WinSCP (HKCU\Software\Martin Prikryl\WinSCP 2\Sessions) and PuTTY (HKCU\Software\SimonTatham\PuTTY\Sessions). Host + port + username only; neither tool stores usable passwords.

Transport trait extensions

  • download_to(remote, writer, cancel, progress) / upload_from(remote, reader, cancel, progress) — streaming variants, 64 KiB chunks, so large transfers don't load the file into memory.
  • size(remote) probe for transfer bytesTotal (SFTP metadata, FTP SIZE).
  • remote_hash(remote, algo) — FTP issues XMD5 / XSHA256 / XCRC via the SITE-like channel; SFTP returns None.
  • ProgressFn callback threaded through the streaming variants.
  • tokio::sync::watch::Receiver<bool> cancel flag; the worker checks it between chunks.

Host-key pinning (TOFU → strict)

  • HostKeyPolicy::{AcceptAny, Tofu}. SftpTransport::connect_with_policy records the SeenKey (algorithm + fingerprint + verdict) from russh's check_server_key.
  • On first successful SFTP connect, the fingerprint is upserted into known_hosts.json. A subsequent connect with a different key is rejected at the TLS layer (russh returns false from the handler).
  • hostkey.seen WS event fires on every connect with { host, port, algorithm, fingerprint, new }.
  • Settings toggle: "SFTP host-key verification" (off = legacy accept-any, on = strict). A caller can force per-connect override with acceptAnyHostKey: true in the connect body.

New endpoints

  • GET /config — server-side settings.
  • PUT /config — validate + persist + broadcast config.changed.
  • GET /bookmarks, POST /bookmarks, PUT /bookmarks/:id, DELETE /bookmarks/:id — full CRUD.
  • GET /hostkeys, POST /hostkeys/trust, DELETE /hostkeys/:host/:port.
  • POST /dir/compare { localPath, remotePath, maxDepth } — recursive classifier; returns { localOnly, remoteOnly, differing, same }.
  • POST /transfers/verify { localPath, remotePath } — hashes local file and compares to FTP HASH/XMD5/XSHA256/XCRC response.

Transfer pipeline

  • transfer_download / transfer_upload now call the streaming variants and emit transfer.progress WS events throttled to 250 ms.
  • Cooperative cancel: DELETE /transfers/:id signals the worker via a watch::Sender<bool> stored in AppState.cancel_flags. The worker short-circuits between chunks, surfaces a transfer.cancelled event, and cleans up the partial file for downloads.
  • transfer_slots: Arc<Semaphore> gates concurrent transfers to AppConfig.concurrency (default 2, clamp 1–16).
  • Optional post-upload integrity verify: when config.verifyAfterUpload is on, the worker computes local MD5/SHA-256/CRC32, asks the server for its own hash, and attaches verified: true|false|null to the response.

Frontend

  • Dark mode with a new theme-dark body class and a three-way Settings toggle (light / dark / auto). "Auto" follows prefers-color-scheme.
  • Settings modal now reads/writes /config on the server (not just localStorage). Adds strict-host-key toggle and log-level chooser.
  • Bookmarks menu: Add current path, Manage bookmarks (list + jump
  • delete), Import from WinSCP, Import from PuTTY.
  • First-run import prompt: on launch, if WinSCP or PuTTY sessions exist in the registry and the sites list is empty, offer to import.
  • Live progress bar on every running queue row, updated from transfer.progress events. Per-row cancel button (✕) issues DELETE /transfers/:id.
  • Directory sync modal (View → Compare directories) uses POST /dir/compare and shows classified results in a tabbed preview with buttons to apply each direction.
  • UI build marker bumped so it's obvious which pass is live.

Infra

  • scripts/docker-compose.test.yml brings up atmoz/sftp on 22322 and stilliard/pure-ftpd on 22321, both bound to 127.0.0.1 with demo/demo creds so the endpoint suite can run without the IONOS host.
  • Cargo deps added: md-5, sha2, crc32fast, hex, base64, urlencoding, and (Windows-only) winreg.

Tests

  • 60 unit tests (was 45). Additions: app_config, bookmarks, known_hosts, win_import::decode_key_roundtrip, transport::sftp::host_key_policy_variants_compile.

Still pending (explicitly deferred, not abandoned)

  • Multi-session tabs — big AppState refactor (would invalidate the /session/* contract for external consumers). Tracked in todo.md.
  • Plugin surface for S3/WebDAV/GCS — RemoteEntry generalization.
  • Standalone opensentinel-ftproxy-client crate (typed client + WS reconnect).
  • Mobile packaging — deliberately out of scope per this pass.

[0.1.0] — 2026-04-22

First functional release. Everything below was built in a single session on top of an empty Tauri scaffold.

Backend (Rust, Tauri 2)

  • Replaced the initial raw TcpListener HTTP toy with an axum + tokio server. 29 routes, bearer-token middleware, CORS, WS.
  • Transport trait with two production impls:
  • SFTP via russh 0.45 + russh-sftp 2.1 (pure-Rust, async). Upload uses open_with_flags(CREATE | WRITE | TRUNCATE) (OpenSSH's sftp-server rejects the flags create() emits).
  • FTP via suppaftp 6 with async + async-rustls features. Uses retr_as_stream / finalize_retr_stream and put_file with futures_util::io::Cursor.
  • Localhost bind, preferred port 7878 with fallback to an OS-assigned ephemeral port (FTPROXY_PORT env override).
  • Bearer-token auth on every endpoint except /health. Token generated on first launch, written to the app data dir.
  • bridge.url discovery file so external local consumers can find the bridge even when it falls back to a non-default port.
  • JSON-persisted saved sites in sites.json.
  • Passwords stored in the OS keychain via keyring 3 with the windows-native (+ apple-native, sync-secret-service) feature flags explicitly enabled.
  • WebSocket /events with typed events: hello, session.changed, remote.changed, transfer.started/completed/failed, sites.changed, log.
  • Structured error envelope: { code, message, retryable }.

Frontend (static HTML/CSS/JS in dist/)

  • FileZilla-style layout: menu bar (File/Edit/View/Transfer/Server/ Bookmarks/Help), toolbar, quickconnect row, scrolling message log, dual panes (each with path bar + tree + file list + foot), queue table, queue tabs (Queued/Failed/Successful), status bar.
  • All menu items and toolbar buttons are wired to real handlers — no stubs left.
  • Working: drag-drop (OS→remote, local→remote, remote→local), right-click context menus, double-click navigation/transfer, breadcrumb navigation, sortable columns, multi-select, per-pane tree lazy-load.
  • Site Manager: full CRUD with a password eye-toggle that fetches the cleartext value from the OS keychain via GET /sites/:id/password.
  • Settings modal (localStorage-backed preferences).
  • FileZilla XML import: File → Import sites from FileZilla XML — reads sitemanager.xml, base64-decodes <Pass> entries, maps <Protocol> integers, and batch-posts to /sites.
  • Directory comparison (View → Compare directories): diffs the current local vs remote pane and reports local-only / remote-only / differing-size.
  • About + bridge info modals with token copy.
  • Live events: WS-driven auto-refresh of remote/local panes on transfer-complete, of queue on any transfer event, of site list on sites.changed.
  • Connection chip in the menu bar — green when connected, red when not — plus toolbar disconnect/reconnect buttons that enable/disable by state.
  • Remote pane clears on every transition to disconnected (startup, explicit disconnect, server drop).

Security / hygiene

  • tauri.conf.json sets dragDropEnabled: false so the webview handles HTML5 drag-drop natively (Tauri's native interceptor blocks drop events otherwise).
  • Loopback bind only. Bearer token required on all non-/health endpoints. Passwords never persist to disk in cleartext.
  • .gitignore covers src-tauri/target, src-tauri/gen/schemas, IDE junk, tasks/.

Infrastructure

  • Provisioned an SFTP account on the release VPS:
  • First per-domain user chrooted to /srv/sftp/<domain-slug>/.
  • /var/www/<domain> bind-mounted into the jail via /etc/fstab.
  • Docroot set to root:deploy 2775 so group members can create files without violating sshd's chroot ownership rules.
  • Shell /usr/sbin/nologin — SFTP only.
  • Saved as the first GoGreenSuites SFTP entry in the Site Manager with the password in Windows Credential Manager.

Validation

  • 45 unit tests across auth, bridge, config, errors, localfs, persist, state, transport — all green with cargo test --lib. Handler-level assertions use tower::ServiceExt::oneshot so the real router runs without a listener.
  • scripts/test-endpoints.ps1 — 25 end-to-end endpoint assertions, all passing against the live IONOS SFTP server.
  • cargo check clean (3 benign dead-code warnings).
  • Upload integrity spot-checked: local md5sum matches remote md5sum bit-for-bit.

Observability

  • tracing_subscriber + tower_http::TraceLayer wired. Every HTTP request now emits a span with method, URI, status, and duration.
  • #[tracing::instrument] on post_connect, transfer_upload, transfer_download with host/port/user/path fields.
  • RUST_LOG=debug opens up internals; default filter keeps russh and suppaftp at warn so SFTP session chatter doesn't drown signal.
  • User-visible log pane fed by the log WebSocket event; last 1000 entries also available via GET /logs.

Working notes from this session

See tasks/lessons.md for gotchas captured along the way (russh Handler lifetimes, keyring 3.x feature flags, Tauri's dragDropEnabled, and more).