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_kibsince the per-site bandwidth roadmap item shipped (2026-05-02), wired all the way throughthrottle::set_global_rate_kibon 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;0keeps the historical "unlimited" behavior, and the server-sidevalidate()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 fromcfg.transferRateKib, save payload sendstransferRateKibclamped 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
(
#1B5E20dark,#4CAF50mid) 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.pngfor legacy MSIX packaging were intentionally left at the previous wordmark; only theicon.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) andsrc-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.mdgained a new section ("Icon swap pipeline (Tauri 2 on Windows)") documenting thecargo tauri icon→ cache invalidation flow, thelib.rs-touch trick to force re-link without a fullcargo 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.jsonis 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 bytesFTPXlet the loader detect ciphertext vs legacy plaintext JSON without speculative parsing. - Backward compat: existing plaintext
sites.jsonfiles 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-visibleoutline 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.chipspans getaria-hiddenso they're not double-announced. - Platform-disabled swatches (iCloud on Linux) now also set
aria-disabled— some screen readers don't track the DOMdisabledattribute reliably. - Wire console gets
role="log"+aria-live="polite"sostatusLog()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-onlyutility class for visually-hidden screen-reader text.- 13 new JS unit tests in
dist/__tests__/a11y.test.jslock 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 infoclean —tauri-plugin-single-instancev2.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.rsaccept_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-instancev2.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.jsonon 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 astd::panic::set_hookthat: - Always writes a JSON record to
<data_dir>/crash.log(rotated at 1 MiB) - Optionally POSTs to
$CRASH_REPORT_URLonly 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 idempotentinstall_is_idempotentassertion.
Installer pipeline¶
tauri.conf.jsonbundle.targetsmade explicit:msi,nsis,deb,rpm,appimage,dmg,app.- Wix
upgradeCodepinned (stable GUID) so future MSI upgrades replace cleanly instead of side-installing. - NSIS
installMode: perMachine— writes toC:\Program Filesfor enterprise compatibility. - macOS
minimumSystemVersion: 10.15(Catalina; ~99% market coverage). - Linux
.deb/.rpmdependsnow includecifs-utils(for SMBmount.cifs) andnfs-common/nfs-utils(for NFSmount.nfs) so package-manager installs get working SMB/NFS out of the box. appimage.bundleMediaFramework: trueso AppImage works on minimal distros without preinstalled webkit2gtk.- Existing
.github/workflows/release.ymlalready 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 andgit push --tagsproduces 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 athttps://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 readingHKLM\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 (Dropboxmust precedeBoxto avoid substring collision).- Unified
seen: HashSet<PathBuf>dedup across every push site incloud_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 callsdownload()and materialises the whole file into aVec<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'sbytes_stream()and pace each chunk throughThrottle::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/start→append_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), thencreateUploadSession+ 4 MiB chunked PUTs against the session URL. Memory footprint is now bounded byCHUNK(~4-8 MiB) regardless of payload size;Throttle::from_global()runs on every chunk. transport::sftp::connect_with_policy:russh_keys::load_secret_keywas synchronous file I/O inside anasync fn— slow / network-mounted homedirs could pin a tokio worker for seconds. Now wrapped intokio::task::spawn_blocking.transport::smb::SmbTransport::{connect,close}:places::map_network_drive/unmap_network_driveshell out tonet use/mount_smbfs/mount -t cifs, which can stall 60+ seconds against a dead host. Wrapped both inspawn_blocking.transport::nfs: switched fromstd::process::Commandtotokio::process::Commandfor mount + umount, and replaced thechrono::Utc::now().timestamp()mount-point names withuuid::Uuid::new_v4()so two NFS sites mounting in the same second can't collide.transport::dropbox::share_link: previousErr(_)swallowed all failures fromcreate_shared_link_with_settingsand silently fell back to listing existing links. Now matches on the documented409 / shared_link_already_existsmarker before falling back; auth / network / 5xx failures surface verbatim.
MEDIUM — correctness + race fixes¶
bridge::ws_events: switched WebSocket bearer-token compare from plain!=toauth::constant_eqfor parity with the REST middleware.bridge::close_sessionTOCTOU: held thesessionswrite-lock and theactive_session_idwrite-lock together so two concurrent close_session calls can't interleave and leaveactive_session_idpointing at a removed slot.state::active_session: replacedexpect("no sessions")with a graceful lazy-create fallback that pushes a fresh slot and emits atracing::warn!. The bridge no longer panics if the session list is ever drained.throttle::RATE_TEST_LOCK:.lock().unwrap()now usesunwrap_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 tometrics::tests::METRICS_TEST_LOCKafter a flakyinstall_is_idempotentrun.
LOW — polish¶
bridgerestore + share-link: both endpoints now accept an optionalsessionIdfield and route vialock_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 omitsscope=when the caller passes no scopes (Box's app-side scope model).transport::boxcloud: emittracing::warn!when the 10k-item cap onresolve_id/item_infopath-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 nowftproxy.local-tls.test(RFC 6761 reserved) instead of.invalid.lib::list_user_site_templates: factored the read-loop into a pureread_user_site_templates(&Path)helper + 7 inline tests covering missing dir, empty dir, well-formed JSON, missing required keys, invalid JSON, ignored non-.jsonfiles, 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/configGET + 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 indist/app.js:783-785already collapsesazure-files → azureand*-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 withstatusLog(...)per the CLAUDE.md "use statusLog instead of alert" rule. Levels chosen by context:warnfor "you forgot to fill in X",errfor catch-block surfacing,statusfor 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-rolledfetch(...)+ 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 hadLinks: 0(orphaned inode) after the docroot was redeployed;umount && mountre-bound it. - Re-applied deploy-group ACLs with
setfacl -R -m g:deploy:rwx -m d:g:deploy:rwx /var/www/gogreensuites.comso chrooted SFTP users in thedeploygroup 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 forbox-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 andplaces::cloud_sync_folders()reports a desktop-sync client's folder for which no<provider>-localsaved 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(alsoBox-…,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 insites.json+ the Site Manager. - Logs on every fire:
auto-seed: added local-sync sites count=Nplus 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_rootnow pre-fills to the detected sync path. Same auto-fill behaviour Dropbox / OneDrive / Google Drive already had when their desktop clients are installed. - Frontend
localSyncmap (dist/app.js) extended with aboxslot; matches the place's label in three forms: "Box"— Windows / Linux fallback (places.rs::cloud_sync_folderspushes this when%USERPROFILE%\Boxexists)"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.exampletemplate;.env.localalready in.gitignore. Stops the long-tail of "set $env:FTPROXY_VPS_HOST=…" in every PowerShell session. scripts/release.ps1andscripts/test-endpoints.ps1now source.env.localif 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 atitleattribute 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_kibso the existing save loop serialises it intoextras.bandwidth_kib.0and 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_connectnow logs (bothtracing::info!AND user- visiblestate.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>(needsnfs-common/nfs-utils). - Connection input forms accepted:
server:/export(canonical),nfs://server/export(URL form), or bareserver+extras.export. Optionalextras.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_protocolaccepts"nfs"- Bridge dispatch in both
dispatch_connectandpost_connect default_folder_for_protocol→"NFS"/healthprotocolsarray 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*.jsonin<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, andsitekeys. Light validation rejects malformed files with atracing::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.jsonwritten to the user'stemplates/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¶
SessionSlotgainedrate_kib: AtomicU64, set on connect from the saved site'sextras.bandwidth_kib(0= use the global rate).throttle.rsgained atokio::task_local!SCOPED_RATE_BPSthatThrottle::from_global()consults before falling through to the process-wide rate. New helperwith_scoped_rate_kib(kib, future)wraps any future with the scope.bridge::run_transfer_uploadandrun_transfer_downloadare 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 viaThrottle::from_global(), no per-transport edits needed.- UI input for
extras.bandwidth_kibin 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 tosites.jsonor viaPOST /sitesextras 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/rawendpoint — no new Rust code. - Pinned by 4 new tests in
dist/__tests__/queue-pill.test.js.
Public share-link generation (roadmap #3) — Dropbox / Google Drive / OneDrive¶
- New Transport trait method
share_link(remote) -> ShareLinkwith bail-by-default impl. Implemented on: - Dropbox:
sharing/create_shared_link_with_settingswith a fallback tosharing/list_shared_linksif a link already exists for the path (Dropbox returns 409 in that case). - Google Drive:
permissions.create(anyone-with-link reader) followed byfiles.get?fields=webViewLinkto get the URL Drive populates after the permission lands. - OneDrive:
/createLinkwithtype=view,scope=anonymous, pullslink.webUrlfrom the response. - Other transports degrade to the trait default ("not supported").
- New
ShareLinkstruct:{ url, isPublic, expiresAt? }. Surfaced to the wire as JSON via the new bridge endpointPOST /files/remote/share-linkwith{ remotePath }. - New right-click context menu item Get share link… visible on
dropbox/gdrive/onedrivefor 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
b2innormalize_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, markB2, 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:
b2added toPROTO_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
/healthprotocolsarray 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
Transporttrait —restore_object(&mut self, remote, days)andstorage_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'srestore_objectandhead_object. - Bridge: New
POST /files/remote/restoreendpoint 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 withInvalidObjectState. 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
s3andb2protocols 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 --syncuses 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
OverwritePolicyenum values: skip / overwrite / overwrite-if-newer (default) / resume / rename - Max depth (1–12, default 4)
- Dry Run checkbox — calls
/dir/compareinstead of/dir/syncso 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/syncbridge 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/downloadcalls — a pre-/dir/syncworkaround) 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
curlexample - 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.writeTextwith a textarea +execCommand("copy")fallback for environments that strip the Clipboard API. - New native menu id
help_automation_infoadded toNATIVE_MENU_IDS(Rust side) and the JS dispatch (in lockstep permenu_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 everykind === "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.rscovering 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 tobutton only navigatedif (state.connected)and ignored the bookmark'ssiteId. Clicking on a fresh session looked broken because the no-op was silent. The rewrite (dist/app.js:4412) funnels throughconnectSaved(site)— the same unified path Site Manager → Connect uses — so any bookmark with a validsiteIdconnects with full keyring lookup for passwords / OAuth tokens / object-store access keys, then cd's the remote pane tob.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 alocalPathis 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 (sinceplaces.rsalready formatsZ:\ (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.hostpopulated 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 ons.info?.connected— once disconnected, the label falls back tosession #<shortId> · <proto>and swatch clicks retint the text as expected. Title tooltip got the same gate ons.info?.usernamefor consistency. Pinned by 3 new tests.
Queue pill UX — ellipsis + horizontal scroll¶
- Each queue pill now caps at
max-width: 240pxwith a.fnspan 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 fulllocalPath ⇄ remotePathso the truncation is reversible. - Queue strip itself gets
overflow-x: autoas 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 filedist/__tests__/queue-pill.test.js(9 tests).
Real bugs caught by manual usage that no existing test covered¶
rustls 0.23panic on FTP/FTPS connect —suppaftp'sasync-rustlspath didn't auto-install aCryptoProvider. Without it the bridge crashed silently mid-request with "Empty reply from server" curl-side. Fixed inlib.rs::run()by callingrustls::crypto::ring::default_provider().install_default()before any TLS code path. Even plain FTP triggered the panic becausesuppaftplazy-loads the TLS stack at session-setup time.azure_core 0.21HMAC backend missing — SharedKey signing panicked with "Could not automatically determine the process-level CryptoProvider." Fixed by adding thehmac_rustfeature flag toazure_core,azure_storage, andazure_storage_blobs.- WebDAV PUT 401 in the GUI — IIS Basic Auth requires the user
to be in the
Userslocal group (for theLogonUsercall's "Allow log on locally" right).New-LocalUserdoesn't do that automatically. Documented intasks/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.dataChannelPortto 50000-50100 and opened the range in Windows Firewall. Without this,suppaftpgot connection-refused on data channels even thoughcurl ftp://127.0.0.1worked. - Tab-label stuck on stale protocol — session tab render used
s.info?.protocolfirst, so the label kept showing the prior session's protocol after disconnect. Reordered to preferstate.intendedProto(set on swatch click) when the tab isn't actually connected. - Swatch click chrome update intermittent —
state.connectedcould go stale (especially after the audit script's mass disconnect), causingapplyAccent()to take the live branch and ignorestate._forceProto.applyForcedAccent()nowawait refreshSession()before deciding, andrefreshSession()re-runsapplyAccent()wheneverstate.connectedflips. - Endpoint smoke /transfers/upload status check stale —
scripts/test-endpoints.ps1read$r.data.statusbut the upload handler returns$r.data.job.statuson 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-localdelete during sync lock, IIS FTP425PASV port-reuse race).
Day 1 of Azure Storage Entra ID auth refactor¶
transport/azure.rs::connect()now dispatches oninfo.extra["auth_method"](account_keydefault,sas,entra):account_keykeeps the existingStorageCredentials::access_keyflow — back-compat, no behaviour change for sites that don't setauth_method.sasconsumesinfo.extra["sas_token"](or password fallback) and constructsStorageCredentials::sas_token, with defensive?/full-URL prefix stripping for the common Azure-Portal copy.entrareturns a clear "not yet implemented" bail pointing at the newdocs/azure-entra-plan.md. Days 2-4 land the three sub-modes (auto/service-principal/interactive) + SharePoint Online + UI dropdown.sas_tokenadded tocredentials::SECRET_EXTRA_KEYSso 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 toCargo.tomlso Day 2'sDefaultAzureCredential/ClientSecretCredential/InteractiveBrowserCredentialcan land without a follow-up Cargo round-trip.- Day 2 work scheduled as a one-time remote agent
(
trig_019Lodw9zE5sXHSroem3gM6Qfires 2026-05-08 13:00 UTC). Seedocs/azure-entra-plan.mdfor the full Day 1-4 plan.
box-local protocol (Box Drive desktop sync)¶
- New
box-localslug recognized bybridge.rs::normalize_protocol(alsoboxlocal). - Routes to
LocalCloudTransportlike 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 aPlacefor 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, markBOX, kickerBox Drive (local sync), title<em>Box</em>). - Default Site Manager folder for
box-localisBox.
Azure rename — single "Azure Storage" family¶
- Folder default consolidated: both
azureandazure-filesnow default toAzure Storage(was split asAzure Blob/Azure Files). - Status-bar protocol labels:
Azure Storage (Blob)/Azure Storage (Files)(wasAzure 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 Storagefamily. - The Azure swatch's Connect button now shows a chooser when both
variants exist as saved sites (
actionPolicyandconnectFromToolbarcandidate-list special-casesfamily === "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 isdisabledwith 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 snapshotswasConnectedand re-invokesapplyAccent()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 JSstate.connectedis stale relative to the bridge.proto-markelement gets direct inlinebackground/borderColor/colorin 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.exampletemplate,.env.localalready in.gitignore. Bothscripts/release.ps1andscripts/test-endpoints.ps1auto-source it viaInvoke-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 --lib→ 241/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 test→ 122/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.ps1→ 25/25 against the IONOS SFTP target.scripts/audit-protocols.py→ 12/12.scripts/audit-protocols-crud.py→ 12/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_historyrows recorded assucceeded.
[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 fromPOST /sessions, copiesstate._forceProtointointendedProto[newId], and immediately POSTs/sessions/activeso 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 (becauseapplyAccent()short-circuits onstate.connected), but the click still primes_forceProtoso 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
smbtoPROTO_ACCENTS(slate-blue#94a3b8, markSMB, 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 inindex.html. - Added
--accent-smb: #94a3b8token +.swatch[data-proto="smb"] .tab .chip.smbCSS rules instyles.css.
Server-side — FTPS test surface¶
vsftpd3.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 range40000-40100. ufw rules added.- Single test user, chrooted to
/srv/ftps/<user>/with a writableuploads/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¶
SiteScheduleandBatchJobgainstart_at/end_at(Unix epoch seconds, optional). Scheduler tick gates firings outside the window.start_atlets users say "fire weekly starting May 5";end_atlets 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-syncfor the "I want to sync 1–2 specific files, not a whole folder" case. TakessiteId,direction,localDir,remoteDir,files[], optionalpolicy. 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
schedulebecomes a 1-step Job named "Schedule for <site>", then the inlinesite.scheduleis cleared. Idempotent — re-running on a fully-migrated config is a no-op. Wired inlib::run_headless_bridgeand 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)¶
BatchStepenum'srename_all = "camelCase"only renamed variant tags (Sync→sync), not the fields of each variant. The frontend was sendingsiteId/localPathbut Rust expectedsite_id/local_path. Fixed by addingrename_all = "camelCase"to each variant individually. Newfile_sync_step_serializes_with_camelcase_fieldstest locks down the wire shape.
Logging¶
crate::batch_jobs::runnowtracing::instrument'd withjob_idin the span.- Per-step
run_stepinstrumented withjob+kindfields. - File-sync step emits start/finish info-level logs +
state.push_logfor the visible log pane. - Migration logs each site → job conversion at INFO with
site_id,sitename, newjob_id,cron.
Tests — net +5 over v0.8.8¶
batch_jobs::tests::file_sync_step_serializes_with_camelcase_fields— assertssiteId/localDir/remoteDiron the wire and round-trips back through Deserialize.batch_jobs::tests::job_with_schedule_window_serializes_camelcase— assertsscheduleStartAt/scheduleEndAtshape and thatNoneskip-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_variantsextended to cover the newfile-syncvariant.- 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 ofGET /sessionsso the user's tab strip never flickers when cron fires.ConnectRequestandDirSyncBodynow accept an optionalsessionIdfield. When set, the connect/sync targets that slot instead of the active one and does NOT switchactive_session_id.scheduler::fire_schedulecreates 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 viastate::push_log. - Channels (env-driven, read at call time so
.envedits don't need a recompile): - Slack —
SLACK_WEBHOOK_URL({ "text": ... }payload) - Discord —
DISCORD_WEBHOOK_URL({ "content": ... }payload) - Telegram —
TELEGRAM_BOT_TOKEN+TELEGRAM_CHAT_ID(Bot APIsendMessage) - Webhook —
WEBHOOK_ON_SUCCESS_URL/WEBHOOK_ON_FAILURE_URL(generic JSON POST with full run record) - Email —
SMTP_*env recognized, but the in-process sender is a stub (would need addinglettreto 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 theDeliveryResultenvelope (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/syncagainst 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_historywithbatchIdset so the cross-site Job History view groups them). - Bridge endpoints:
GET /batch-jobs— listGET /batch-jobs/:id— onePOST /batch-jobs— createPUT /batch-jobs/:id— updateDELETE /batch-jobs/:id— deletePOST /batch-jobs/:id/run— trigger now (returns RunSummary)- Scheduler integration: each tick now also walks
BatchJobs with a populatedscheduleCronand fires those due in the same window. schedule_history::record_finish_with_batchoverload so batch steps stamp the run rows withbatch_id. The free-floatingrecord_finishkeeps 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.ScheduleRuncarries: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(schedulerfor cron,manualfor 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_tscutoff filter - bounded cap behavior. Tests serialize via a
TEST_LOCKso the shared on-disk file doesn't race.
Scheduler wired to record every firing¶
scheduler::fire_schedulenow returnsanyhow::Result<ScheduleRunStats>instead ofResult<()>. It parses/dir/sync's response envelope to extract uploaded / downloaded / failed counts.scheduler::scheduler_loopcallsrecord_startimmediately before each fire (status=running), andrecord_finishafter withsucceeded+ stats orfailed+ error string.triggered_byset to"scheduler"for cron firings,"manual"forPOST /scheduler/run-nowinvocations.
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. Defaultlimit=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_routerand emittracing::infoon 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=20and 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 menuinstall_native_menuand 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.envseeded 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 fromPROTO_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-inLOCAL(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 protocolsunfiltered or5 of 41 siteswhen 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 viaoauth_statusTauri 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_ACCENTSentries fordropbox-local/gdrive-local/onedrive-localwith marksDBX·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. applyAccentlookup 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_FILEpath) — 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.rsroute 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_secretssecret_extras_endpoint_404s_for_unknown_site- Test count: 177 unit + 16 integration (was 173 + 12 at start of v0.8.6 work).
Misc fixes¶
transportmodule is nowpub modso integration tests intests/can constructConnectionInfoand call transportconnect()directly (mirrors whatplaces::cloud_sync_foldersand friends already do).places.rs::cloud_sync_foldersGoogle-Drive detection:<drive>:(no trailing backslash) was being treated byPathBuf::joinas "current dir on G:" rather than the drive root, producingG:My Driveinstead ofG:\My Drive. Normalize trailing separator before join.oauth.rs::load()no longer silently returnsNoneon keyring failures. Logstracing::warn!on entry-build / read errors / JSON corruption (still treatsNoEntryas quiet, since "user hasn't signed in yet" isn't an error).- Removed the stray
let _ = (); // marker for clarityfrom the refresh-decision branch inoauth.rs. - 20 dead-code warnings → 0 (
cargo fixremoved 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 toprovider_config()(auth + token URLs +token_access_type=offlineextra). Threadedclient_secretthrough the whole flow so confidential clients (Dropbox; Google "web" / installed app types) can authenticate. Made PKCE conditional via apkce: boolfield onProviderConfig— Dropbox returnsinvalid_response_typeif PKCE is sent when the app's "Allow public clients" toggle is off, so we omitcode_challengefor Dropbox and send it for Google + Microsoft.save()now stripsaccess_tokenbefore 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 byrefresh_if_needed.refresh_if_needed()triggers when access_token is missing OR within 60s of expiry.- Bridge
post_connect: fordropbox/gdrive/onedrivesites with a stored OAuth identity, callsrefresh_if_neededand injects the fresh access_token asinfo.passwordautomatically. Transport stays oblivious. - New endpoint
GET /sites/:id/secret-extrasreturns keychain-stored extras (client_secret, service_account_json) so the Site Form can pre-populate masked fields on edit. client_secretadded tocredentials::SECRET_EXTRA_KEYS— never serialized tosites.jsonplaintext.
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.jsonon Mac/Linux) → finds custom Dropbox sync folders even when the user moved them off C:. - Reads OneDrive registry (
HKCU\Software\Microsoft\OneDrive\ Accounts\*\UserFolderviawinreg) → 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>-localwhen local mode is selected; on edit, recognizes the-localsuffix 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-filesprotocol. 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 toSmbTransport. 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-filesso 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¶
applyAccentandactiveProtoreordered: 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.applyForcedAccentclears_forceProtoafter 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 viadefault_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_browseron Windows now usesrundll32 url.dll,FileProtocolHandlerinstead ofcmd /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 noresponse_typeleft. rundll32 receives the URL as a single argument and dispatches via Windows shell URL handling, preserving the full string. Lesson saved totasks/lessons.mdand~/.claude/projects/.../memory/.
OAuth callback fixed-port + redirect URI¶
oauth::start_callback_listenernow binds on port 53682 (the convention gcloud uses for desktop OAuth flows) before falling back to a random port. The redirect URI useslocalhost(not 127.0.0.1) because Dropbox treatshttp://localhostas a wildcard for any port when whitelisting redirect URIs buthttp://127.0.0.1literally.- Users register
http://localhost:53682/oauth/callbackonce in their Dropbox app's Redirect URIs. Google + Microsoft honor the loopback wildcard so justhttp://localhostregistration works for those.
Test additions¶
tests/s3_multipart_resume.rs(gatedS3_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 macOSInfo.plistdocument.wflowvia theplistcrate, and validating the Linux.desktopentry against FreeDesktop spec keys.tests/updater_check.rs— 3 cases againsttauri_plugin_updater:: RemoteRelease: spins a localhost axum server, deserializes alatest.jsonpayload, validatesdownload_url()+signature()per target, and asserts version-comparison logic both up and down.- New
oauth.rstests: Dropbox provider config (offline, no PKCE), PKCE conditional in auth URL build, access_token strip insave, refresh-decision branches. - New
places.rstests: Dropbox info.json parsing (personal / business / malformed JSON degrades to None), OneDrive registry smoke, cloud_sync_folders return type. - New
bridge.rstests: localcloud + azure-files innormalize_ protocol, Azure Files SMB-shape translation guard, every supported protocol covered bydefault_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 returningNone— operators had no way to triage.bridge.rsOAuth 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.rsmodule installsmetrics_exporter_prometheusrecorder at startup (idempotent). Exposes/metricstext/plain scrape body — pre-auth (loopback-only deployment makes this safe). - HTTP middleware records
ftproxy_http_requests_total{method, path, status}counter andftproxy_http_request_duration_secondshistogram 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, andftproxy_transfer_duration_secondsper transfer. - Queue and session gauges (
ftproxy_queue_depth,ftproxy_sessions_connected) updated on every/healthpoll — Grafana sees them at the scrape cadence.
JSON access logs (opt-in)¶
RUST_LOG_FORMAT=jsonswitchestracing-subscriberto its JSON formatter so headless deploys can ship logs cleanly into Loki / ELK / Datadog without an intermediate parser.prettyandfullalso accepted; default stays human-readable so desktop dev experience is unchanged.tracing-subscribergains thejsonfeature.
Per-token rate limiting¶
- New
rate_limit.rs—governortoken-bucket keyed on the Authorization header. Default 600 req/min (10/s with a 100-rps burst window) per token, configurable viaAppConfig.rate_limit_per_minute(0disables). /health,/metrics,/eventsare bypassed so liveness probes and Prometheus scrapers can poll freely.- 429s are counted in the metrics counter alongside 200s.
PUT /configclears 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. degradedfires 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.rsgets an in-process LRU (60 s TTL, 256-entry cap) that wraps everykeyring::Entry::get_passwordcall. Repeated reads for the same site within a short window now skip the OS keychain round-trip.setandremovewrite 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 viastate.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.
/healthpolls and/metricsscrapes 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 --lib→ 155/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.jsonpersistence 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-APIbackend 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 sftpusersblock 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/fstabso 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(notreload 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'sremotePathis 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:rootwith 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.jsonor$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 getread_only: true, the bridge refuses PUT/DELETE on them with a clear "managed by IT" message, and the user-sidesites.jsonnever 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_offsetimpl on FTP transport — sendsREST <offset>beforeRETR. 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.skippedto 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 anyreqwest::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).socksreqwest feature added sosocks5://user:pass@host:1080URLs work alongsidehttp://.../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
.logfile. 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 --lib→ 142/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'sschedule.cronagainst the (last-tick, now] window, fires a/dir/syncfor 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 dowwith*and comma-lists. Thecroncrate is wrapped to add a synthetic seconds field so it agrees with the user-facing 5-field surface. - Headless
ftproxy-bridgeruns the same scheduler — Windows Task Scheduler can fire a backup at 3 AM with the desktop closed. - New
POST /dir/syncendpoint — 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 commandopen_remote_in_editordownloads to%TEMP%/ftproxy-edit/<sha8>/<basename>, launches the OS default editor (cmd /C start ""/open/xdg-open), and spawns anotifycrate 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¶
SessionSlotnow caches the last successfulConnectionInfo. Newdispatch_connect()rebuilds the transport from any cached info; newreconnect_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. HonorsAppConfig.auto_reconnect(default on); never retries on cancellation or auth failure.- New
POST /sessions/:id/reconnectendpoint 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 cheaplist("."). 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 callsListMultipartUploadsfirst. If a pending upload for this key exists (from a previous interrupted run), it pulls the already-uploaded parts viaListParts, carries their etags forward, and continues from the next part_number — no bytes re-sent, no double-billing on dangling part charges.UploadPartfailures no longer auto-abort — the upload-id is left pending so the next call resumes. NewS3Transport::abort_pending_uploads(remote)for explicit discard-and-restart.CompletedMultipartUploadparts are sorted by part_number before the finalCompletecall so resumed-then-extended uploads serialize correctly.
macOS / Linux Send To equivalents¶
- Renamed conceptually from "Send To shortcut" to platform-appropriate surfaces:
- Windows: existing
.lnkin%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" runsftproxy-cliagainst each selected path. Menu refresh viapbs -flush. - Linux:
.desktopfile in~/.local/share/applications/withMimeType=application/octet-stream;text/plain;…so Nautilus/Dolphin/Thunar surface "Open With → Go Green Transit" on every file. Best-effortupdate-desktop-databaserefresh. - Module renamed from "Windows-only" docstring; Tauri command works on every platform.
Auto-updater (Tauri plugin)¶
tauri-plugin-updater 2wired into the Tauri builder. Newcheck_for_updatesTauri command surfaces an Update / "you're on the latest build" prompt via Help → Check for updates…tauri.conf.jsoncarries 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 fromtauri-cli signer generate, updates flow.capabilities/default.jsongrantsupdater:default.
Code signing / notarization config¶
- New
SIGNING.mdwalks 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.versionbumped to0.8.3.
Tests¶
cargo test --lib→ 147/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.rsmodule + 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, callsplaces::map_network_driveto mount the share via the OS's native SMB stack; every file op (list,mkdir,download_to,upload_from, etc.) runs throughtokio::fsagainst the mount root. Onclose(), 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 bareserverwhen theshareextra is set. Anonymous connections work when username/password are blank. normalize_protocolacceptssmb/cifs/sambaaliases.- Bandwidth throttle is wired into SMB's streaming download/upload paths just like every other transport.
Tests¶
cargo test --lib→ 139/139 (was 134). New tests cover:- Places: smoke test for drive enumeration + quick-locations presence.
- SMB transport: pre-mounted-path attach (no real
net usecall, 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.jsonis now updated on every push / terminal-state change. On restart, anything that wasrunning/pending/queuedbecomesinterruptedwithbytesResumedarmed 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
OverwritePolicyenum onTransferJob:Skip/Overwrite/OverwriteIfNewer/Resume/Rename. - Bridge accepts
policyon/transfers/uploadand/transfers/download. Falls back to AppConfigdefaultOverwritePolicywhen missing. - New
Transport::download_to_offset(remote, w, offset, …)trait method. SFTP overrides with a realseek+streamimpl; everything else uses the defaultdownload_tofallback.
Conflict resolution UX¶
- Server-side handlers now check existence + mtime before kicking off
a transfer.
OverwriteIfNewercompares the local file'smodified()to the remoteRemoteEntry.modified_at;Renamefinds a free.N-suffixed name;Skipshort-circuits to statusskipped;Resumearmsbytes_resumed. Emitstransfer.skippedon the WebSocket event stream.
Bandwidth throttle (real, not a placeholder)¶
- New
throttle.rsmodule.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, andpath_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; honorsFTPROXY_BASE/FTPROXY_TOKENenv 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.lnkin%APPDATA%\Microsoft\Windows\SendTo\pointing atftproxy-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/execendpoint runs a one-shot command on the active SFTP session viarussh'sexecchannel. Captures stdout / stderr / exit status, capped at 1 MiB. Server menu → "Run command…". Transport::run_commandtrait method (default: bail with "SFTP-only"). SFTP override threads throughchannel_open_session/exec/wait.
Speed-test / benchmark¶
- New
POST /benchmarkendpoint 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.xmlfavorite, surfaces directory hierarchy as thefolderfield. - CuteFTP: registry import for CuteFTP 7 / 8 / 9 under
HKCU\Software\GlobalSCAPE\CuteFTP {N}\Sites. Supports nested site folders → flattens toFolder1 / 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 --lib→ 134/134 (was 111). New tests cover:- OverwritePolicy wire round-trip + unknown-fallback.
- TransferJob back-compat deserialize (no
policy/bytesResumedfields). - 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 sync —
SavedSite.scheduleandSiteScheduleare 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 infrastructure —
dist/i18n/{en,es,fr}.json+ at()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 andbytesResumed; 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_pagenow readsNextMarkeroff theListBlobsResponseand threads any caller-suppliedListOpts.continuationback into the request viaListBlobsBuilder::marker. Containers with >1000 blobs paginate correctly; the bridge surfaces the marker as the opaquenext_tokenin theListPageenvelope. - GCS:
list_pagealready threadedpageTokenon the request side, but the response struct (ListResp.next_page_token) had no#[serde(rename_all = "camelCase")]and so never matched GCS'snextPageTokenwire 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 →
Nonecoalescing 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 + GCSlist_pagewithentriescount andhas_nextflag atdebug. 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:
nextPageTokenJSON decode + empty-token coalesce. cargo test --lib→ 111/111 (was 107).npm test(Vitest) → 39/39 (unchanged — pure-backend change).
Doc cleanup¶
tasks/todo.mdrewritten to reflect 0.7.4. The "Known gaps" section is now down to one item (mobile packaging, deferred).tasks/future.mdrewritten — 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.mdupdated: Target A (MCP server) marked shipped viasrc-tauri/src/bin/ftproxy-mcp.rs.AUDIT-2026-04-26.mdhead 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…andImport 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 fromHKCU\Software\FTPware\CoreFTP\Sitesand the free-edition…\CoreFTPLE\Sitesroot, mapping thePTypefield (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_puttyids are gone, replaced byfile_import_winscp,file_import_putty,file_import_coreftp.NATIVE_MENU_IDSand the__ftpMenuActionswitch are kept in lockstep; new Vitest contract test pins the JS side. importFromRegistry(source)is now table-driven via aREGISTRY_IMPORTSmap so adding the next client is a one-line diff in JS plus a Tauri command in Rust.
Logging¶
tracing::instrumentonwinscp_sites,putty_sites, and the newcoreftp_sites. Each emitsinfowith the scanned-count ordebugwhen 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 returnNone. A no-op smoke test confirmscoreftp_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__ftpMenuActionrecognises the newfile_import_*ids and not the oldbookmarks_import_*ones.
Test totals¶
cargo test --lib→ 107/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
extramap 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 hashandCompare dirsbuttons 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-sepdividers collapse with them.actionPolicy(cmd, proto)is the single source of truth for enabled / disabled / hidden / tooltip per cell — implements theAUDIT-2026-04-26.md§9 matrix end-to-end.updateToolbarState()runs synchronously insideapplyAccent()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 · SFTPinstead ofDisconnectedso the chrome looks intentional rather than empty.
Native menu¶
View → Hard Reload (Ctrl+Shift+R)added — clearscaches.keys()sessionStoragebeforelocation.reload(). Same pattern as GoGreenMarketing. Fixes the stale-WebView2-cache class of bug permanently.
Tests¶
cargo test --lib→ 104/104 (was 96; +8 new: FTPS aliases, HashQuery deserialisation, redownload cap constant, StoredToken serde roundtrip, pkce-pair-uniqueness, credentials helpers).npm test→ 30/30 (was 11; +19 indist/__tests__/action-policy.test.jscovering 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 registerftproxy-mcpwith 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.mdrefreshed with a status-delta header marking every sprint-1 item DONE; original audit body preserved as a historical snapshot.SECURITY-AUDIT-2026-04-26.mdrefreshed — 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.mdextratable now documentsencryption,logon_type,key_path,key_passphrase, plus notes on thepassword-vs-extra.access_tokenDropbox routing.USER-GUIDE.mdadds an FTP-family Encryption + Logon Type section, Hard Reload to the keyboard shortcuts.CREDENTIALS.mdadds SFTP key-file auth setup + an FTPS section.IMPLEMENTATION.mdsource map addsftps.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.comon 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_policydetectsextras.logon_type == "key", loads the key viarussh_keys::load_secret_key, and authenticates viaauthenticate_publickeyinstead of password. connectSaved()honorslogon_type: prompts whenask, substitutes anonymous credentials at the bridge layer.
S3 multipart upload (>5 GB)¶
transport::s3::uploadswitches toCreateMultipartUpload→ loop ofUploadPart(64 MiB chunks) →CompleteMultipartUploadwhendata.len() > 5 GB. Falls back to single PutObject for smaller payloads. Best-effortAbortMultipartUploadon failure to avoid leaving billable orphan parts.
OneDrive resumable upload (>4 MiB)¶
transport::onedrive::uploadPOSTscreateUploadSessionfor files larger than 4 MiB, then PUTs 4 MiB chunks against the session URL withContent-Rangeheaders. Best-effort session DELETE on chunk failure.
F-LOW-3: SFTP TOFU first-run explainer¶
- One-time modal on bootstrap (gated by
localStoragekeyftproxy_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>/tokenorFTPROXY_TOKENenv var; base URL fromFTPROXY_BASE(defaulthttp://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.jsongainsvitest 1.6+jsdom 24devDependencies andnpm test/npm test:watchscripts.vitest.config.jsoverrides the default**/dist/**exclude so tests underdist/__tests__/are picked up.- First test set:
dist/__tests__/helpers.test.js— 11 assertions coveringextOf,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 viasuppaftp's async-rustls feature (already enabled). Both Explicit (AUTH TLSupgrade on port 21) and Implicit (TLS handshake on port 990) modes wired, using awebpki-roots-backed rustls connector.normalize_protocolacceptsftps,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
deprecatedfeature forconnect_secure_implicit.
Security findings closed¶
- F-MED-1: GCS service-account JSON moved to OS keychain. New
credentials::SECRET_EXTRA_KEYSlist;bridge::post_site/put_sitemove every listed key out ofextraand store it under keychain key<site_id>:<extra_key>.post_connecthydrates from keychain back into the in-memoryextramap for the transport.delete_sitecleans up. Result: nothing insites.jsonis 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 amake_span_withbuilder 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.jsonapp.security.cspgoes fromnulltodefault-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-auditjob runs on every push:cargo clippy --lib --tests -- -W clippy::all -A dead_codeandcargo 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()afterfs::write. No-op on Windows (NTFS user-ACL covers it).
Per-protocol Verify hash exposure¶
- Dropbox —
remote_hashreturns the server'scontent_hash(Dropbox's custom 4 MB-block SHA-256-based scheme; algo namedropboxorcontent_hash). - Google Drive —
remote_hashreturnsmd5Checksumfrom Drive API metadata (algomd5). - OneDrive —
remote_hashreturns whichever Graph hash the caller asks for (sha1,sha256,quickxor);autopicks the best available. - SFTP / WebDAV fallback —
bridge::get_files_remote_hashnow re-downloads + hashes locally (md5 / sha256) when the protocol's native primitive returnsNone. 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_outTauri command; drops the keychain entry; updates the status line to "Signed out — keychain entry removed".
Cleanup¶
- Deleted dead
openKebabMenuJS function (kebab UI removed in v17). - Deleted
body.theme-lightCSS overrides —applyTheme()is dark-locked, so the light tokens were unreachable. - Deleted stray
ftproxy-run.logat the repo root; added*.logto.gitignore. - Archived
CODEX.md→tasks/archive/CODEX-initial-overhaul.md. - Archived
tasks/ui-restyle-progress.md→tasks/archive/ui-restyle-progress-2026-04-24.md. tasks/memory.mdupdated: 9 protocols (was 6), 96/96 tests (was 74/74), OAuth + secret-extras notes added.IMPLEMENTATION.mdsource map updated:oauth.rslisted, 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_defaultstransport::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-mcpbinary (3-day MCP server wrapper) — biggest single strategic unlock perAUDIT §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 challengestart_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 aStoredTokenrefresh_if_needed()checksexpires_at - now < 60s, refreshes viarefresh_tokengrant when stale- Token storage: OS keychain via existing
keyringcrate, serviceai.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 bystatenonce.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 withq=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.nextLinkpagination),mkdir(folderfacetconflictBehavior: 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
gdriveandonedrive: replaced the "OAuth not implemented" warning with a real Sign in with Google / Sign in with Microsoft button that callsoauth_sign_in, opens the system browser, and shows live status: "Signed in. Token expires …" - New
oauth_keyfield (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_statusand 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_paramsoauth::tests::microsoft_provider_config_resolvesoauth::tests::unknown_provider_errorsoauth::tests::random_state_is_url_safetransport::gdrive::tests::missing_client_id_message_helpfultransport::gdrive::tests::missing_oauth_key_message_helpfultransport::onedrive::tests::item_url_root_vs_nestedtransport::onedrive::tests::url_escape_handles_spacestransport::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_idfield - 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 → grantFiles.ReadWritedelegated 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 viareqwest, personal access token auth (no OAuth dance). Token comes in viaextra["access_token"]from the Site Form (or as a fallback in thepasswordfield).- Implements
list(with cursor pagination vialist_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/uploadwithmode: overwrite), andsize(files/get_metadata). - Connect-time smoke test against
users/get_current_accountso 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 tokenpassword field with link todropbox.com/developers/apps. - Accent: Dropbox blue
#0061fffor 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 toconsole.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_idfield + warning that the flow isn't wired yet. - Accent: Google brand yellow
#fbbc04.
Microsoft OneDrive — scaffold (OAuth pending)¶
transport/onedrive.rs— same shape asgdrive.rs. Connect succeeds, list / transfer error with hint toportal.azure.comAAD app registration.- Site Form extras:
client_id+tenantfields. - Accent: Microsoft blue
#0078d4.
Plumbing changes¶
bridge::normalize_protocol()now acceptsdropbox(aliasdbx),gdrive(aliasesgoogle-drive,googledrive), andonedrive(aliasesone-drive,msgraph).post_connectdispatches the three new protocols to their respectiveTransport::connectconstructors.- Frontend
PROTO_ACCENTSmap gets three new entries with proper colors / kicker text / mark badges (DBX/GDR/1DR). SINGLE_PANE_PROTOSset 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 quirktransport::dropbox::tests::missing_token_yields_helpful_errortransport::gdrive::tests::pending_error_carries_setup_hinttransport::onedrive::tests::pending_error_carries_setup_hintbridge::tests::normalize_protocol_cloud_aliasesNATIVE_MENU_IDScontract 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/sitesPOST. 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…/Disconnectbuttons 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 broken —
openCtxMenuandopenMenuwere setting inlineleft/topon the inner.ctx-menu/.dropdownelements, but only the outer#ctx-root/#dropdown-roothasposition: 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 opensgogreenpaperlessinitiative.comin the user's browser. .brand-lockup/.brand-primary/.brand-secondaryCSS preserved so the "GoGreen Transit" text still reads alongside the logo.
Logging¶
statusLogentries 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.
Navigation¶
- 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 acmdwire 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-logsin 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.rsinstalls a real Windows menubar via Tauri'sMenuBuilder/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-actionviawindow.eval(__ftpMenuAction(id))which routes to the existing JS dispatchers — no@tauri-apps/apinpm dependency required. NATIVE_MENU_IDSconst + 3 unit tests assert the id contract between Rust and JS never drifts silently.
Layout-per-protocol¶
.workspace.single-paneCSS 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↑ Uploadaction 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-bodyusesheight: 200pxandmax-height: 200px+overflow-y: auto(wasmax-heightalone, which let the box shrink to content height instead of holding 200px)..workspaceuses fixedheight: 520pxso the panes are bounded and.rowsactually scrolls inside them (previously unboundedmin-height: 460pxgrew the pane to fit 50+ files).applyTheme()is dark-locked — config.theme no longer leaks atheme-lightclass that was washing out colours.--accent-sftpcorrected from#5eead4to#7dffb2(matches mock)..action.disabledis nowcolor: var(--ink-mute)only (noopacity: 0.5) — disabled buttons are still legible.- Session-tab strip moved out of
.shell-headinto 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/.failclasses at 11px mono (was.queue-summaryat 10px uppercase); separators are mid-dot·atvar(--ink-faint); "done" rendered in--ok, "failed" in--err— matches mock verbatim. - Statusbar uses inline
.okgreen dot +.vvalues with units baked in ("0 KB/s","— ms"); dropped the.uunit span that was dimming units withvar(--ink-faint). - Body padding
36px 36px 64px, max-width1440pxon concept-tag / tabs-strip / shell / palette / footnote — matches mock exactly. - Added the mock's
rise/rowIn/wireInkeyframe animations. - Wire-line rows stamped with
wire-newclass so new entries animate in, matching the mock's streaming effect.
In-app connect paths¶
Connect…·Sites…·Disconnectactions 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-finalshown 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¶
renderFileListnow emits<div class="row">with the mock's structure: glyph (↑ / ▸ / ◆ / ⇢), name (.n+.extpill + 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).renderSessionTabsnow emits.tabwith a coloured.chip.<proto>square, a.xclose button, and a trailing dashed.tab-add.renderTransfersemits.q-itempills (active / ok / fail) with direction arrow, filename, and status/percent chip.statusLogwrites.wire-line.tx/.rx/.sys/.err/.warnrows with[ts] [TAG] [msg]shape; rx pulls the numeric response code into a.codespan, 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 withpulse · live · host · uptimewhen connected; a rolling tick updates uptime every second.updateModeRail()populateschannel / type / AUTH TLSpills from the live session.updateWireMeta()keepsCTRL :<port>andTLS · session · N framescurrent.
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 — existingMENUSdispatch unchanged. - Connect action opens a focused Quick-Connect modal that writes back
to the hidden
#qc-*inputs then callsquickConnect(). - Keyboard shortcuts:
Uupload,Ddownload,Rrefresh,F5refresh both,Escclose dropdowns/menus (existing).
Chrome¶
- Drag-over highlight rule
.pane.drag-over+ pseudo "drop to transfer" label added todist/styles.css. - Hidden
#local-path/#remote-pathtext inputs retained for backwards compat with existingnavigateLocal/Remoteplumbing; 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
ftproxyhandle for continuity.productNameand window title intauri.conf.jsonupdated.
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
--accentCSS variable drives the whole UI;applyAccent()indist/app.jsre-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.jspreserved 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¶
ConnectionInfoand the bridgeConnectRequestgained anextra: HashMap<String, String>field.SavedSite.extra(optional, serde-default so existing sites still load) persists protocol-specific settings alongside host/user.post_connectmergesextrafrom the connect body with any stored on the referencedsiteIdso the UI doesn't have to resend cloud config every time.
S3 transport (transport::s3)¶
- Built on
aws-sdk-s3(v1). Probes withHeadBucketon connect. - Inputs:
username= access key,password= secret access key,extra.bucket(required),extra.region(defaults tous-east-1),extra.endpoint(forces path-style, enabling MinIO, Cloudflare R2, DO Spaces, Wasabi, etc.),extra.path_styleoverride. - Operations:
list/list_pageviaListObjectsV2with/delimiter (common prefixes become directories);mkdircreates a zero-byteprefix/marker;rmdirbatchesDeleteObjectsin chunks of 1000;rename= CopyObject + DeleteObject;download/upload+ streamingdownload_to/upload_from;sizeviaHeadObject. - Single-part
PutObjecttoday; multipart (required for >5 GB) is a follow-up.
Azure Blob transport (transport::azure)¶
- Built on
azure_storage+azure_storage_blobs. Probes withget_propertieson the container. - Inputs:
username= account name,password= account key,extra.container(required). - All operations target BlockBlobs.
rename= CopyBlob (server-side) - DeleteBlob.
list_pagecurrently returns one page per call; Azure's continuation plumbs through the same stream.
GCS transport (transport::gcs)¶
- Raw REST against
storage.googleapis.comwith a service-account JWT exchanged for an OAuth2 access token (cached, refreshed ≥30 s before expiry). Avoids the fullgoogle-cloud-storagecrate to keep the dep tree tight — only addsjsonwebtoken+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_pageusesobjects.list?delimiter=/;uploadusesuploadType=media;renameusesrewriteTo;rmdiriterates + deletes (no batch delete on GCS).
Bridge / routing¶
normalize_protocolacceptss3,azure/azureblob/azure-blob,gcs/gs, plus the existing ftp / sftp / webdav.post_connectdispatches to the right transport constructor.port = 0is 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.
connectSavednow sends the site's storedextrain the connect body so cloud tabs reconnect cleanly.
Client crate¶
opensentinel-ftproxy-clientgainsConnectRequest.extra,SavedSite.extra, andNewSite.extrafields. Consumers targeting cloud backends pass the same key/value pairs the bridge expects.
Tests¶
cargo test --libmain: 74/74 passing (was 69).cargo testclient 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 onConnectionInfo.
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 throughopts.continuation. Low-priority until someone hits a1000-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_slotsis nowRwLock<Arc<Semaphore>>.PUT /configwith a newconcurrencyvalue 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/uploadand/downloadroute through a newslot_for_session/lock_for_sessionhelper 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_downloadwere extracted intorun_transfer_upload/run_transfer_downloadfunctions that take an optionalsession_id; the old routes delegate withNone.
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.rsnow exposesbuild_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— runscargo test --libon the main crate pluscargo test --all-featureson the client crate.endpoint-tests— spinsscripts/docker-compose.test.yml(atmoz/sftp + pure-ftpd), builds + runsftproxy-bridge, locates the token in the Linux XDG data dir, and executesscripts/test-endpoints.ps1withFTPROXY_TEST_STACK=docker.test-endpoints.ps1token lookup is cross-platform:FTPROXY_TOKENenv var wins, thenAPPDATA,XDG_DATA_HOME,HOME/.local/share, with a final recursive fallback.
WebDAV transport (first real plugin)¶
- New
transport::webdavmodule implementing theTransporttrait viareqwest+quick-xml. Covers PROPFIND (list+list_page), MKCOL, PUT (buffer-first upload with cancel), GET (streamingdownload_towith 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) andpost_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 --libmain crate: 69/69 passing (was 64).cargo testclient 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
SessionSlottype holding its owninfo: RwLock<SessionInfo>andtransport: Arc<Mutex<Option<Box<dyn Transport>>>>.AppStatenow ownssessions: 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.changedWS 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¶
RemoteEntrynow documents akind = "object"value for S3-like backends;ListPage { entries, next_token }+ListOpts { prefix, continuation, limit }added to theTransporttrait with a default impl that returns the whole listing (preserving SFTP/FTP behaviour).- New
GET /files/remote/page?path=&continuation=&limit=endpoint. transport::pluginsubmodule 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 = trueon 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 reportunverified. - Host-key mismatch modal: the bridge now emits
hostkey.mismatchwith{ 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 callsPOST /hostkeys/trust. test-endpoints.ps1docker switch: set$env:FTPROXY_TEST_STACK = "docker"to point the suite atscripts/docker-compose.test.yml(atmoz/sftp on 22322, demo/demo). The script auto-provisions a temporary site in that mode.
Tests¶
cargo test --libon the main crate: 64/64 passing (was 60).cargo testonopensentinel-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.jsonwith fields:defaultLocalPath,concurrency,logLevel,theme,verifyAfterUpload,ftpTransferType,completionSound,strictHostKey. Loaded at startup, clamped/validated on write.bookmarks.rs—Bookmarkas(site_id?, remote_path, local_path?, note?). Persisted tobookmarks.json. Distinct fromSavedSiteso one site can have many working directories.known_hosts.rs— SFTP fingerprint store (JSON, not OpenSSH format) withcheck()returningUnknown | 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 transferbytesTotal(SFTPmetadata, FTPSIZE).remote_hash(remote, algo)— FTP issuesXMD5/XSHA256/XCRCvia the SITE-like channel; SFTP returnsNone.ProgressFncallback 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_policyrecords theSeenKey(algorithm + fingerprint + verdict) from russh'scheck_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.seenWS 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: truein the connect body.
New endpoints¶
GET /config— server-side settings.PUT /config— validate + persist + broadcastconfig.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 FTPHASH/XMD5/XSHA256/XCRCresponse.
Transfer pipeline¶
transfer_download/transfer_uploadnow call the streaming variants and emittransfer.progressWS events throttled to 250 ms.- Cooperative cancel:
DELETE /transfers/:idsignals the worker via awatch::Sender<bool>stored inAppState.cancel_flags. The worker short-circuits between chunks, surfaces atransfer.cancelledevent, and cleans up the partial file for downloads. transfer_slots: Arc<Semaphore>gates concurrent transfers toAppConfig.concurrency(default 2, clamp 1–16).- Optional post-upload integrity verify: when
config.verifyAfterUploadis on, the worker computes local MD5/SHA-256/CRC32, asks the server for its own hash, and attachesverified: true|false|nullto the response.
Frontend¶
- Dark mode with a new
theme-darkbody class and a three-way Settings toggle (light / dark / auto). "Auto" followsprefers-color-scheme. - Settings modal now reads/writes
/configon 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.progressevents. Per-row cancel button (✕) issuesDELETE /transfers/:id. - Directory sync modal (View → Compare directories) uses
POST /dir/compareand 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.ymlbrings upatmoz/sftpon 22322 andstilliard/pure-ftpdon 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
AppStaterefactor (would invalidate the/session/*contract for external consumers). Tracked intodo.md. - Plugin surface for S3/WebDAV/GCS —
RemoteEntrygeneralization. - Standalone
opensentinel-ftproxy-clientcrate (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
TcpListenerHTTP toy with anaxum+tokioserver. 29 routes, bearer-token middleware, CORS, WS. Transporttrait with two production impls:- SFTP via
russh0.45 +russh-sftp2.1 (pure-Rust, async). Upload usesopen_with_flags(CREATE | WRITE | TRUNCATE)(OpenSSH's sftp-server rejects the flagscreate()emits). - FTP via
suppaftp6 withasync+async-rustlsfeatures. Usesretr_as_stream/finalize_retr_streamandput_filewithfutures_util::io::Cursor. - Localhost bind, preferred port 7878 with fallback to an OS-assigned
ephemeral port (
FTPROXY_PORTenv override). - Bearer-token auth on every endpoint except
/health. Token generated on first launch, written to the app data dir. bridge.urldiscovery 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 3with thewindows-native(+apple-native,sync-secret-service) feature flags explicitly enabled. - WebSocket
/eventswith 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.jsonsetsdragDropEnabled: falseso the webview handles HTML5 drag-drop natively (Tauri's native interceptor blocks drop events otherwise).- Loopback bind only. Bearer token required on all non-
/healthendpoints. Passwords never persist to disk in cleartext. .gitignorecoverssrc-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 2775so 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 withcargo test --lib. Handler-level assertions usetower::ServiceExt::oneshotso 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 checkclean (3 benign dead-code warnings).- Upload integrity spot-checked: local
md5summatches remotemd5sumbit-for-bit.
Observability¶
tracing_subscriber+tower_http::TraceLayerwired. Every HTTP request now emits a span with method, URI, status, and duration.#[tracing::instrument]onpost_connect,transfer_upload,transfer_downloadwith host/port/user/path fields.RUST_LOG=debugopens 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
logWebSocket event; last 1000 entries also available viaGET /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).