Vibecode Transmit
track this build5 phases, 15 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Transmit, build a focused SFTP and object-storage client with bookmarks and transfer queue. The hard boundary is protocol compatibility, native polish, sync, security, and ongoing maintenance, plus os integration polish and maintenance.
You are building a lean indie version of Transmit. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Transmit · indie build A two-pane transfer client that connects over SFTP and to S3-compatible object storage, keeps encrypted bookmarks, queues transfers that survive a dropped connection and resume rather than restart, verifies what it uploaded, and never silently overwrites a newer file. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | the native file panel, keychain access and drag and drop all want AppKit | | SFTP | libssh2 through a thin Swift wrapper | implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do | | Object storage | The S3 API over HTTPS with signature version 4 | one API covers every S3-compatible provider, which is most of them | | Credentials | The macOS keychain, never a settings file | a bookmark file containing passwords is a file people sync and share by accident | | Queue | A persisted transfer queue with byte-level resume | a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14 or newer** · free if you own one - Why: The native panel, keychain and drag and drop are platform APIs. - Get it: Apple menu, then About This Mac. - Verify: About This Mac reports macOS 14 or higher - [ ] **Xcode 16 or newer** · free - Why: Builds the application and links libssh2. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **A real SFTP server you control** · a few USD a month, or free on hardware you own - Why: Local testing hides authentication, host key and timeout behaviour. You need somewhere you can also change the host key deliberately in Phase 1. - Get it: Any small virtual server with SSH, or a machine on your network. You must be able to regenerate its host key to test the refusal path. - Verify: You can connect and can change the host key on purpose - [ ] **An S3-compatible bucket with credentials** · storage is cheap; some providers charge for requests and egress - Why: Phase 3 needs a real bucket. Providers differ subtly in their S3 implementations, so test against the one you will actually use. - Get it: Create a bucket with any S3-compatible provider and credentials scoped to it. Confirm it supports multipart upload before relying on it. - Verify: A signed list request returns the bucket contents - [ ] **Test files including one over the multipart threshold** · free - Why: Resume and multipart both need a file large enough to interrupt partway, which a small file does not give you. - Get it: Generate a file comfortably above your multipart threshold. Keep it as a fixture. - Verify: A file large enough to interrupt mid-transfer is available ## Quick start ```sh xcrun notarytool submit Transfer.zip --keychain-profile AC --wait ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. - protocol compatibility, native polish, sync, security, and ongoing maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes If one of those is essential to you, that is the reason to keep paying for Transmit, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Transmit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS file transfer client for SFTP and object storage with bookmarks and a resumable queue to replace Transmit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · the native file panel, keychain access and drag and drop all want AppKit - SFTP: libssh2 through a thin Swift wrapper · implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do - Object storage: The S3 API over HTTPS with signature version 4 · one API covers every S3-compatible provider, which is most of them - Credentials: The macOS keychain, never a settings file · a bookmark file containing passwords is a file people sync and share by accident - Queue: A persisted transfer queue with byte-level resume · a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating ### Data model (create this before Phase 1) - bookmarks(id, name, protocol, host, port, username, remote_path, local_path, keychain_ref) · never a password field - transfers(id, bookmark_id, direction, local_path, remote_path, bytes_total, bytes_done, state, attempts, error, queued_at) - conflicts(transfer_id, reason, local_mtime, remote_mtime, local_size, remote_size, resolution) - verifications(transfer_id, algorithm, local_digest, remote_digest, verified_at) - The queue is persisted, so quitting mid-transfer and relaunching resumes rather than starting again. ### Phase 1 · Connect and list, with host keys taken seriously Build: Connect over SFTP with key or password authentication, list a directory, and store the server's host key on first connection. On a subsequent connection with a different key, refuse and explain, rather than showing a dialogue people click through. Trap: A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. Done when: A connection to a real server lists a directory. The host key is stored on first connection. Changing the key on the server causes a refusal rather than a warning. Credentials are in the keychain and absent from every file on disk, verified by a grep. Do not build yet: No transfers, no queue, no object storage. ### Phase 2 · Transfers that resume Build: Upload and download with progress. Persist byte offsets so an interrupted transfer resumes rather than restarting. Retry with backoff on a dropped connection. Quitting and relaunching must resume the queue. Trap: Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. Done when: A transfer interrupted by pulling the network cable resumes from its offset rather than from zero. Quitting mid-transfer and relaunching resumes the queue. A resumed file is byte-identical to the source, verified by digest. Repeated failures stop after the attempt cap with the error visible. Do not build yet: No object storage or conflict handling yet. ### Phase 3 · Object storage as a second protocol Build: Add S3 support with signature version 4, multipart upload for large files, and listing with prefixes presented as folders. Be explicit where object storage differs from a filesystem, because pretending it does not is where users lose data. Trap: Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. Done when: A bucket lists with prefixes shown as folders. A file over the multipart threshold uploads in parts and is complete and correct afterwards. Deleting a prefix warns with the object count before proceeding. An interrupted multipart upload is either completed or aborted rather than left as billed incomplete parts. Do not build yet: No conflict resolution or verification yet. ### Phase 4 · Conflicts and verification Build: Before transferring over an existing file, compare size and modification time and present a conflict with both sides shown. Default to refusing rather than overwriting. After transfer, verify with a digest where the protocol allows one. Trap: Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. Done when: Uploading over a newer remote file produces a conflict showing both sizes and timestamps rather than overwriting. Choosing to overwrite proceeds and is logged. A completed upload is verified by digest where the protocol supports it, and a deliberately corrupted transfer fails verification. Do not build yet: No synchronisation or distribution yet. ### Phase 5 · Synchronising a folder Build: A one-way sync with a dry run listing every intended upload, update, and deletion. Deletions require explicit confirmation. Exclusion patterns for things that should never be uploaded. Trap: A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. Done when: A dry run lists every intended change and performs none, verified against the remote listing afterwards. Deletions are counted separately and require their own confirmation. An exclusion pattern keeps matching files out of every operation. Running sync twice with no changes performs no transfers. Do not build yet: Distribution comes last. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the credential handling, because a transfer client holds keys to servers. Trap: Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share. Done when: A notarized build opens on a fresh Mac with no Gatekeeper warning. Exported bookmarks contain no credentials, verified by inspecting the file. The README states where credentials live and what an export does and does not include. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of protocol support in the commercial client, which covers a long tail of servers with their own quirks. - The years of compatibility work behind handling servers that implement standards loosely, which is most of the real cost in this category. - Mounting remote storage as a local disk, which is a filesystem extension and a different kind of project. - Two-way synchronisation with conflict resolution, which is meaningfully harder than the one-way sync here. ### README must contain - That a changed host key causes a refusal rather than a warning, and how to accept a new one deliberately - That credentials live in the keychain and never in a bookmark file or an export - That transfers resume by verified offset rather than by appending blindly - That object storage prefixes are not directories, and what deleting one actually does - That sync deletions are counted and confirmed separately from the sync itself ===== AGENTS.md ===== # Agent instructions · Transmit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, libssh2 through a thin Swift wrapper, The S3 API over HTTPS with signature version 4, The macOS keychain, never a settings file, A persisted transfer queue with byte-level resume. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. ===== BUILD_PLAN.md ===== # Build plan · Transmit A two-pane transfer client that connects over SFTP and to S3-compatible object storage, keeps encrypted bookmarks, queues transfers that survive a dropped connection and resume rather than restart, verifies what it uploaded, and never silently overwrites a newer file. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Connect and list, with host keys taken seriously You can browse a remote server, and an unexpected host key stops you rather than warning you. ### Steps 1. Wrap libssh2 for connect and list Key and password authentication, directory listing with sizes and modification times. Files: `SFTP.swift` 2. Store and check host keys First connection stores; later connections compare. A mismatch is a refusal. Files: `HostKeys.swift` 3. Put credentials in the keychain Bookmarks store a keychain reference, never a secret. Grep your settings file to prove it. Files: `Credentials.swift` ### Done when - [ ] A real server's directory lists correctly - [ ] A changed host key causes a refusal, not a warning - [ ] No credential appears in any file on disk - [ ] Key-based authentication works with a passphrase-protected key ### Watch out - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. ## Phase 2 · Transfers that resume A transfer interrupted at eighty percent continues from eighty percent. ### Steps 1. Persist the queue with byte offsets Written to the database as transfers progress, so a crash loses at most a small window. Files: `Queue.swift` 2. Verify before resuming Check the remote size and, where possible, the overlapping bytes. Trusting the offset alone is how corruption happens. Files: `Resume.swift` 3. Retry with backoff and a cap Exponential backoff, a cap, and the error preserved for the user rather than retried forever. Files: `Queue.swift` ### Done when - [ ] An interrupted transfer resumes from its offset - [ ] Relaunching resumes the queue - [ ] A resumed file matches the source by digest - [ ] Repeated failure stops at the cap with the error shown ### Watch out - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. ## Phase 3 · Object storage as a second protocol An S3-compatible bucket behaves like a folder, with its differences made visible. ### Steps 1. Implement signature version 4 Signing is fiddly and a wrong signature returns an unhelpful error. Test against a known request before building on it. Files: `S3/Sign.swift` 2. Add multipart upload with cleanup Above a threshold, upload in parts. Abort incomplete uploads on failure, because unfinished parts are billed and invisible. Files: `S3/Multipart.swift` 3. Make prefix semantics visible Show the object count before a prefix delete. This is not a directory and the interface should not fully pretend it is. Files: `S3/Delete.swift` ### Done when - [ ] A bucket lists with prefixes as folders - [ ] A large file uploads in parts and is correct - [ ] A prefix delete warns with the object count - [ ] An interrupted multipart upload is aborted rather than abandoned ### Watch out - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. ## Phase 4 · Conflicts and verification Nothing is overwritten by accident, and you know the upload arrived intact. ### Steps 1. Detect conflicts before transferring Compare size and modification time. Default to refusing, never to overwriting. Files: `Conflicts.swift` 2. Show both sides Sizes and timestamps for local and remote. The user decides; the client does not guess. Files: `ConflictSheet.swift` 3. Verify after transfer A digest comparison where available. Test with a deliberately corrupted transfer to confirm it fails. Files: `Verify.swift` ### Done when - [ ] An overwrite of a newer remote file raises a conflict - [ ] Both sides' sizes and timestamps are shown - [ ] A completed upload is verified by digest where supported - [ ] A corrupted transfer fails verification ### Watch out - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. ## Phase 5 · Synchronising a folder You can mirror a local folder to a remote one and know exactly what will change first. ### Steps 1. Compute the change set Compare listings, produce uploads, updates and deletions as a plan before doing anything. Files: `Sync.swift` 2. Confirm deletions separately Their own count and their own confirmation. This is the operation that causes the losses. Files: `SyncSheet.swift` 3. Support exclusions Patterns applied before the plan is built, so excluded files never appear in it. Files: `Sync.swift` ### Done when - [ ] A dry run lists changes and performs none - [ ] Deletions are counted and confirmed separately - [ ] Exclusion patterns keep files out of every operation - [ ] A second run with no changes transfers nothing ### Watch out - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. ## Not in this build - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. ## After v1, if you want it - A menu bar transfer status so long uploads do not need the window open - Server-side move and copy where the protocol supports it, avoiding a round trip - Scheduled sync with a notification summarising what changed ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookmarks and the transfer queue. Contains no secrets by design. BOOKMARKS_DB_PATH=~/Library/Application Support/Transfer/bookmarks.db # Required. Stored host keys. A mismatch here is a refusal, not a warning. HOST_KEYS_PATH=~/Library/Application Support/Transfer/known_hosts # Required. Files above this upload in parts. Below it, a single request is simpler and faster. MULTIPART_THRESHOLD_MB=64 # Required. Retries before a transfer is marked failed with its error preserved. TRANSFER_MAX_ATTEMPTS=5 # Required. Whether to compare digests after transfer where the protocol allows it. VERIFY_AFTER_TRANSFER=true
You are building a lean indie version of Transmit. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Transmit · indie build A two-pane transfer client that connects over SFTP and to S3-compatible object storage, keeps encrypted bookmarks, queues transfers that survive a dropped connection and resume rather than restart, verifies what it uploaded, and never silently overwrites a newer file. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | the native file panel, keychain access and drag and drop all want AppKit | | SFTP | libssh2 through a thin Swift wrapper | implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do | | Object storage | The S3 API over HTTPS with signature version 4 | one API covers every S3-compatible provider, which is most of them | | Credentials | The macOS keychain, never a settings file | a bookmark file containing passwords is a file people sync and share by accident | | Queue | A persisted transfer queue with byte-level resume | a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14 or newer** · free if you own one - Why: The native panel, keychain and drag and drop are platform APIs. - Get it: Apple menu, then About This Mac. - Verify: About This Mac reports macOS 14 or higher - [ ] **Xcode 16 or newer** · free - Why: Builds the application and links libssh2. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **A real SFTP server you control** · a few USD a month, or free on hardware you own - Why: Local testing hides authentication, host key and timeout behaviour. You need somewhere you can also change the host key deliberately in Phase 1. - Get it: Any small virtual server with SSH, or a machine on your network. You must be able to regenerate its host key to test the refusal path. - Verify: You can connect and can change the host key on purpose - [ ] **An S3-compatible bucket with credentials** · storage is cheap; some providers charge for requests and egress - Why: Phase 3 needs a real bucket. Providers differ subtly in their S3 implementations, so test against the one you will actually use. - Get it: Create a bucket with any S3-compatible provider and credentials scoped to it. Confirm it supports multipart upload before relying on it. - Verify: A signed list request returns the bucket contents - [ ] **Test files including one over the multipart threshold** · free - Why: Resume and multipart both need a file large enough to interrupt partway, which a small file does not give you. - Get it: Generate a file comfortably above your multipart threshold. Keep it as a fixture. - Verify: A file large enough to interrupt mid-transfer is available ## Quick start ```sh xcrun notarytool submit Transfer.zip --keychain-profile AC --wait ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. - protocol compatibility, native polish, sync, security, and ongoing maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes If one of those is essential to you, that is the reason to keep paying for Transmit, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Transmit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS file transfer client for SFTP and object storage with bookmarks and a resumable queue to replace Transmit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · the native file panel, keychain access and drag and drop all want AppKit - SFTP: libssh2 through a thin Swift wrapper · implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do - Object storage: The S3 API over HTTPS with signature version 4 · one API covers every S3-compatible provider, which is most of them - Credentials: The macOS keychain, never a settings file · a bookmark file containing passwords is a file people sync and share by accident - Queue: A persisted transfer queue with byte-level resume · a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating ### Data model (create this before Phase 1) - bookmarks(id, name, protocol, host, port, username, remote_path, local_path, keychain_ref) · never a password field - transfers(id, bookmark_id, direction, local_path, remote_path, bytes_total, bytes_done, state, attempts, error, queued_at) - conflicts(transfer_id, reason, local_mtime, remote_mtime, local_size, remote_size, resolution) - verifications(transfer_id, algorithm, local_digest, remote_digest, verified_at) - The queue is persisted, so quitting mid-transfer and relaunching resumes rather than starting again. ### Phase 1 · Connect and list, with host keys taken seriously Build: Connect over SFTP with key or password authentication, list a directory, and store the server's host key on first connection. On a subsequent connection with a different key, refuse and explain, rather than showing a dialogue people click through. Trap: A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. Done when: A connection to a real server lists a directory. The host key is stored on first connection. Changing the key on the server causes a refusal rather than a warning. Credentials are in the keychain and absent from every file on disk, verified by a grep. Do not build yet: No transfers, no queue, no object storage. ### Phase 2 · Transfers that resume Build: Upload and download with progress. Persist byte offsets so an interrupted transfer resumes rather than restarting. Retry with backoff on a dropped connection. Quitting and relaunching must resume the queue. Trap: Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. Done when: A transfer interrupted by pulling the network cable resumes from its offset rather than from zero. Quitting mid-transfer and relaunching resumes the queue. A resumed file is byte-identical to the source, verified by digest. Repeated failures stop after the attempt cap with the error visible. Do not build yet: No object storage or conflict handling yet. ### Phase 3 · Object storage as a second protocol Build: Add S3 support with signature version 4, multipart upload for large files, and listing with prefixes presented as folders. Be explicit where object storage differs from a filesystem, because pretending it does not is where users lose data. Trap: Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. Done when: A bucket lists with prefixes shown as folders. A file over the multipart threshold uploads in parts and is complete and correct afterwards. Deleting a prefix warns with the object count before proceeding. An interrupted multipart upload is either completed or aborted rather than left as billed incomplete parts. Do not build yet: No conflict resolution or verification yet. ### Phase 4 · Conflicts and verification Build: Before transferring over an existing file, compare size and modification time and present a conflict with both sides shown. Default to refusing rather than overwriting. After transfer, verify with a digest where the protocol allows one. Trap: Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. Done when: Uploading over a newer remote file produces a conflict showing both sizes and timestamps rather than overwriting. Choosing to overwrite proceeds and is logged. A completed upload is verified by digest where the protocol supports it, and a deliberately corrupted transfer fails verification. Do not build yet: No synchronisation or distribution yet. ### Phase 5 · Synchronising a folder Build: A one-way sync with a dry run listing every intended upload, update, and deletion. Deletions require explicit confirmation. Exclusion patterns for things that should never be uploaded. Trap: A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. Done when: A dry run lists every intended change and performs none, verified against the remote listing afterwards. Deletions are counted separately and require their own confirmation. An exclusion pattern keeps matching files out of every operation. Running sync twice with no changes performs no transfers. Do not build yet: Distribution comes last. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the credential handling, because a transfer client holds keys to servers. Trap: Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share. Done when: A notarized build opens on a fresh Mac with no Gatekeeper warning. Exported bookmarks contain no credentials, verified by inspecting the file. The README states where credentials live and what an export does and does not include. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of protocol support in the commercial client, which covers a long tail of servers with their own quirks. - The years of compatibility work behind handling servers that implement standards loosely, which is most of the real cost in this category. - Mounting remote storage as a local disk, which is a filesystem extension and a different kind of project. - Two-way synchronisation with conflict resolution, which is meaningfully harder than the one-way sync here. ### README must contain - That a changed host key causes a refusal rather than a warning, and how to accept a new one deliberately - That credentials live in the keychain and never in a bookmark file or an export - That transfers resume by verified offset rather than by appending blindly - That object storage prefixes are not directories, and what deleting one actually does - That sync deletions are counted and confirmed separately from the sync itself ===== AGENTS.md ===== # Agent instructions · Transmit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, libssh2 through a thin Swift wrapper, The S3 API over HTTPS with signature version 4, The macOS keychain, never a settings file, A persisted transfer queue with byte-level resume. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. ===== BUILD_PLAN.md ===== # Build plan · Transmit A two-pane transfer client that connects over SFTP and to S3-compatible object storage, keeps encrypted bookmarks, queues transfers that survive a dropped connection and resume rather than restart, verifies what it uploaded, and never silently overwrites a newer file. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Connect and list, with host keys taken seriously You can browse a remote server, and an unexpected host key stops you rather than warning you. ### Steps 1. Wrap libssh2 for connect and list Key and password authentication, directory listing with sizes and modification times. Files: `SFTP.swift` 2. Store and check host keys First connection stores; later connections compare. A mismatch is a refusal. Files: `HostKeys.swift` 3. Put credentials in the keychain Bookmarks store a keychain reference, never a secret. Grep your settings file to prove it. Files: `Credentials.swift` ### Done when - [ ] A real server's directory lists correctly - [ ] A changed host key causes a refusal, not a warning - [ ] No credential appears in any file on disk - [ ] Key-based authentication works with a passphrase-protected key ### Watch out - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. ## Phase 2 · Transfers that resume A transfer interrupted at eighty percent continues from eighty percent. ### Steps 1. Persist the queue with byte offsets Written to the database as transfers progress, so a crash loses at most a small window. Files: `Queue.swift` 2. Verify before resuming Check the remote size and, where possible, the overlapping bytes. Trusting the offset alone is how corruption happens. Files: `Resume.swift` 3. Retry with backoff and a cap Exponential backoff, a cap, and the error preserved for the user rather than retried forever. Files: `Queue.swift` ### Done when - [ ] An interrupted transfer resumes from its offset - [ ] Relaunching resumes the queue - [ ] A resumed file matches the source by digest - [ ] Repeated failure stops at the cap with the error shown ### Watch out - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. ## Phase 3 · Object storage as a second protocol An S3-compatible bucket behaves like a folder, with its differences made visible. ### Steps 1. Implement signature version 4 Signing is fiddly and a wrong signature returns an unhelpful error. Test against a known request before building on it. Files: `S3/Sign.swift` 2. Add multipart upload with cleanup Above a threshold, upload in parts. Abort incomplete uploads on failure, because unfinished parts are billed and invisible. Files: `S3/Multipart.swift` 3. Make prefix semantics visible Show the object count before a prefix delete. This is not a directory and the interface should not fully pretend it is. Files: `S3/Delete.swift` ### Done when - [ ] A bucket lists with prefixes as folders - [ ] A large file uploads in parts and is correct - [ ] A prefix delete warns with the object count - [ ] An interrupted multipart upload is aborted rather than abandoned ### Watch out - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. ## Phase 4 · Conflicts and verification Nothing is overwritten by accident, and you know the upload arrived intact. ### Steps 1. Detect conflicts before transferring Compare size and modification time. Default to refusing, never to overwriting. Files: `Conflicts.swift` 2. Show both sides Sizes and timestamps for local and remote. The user decides; the client does not guess. Files: `ConflictSheet.swift` 3. Verify after transfer A digest comparison where available. Test with a deliberately corrupted transfer to confirm it fails. Files: `Verify.swift` ### Done when - [ ] An overwrite of a newer remote file raises a conflict - [ ] Both sides' sizes and timestamps are shown - [ ] A completed upload is verified by digest where supported - [ ] A corrupted transfer fails verification ### Watch out - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. ## Phase 5 · Synchronising a folder You can mirror a local folder to a remote one and know exactly what will change first. ### Steps 1. Compute the change set Compare listings, produce uploads, updates and deletions as a plan before doing anything. Files: `Sync.swift` 2. Confirm deletions separately Their own count and their own confirmation. This is the operation that causes the losses. Files: `SyncSheet.swift` 3. Support exclusions Patterns applied before the plan is built, so excluded files never appear in it. Files: `Sync.swift` ### Done when - [ ] A dry run lists changes and performs none - [ ] Deletions are counted and confirmed separately - [ ] Exclusion patterns keep files out of every operation - [ ] A second run with no changes transfers nothing ### Watch out - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. ## Not in this build - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. ## After v1, if you want it - A menu bar transfer status so long uploads do not need the window open - Server-side move and copy where the protocol supports it, avoiding a round trip - Scheduled sync with a notification summarising what changed ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookmarks and the transfer queue. Contains no secrets by design. BOOKMARKS_DB_PATH=~/Library/Application Support/Transfer/bookmarks.db # Required. Stored host keys. A mismatch here is a refusal, not a warning. HOST_KEYS_PATH=~/Library/Application Support/Transfer/known_hosts # Required. Files above this upload in parts. Below it, a single request is simpler and faster. MULTIPART_THRESHOLD_MB=64 # Required. Retries before a transfer is marked failed with its error preserved. TRANSFER_MAX_ATTEMPTS=5 # Required. Whether to compare digests after transfer where the protocol allows it. VERIFY_AFTER_TRANSFER=true
You are building a production product version of Transmit. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Transmit · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Transmit, build a focused SFTP and object-storage client with bookmarks and transfer queue. The hard boundary is protocol compatibility, native polish, sync, security, and ongoing maintenance, plus os integration polish and maintenance. ## Product outcome A transfer client that refuses on a changed host key, keeps no credential outside the keychain, resumes transfers by verified offset, never overwrites without asking, and dry runs every sync before it deletes anything. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS 14 or later - Xcode - only the permissions required by the selected feature ## Explicit non-goals for v1 - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. - protocol compatibility, native polish, sync, security, and ongoing maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes ## Success criteria - A changed host key is confirmed to cause a refusal - No credential appears in any file, verified by grep - An interrupted large transfer resumes and matches by digest - A sync dry run is confirmed to perform nothing - An exported bookmark file is confirmed to contain no secrets ===== BRIEF.md ===== # Build brief · Transmit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS file transfer client for SFTP and object storage with bookmarks and a resumable queue to replace Transmit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · the native file panel, keychain access and drag and drop all want AppKit - SFTP: libssh2 through a thin Swift wrapper · implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do - Object storage: The S3 API over HTTPS with signature version 4 · one API covers every S3-compatible provider, which is most of them - Credentials: The macOS keychain, never a settings file · a bookmark file containing passwords is a file people sync and share by accident - Queue: A persisted transfer queue with byte-level resume · a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating ### Data model (create this before Phase 1) - bookmarks(id, name, protocol, host, port, username, remote_path, local_path, keychain_ref) · never a password field - transfers(id, bookmark_id, direction, local_path, remote_path, bytes_total, bytes_done, state, attempts, error, queued_at) - conflicts(transfer_id, reason, local_mtime, remote_mtime, local_size, remote_size, resolution) - verifications(transfer_id, algorithm, local_digest, remote_digest, verified_at) - The queue is persisted, so quitting mid-transfer and relaunching resumes rather than starting again. ### Phase 1 · Connect and list, with host keys taken seriously Build: Connect over SFTP with key or password authentication, list a directory, and store the server's host key on first connection. On a subsequent connection with a different key, refuse and explain, rather than showing a dialogue people click through. Trap: A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. Done when: A connection to a real server lists a directory. The host key is stored on first connection. Changing the key on the server causes a refusal rather than a warning. Credentials are in the keychain and absent from every file on disk, verified by a grep. Do not build yet: No transfers, no queue, no object storage. ### Phase 2 · Transfers that resume Build: Upload and download with progress. Persist byte offsets so an interrupted transfer resumes rather than restarting. Retry with backoff on a dropped connection. Quitting and relaunching must resume the queue. Trap: Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. Done when: A transfer interrupted by pulling the network cable resumes from its offset rather than from zero. Quitting mid-transfer and relaunching resumes the queue. A resumed file is byte-identical to the source, verified by digest. Repeated failures stop after the attempt cap with the error visible. Do not build yet: No object storage or conflict handling yet. ### Phase 3 · Object storage as a second protocol Build: Add S3 support with signature version 4, multipart upload for large files, and listing with prefixes presented as folders. Be explicit where object storage differs from a filesystem, because pretending it does not is where users lose data. Trap: Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. Done when: A bucket lists with prefixes shown as folders. A file over the multipart threshold uploads in parts and is complete and correct afterwards. Deleting a prefix warns with the object count before proceeding. An interrupted multipart upload is either completed or aborted rather than left as billed incomplete parts. Do not build yet: No conflict resolution or verification yet. ### Phase 4 · Conflicts and verification Build: Before transferring over an existing file, compare size and modification time and present a conflict with both sides shown. Default to refusing rather than overwriting. After transfer, verify with a digest where the protocol allows one. Trap: Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. Done when: Uploading over a newer remote file produces a conflict showing both sizes and timestamps rather than overwriting. Choosing to overwrite proceeds and is logged. A completed upload is verified by digest where the protocol supports it, and a deliberately corrupted transfer fails verification. Do not build yet: No synchronisation or distribution yet. ### Phase 5 · Synchronising a folder Build: A one-way sync with a dry run listing every intended upload, update, and deletion. Deletions require explicit confirmation. Exclusion patterns for things that should never be uploaded. Trap: A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. Done when: A dry run lists every intended change and performs none, verified against the remote listing afterwards. Deletions are counted separately and require their own confirmation. An exclusion pattern keeps matching files out of every operation. Running sync twice with no changes performs no transfers. Do not build yet: Distribution comes last. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the credential handling, because a transfer client holds keys to servers. Trap: Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share. Done when: A notarized build opens on a fresh Mac with no Gatekeeper warning. Exported bookmarks contain no credentials, verified by inspecting the file. The README states where credentials live and what an export does and does not include. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of protocol support in the commercial client, which covers a long tail of servers with their own quirks. - The years of compatibility work behind handling servers that implement standards loosely, which is most of the real cost in this category. - Mounting remote storage as a local disk, which is a filesystem extension and a different kind of project. - Two-way synchronisation with conflict resolution, which is meaningfully harder than the one-way sync here. ### README must contain - That a changed host key causes a refusal rather than a warning, and how to accept a new one deliberately - That credentials live in the keychain and never in a bookmark file or an export - That transfers resume by verified offset rather than by appending blindly - That object storage prefixes are not directories, and what deleting one actually does - That sync deletions are counted and confirmed separately from the sync itself ===== ARCHITECTURE.md ===== # Architecture · Transmit ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | the native file panel, keychain access and drag and drop all want AppKit | | SFTP | libssh2 through a thin Swift wrapper | implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do | | Object storage | The S3 API over HTTPS with signature version 4 | one API covers every S3-compatible provider, which is most of them | | Credentials | The macOS keychain, never a settings file | a bookmark file containing passwords is a file people sync and share by accident | | Queue | A persisted transfer queue with byte-level resume | a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | SFTP | Connection, authentication and host key policy. | libssh2 is one option among several. The refusal-on-mismatch policy is not negotiable whichever library you use. | | S3 | Signing, listing and multipart transfer. | One API covers most providers. The multipart abort on failure must survive any change, because abandoned parts cost money silently. | | Queue | Persistence, resume and retry. | The persisted byte offset is the feature. An in-memory queue is simpler and loses everything on a crash. | | Conflicts | Detecting and surfacing overwrites. | Comparison logic can improve; defaulting to refusal rather than overwrite is the property to keep. | | Sync | Planning and confirming a change set. | Any planner, provided the dry run genuinely performs nothing and deletions confirm separately. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `BOOKMARKS_DB_PATH` · required · Bookmarks and the transfer queue. Contains no secrets by design. - `HOST_KEYS_PATH` · required · Stored host keys. A mismatch here is a refusal, not a warning. - `MULTIPART_THRESHOLD_MB` · required · Files above this upload in parts. Below it, a single request is simpler and faster. - `TRANSFER_MAX_ATTEMPTS` · required · Retries before a transfer is marked failed with its error preserved. - `VERIFY_AFTER_TRANSFER` · required · Whether to compare digests after transfer where the protocol allows it. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Transmit product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, libssh2 through a thin Swift wrapper, The S3 API over HTTPS with signature version 4, The macOS keychain, never a settings file, A persisted transfer queue with byte-level resume. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. - Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share. ===== MILESTONES.md ===== # Delivery milestones · Transmit Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Connect and list, with host keys taken seriously You can browse a remote server, and an unexpected host key stops you rather than warning you. ### Steps 1. Wrap libssh2 for connect and list Key and password authentication, directory listing with sizes and modification times. Files: `SFTP.swift` 2. Store and check host keys First connection stores; later connections compare. A mismatch is a refusal. Files: `HostKeys.swift` 3. Put credentials in the keychain Bookmarks store a keychain reference, never a secret. Grep your settings file to prove it. Files: `Credentials.swift` ### Done when - [ ] A real server's directory lists correctly - [ ] A changed host key causes a refusal, not a warning - [ ] No credential appears in any file on disk - [ ] Key-based authentication works with a passphrase-protected key ### Watch out - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. ## M2 · Transfers that resume A transfer interrupted at eighty percent continues from eighty percent. ### Steps 1. Persist the queue with byte offsets Written to the database as transfers progress, so a crash loses at most a small window. Files: `Queue.swift` 2. Verify before resuming Check the remote size and, where possible, the overlapping bytes. Trusting the offset alone is how corruption happens. Files: `Resume.swift` 3. Retry with backoff and a cap Exponential backoff, a cap, and the error preserved for the user rather than retried forever. Files: `Queue.swift` ### Done when - [ ] An interrupted transfer resumes from its offset - [ ] Relaunching resumes the queue - [ ] A resumed file matches the source by digest - [ ] Repeated failure stops at the cap with the error shown ### Watch out - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. ## M3 · Object storage as a second protocol An S3-compatible bucket behaves like a folder, with its differences made visible. ### Steps 1. Implement signature version 4 Signing is fiddly and a wrong signature returns an unhelpful error. Test against a known request before building on it. Files: `S3/Sign.swift` 2. Add multipart upload with cleanup Above a threshold, upload in parts. Abort incomplete uploads on failure, because unfinished parts are billed and invisible. Files: `S3/Multipart.swift` 3. Make prefix semantics visible Show the object count before a prefix delete. This is not a directory and the interface should not fully pretend it is. Files: `S3/Delete.swift` ### Done when - [ ] A bucket lists with prefixes as folders - [ ] A large file uploads in parts and is correct - [ ] A prefix delete warns with the object count - [ ] An interrupted multipart upload is aborted rather than abandoned ### Watch out - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. ## M4 · Conflicts and verification Nothing is overwritten by accident, and you know the upload arrived intact. ### Steps 1. Detect conflicts before transferring Compare size and modification time. Default to refusing, never to overwriting. Files: `Conflicts.swift` 2. Show both sides Sizes and timestamps for local and remote. The user decides; the client does not guess. Files: `ConflictSheet.swift` 3. Verify after transfer A digest comparison where available. Test with a deliberately corrupted transfer to confirm it fails. Files: `Verify.swift` ### Done when - [ ] An overwrite of a newer remote file raises a conflict - [ ] Both sides' sizes and timestamps are shown - [ ] A completed upload is verified by digest where supported - [ ] A corrupted transfer fails verification ### Watch out - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. ## M5 · Synchronising a folder You can mirror a local folder to a remote one and know exactly what will change first. ### Steps 1. Compute the change set Compare listings, produce uploads, updates and deletions as a plan before doing anything. Files: `Sync.swift` 2. Confirm deletions separately Their own count and their own confirmation. This is the operation that causes the losses. Files: `SyncSheet.swift` 3. Support exclusions Patterns applied before the plan is built, so excluded files never appear in it. Files: `Sync.swift` ### Done when - [ ] A dry run lists changes and performs none - [ ] Deletions are counted and confirmed separately - [ ] Exclusion patterns keep files out of every operation - [ ] A second run with no changes transfers nothing ### Watch out - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. ## M6 · Signing, notarization and updates (production only) Others can install it, and it handles their credentials responsibly. ### Steps 1. Sign and notarize Hardened runtime, Developer ID, notarytool, stapler. ```sh xcrun notarytool submit Transfer.zip --keychain-profile AC --wait ``` 2. Export bookmarks without secrets Connection details only. Inspect the exported file to confirm. Files: `Export.swift` 3. Document credential handling Keychain storage, host key policy, and what an export contains. Files: `README.md` ### Done when - [ ] spctl reports the notarized build accepted - [ ] An exported bookmark file contains no credentials - [ ] The README states the host key policy - [ ] An appcast update installs and relaunches ### Watch out - Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share. ===== OPERATIONS.md ===== # Operations · Transmit ## Backup Bookmarks and the queue are small and contain no secrets, so they back up trivially. Credentials live in the keychain and are covered by the keychain's own backup, which is worth stating because people assume otherwise. ## Restore Restore the bookmarks database and the keychain. Bookmarks without their keychain entries prompt for credentials again rather than failing silently, which is the behaviour to test. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For unattended sync, watch the failure count and the deletion count. A sudden jump in deletions is the signal that a path is wrong, and it is worth alerting on rather than logging. ## Incident checklist A sync that deleted the wrong things is the incident that matters, and the mitigation is the dry run rather than a recovery path, because remote deletions are usually not recoverable. Treat any change to sync paths as requiring a fresh dry run. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A changed host key is confirmed to cause a refusal - [ ] No credential appears in any file, verified by grep - [ ] An interrupted large transfer resumes and matches by digest - [ ] A sync dry run is confirmed to perform nothing - [ ] An exported bookmark file is confirmed to contain no secrets ## Launch constraint Do not market omitted Transmit capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookmarks and the transfer queue. Contains no secrets by design. BOOKMARKS_DB_PATH=~/Library/Application Support/Transfer/bookmarks.db # Required. Stored host keys. A mismatch here is a refusal, not a warning. HOST_KEYS_PATH=~/Library/Application Support/Transfer/known_hosts # Required. Files above this upload in parts. Below it, a single request is simpler and faster. MULTIPART_THRESHOLD_MB=64 # Required. Retries before a transfer is marked failed with its error preserved. TRANSFER_MAX_ATTEMPTS=5 # Required. Whether to compare digests after transfer where the protocol allows it. VERIFY_AFTER_TRANSFER=true
# Transmit · indie build A two-pane transfer client that connects over SFTP and to S3-compatible object storage, keeps encrypted bookmarks, queues transfers that survive a dropped connection and resume rather than restart, verifies what it uploaded, and never silently overwrites a newer file. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | the native file panel, keychain access and drag and drop all want AppKit | | SFTP | libssh2 through a thin Swift wrapper | implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do | | Object storage | The S3 API over HTTPS with signature version 4 | one API covers every S3-compatible provider, which is most of them | | Credentials | The macOS keychain, never a settings file | a bookmark file containing passwords is a file people sync and share by accident | | Queue | A persisted transfer queue with byte-level resume | a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14 or newer** · free if you own one - Why: The native panel, keychain and drag and drop are platform APIs. - Get it: Apple menu, then About This Mac. - Verify: About This Mac reports macOS 14 or higher - [ ] **Xcode 16 or newer** · free - Why: Builds the application and links libssh2. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **A real SFTP server you control** · a few USD a month, or free on hardware you own - Why: Local testing hides authentication, host key and timeout behaviour. You need somewhere you can also change the host key deliberately in Phase 1. - Get it: Any small virtual server with SSH, or a machine on your network. You must be able to regenerate its host key to test the refusal path. - Verify: You can connect and can change the host key on purpose - [ ] **An S3-compatible bucket with credentials** · storage is cheap; some providers charge for requests and egress - Why: Phase 3 needs a real bucket. Providers differ subtly in their S3 implementations, so test against the one you will actually use. - Get it: Create a bucket with any S3-compatible provider and credentials scoped to it. Confirm it supports multipart upload before relying on it. - Verify: A signed list request returns the bucket contents - [ ] **Test files including one over the multipart threshold** · free - Why: Resume and multipart both need a file large enough to interrupt partway, which a small file does not give you. - Get it: Generate a file comfortably above your multipart threshold. Keep it as a fixture. - Verify: A file large enough to interrupt mid-transfer is available ## Quick start ```sh xcrun notarytool submit Transfer.zip --keychain-profile AC --wait ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. - protocol compatibility, native polish, sync, security, and ongoing maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes If one of those is essential to you, that is the reason to keep paying for Transmit, and the README should say so rather than pretend.
# Build brief · Transmit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS file transfer client for SFTP and object storage with bookmarks and a resumable queue to replace Transmit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · the native file panel, keychain access and drag and drop all want AppKit - SFTP: libssh2 through a thin Swift wrapper · implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do - Object storage: The S3 API over HTTPS with signature version 4 · one API covers every S3-compatible provider, which is most of them - Credentials: The macOS keychain, never a settings file · a bookmark file containing passwords is a file people sync and share by accident - Queue: A persisted transfer queue with byte-level resume · a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating ### Data model (create this before Phase 1) - bookmarks(id, name, protocol, host, port, username, remote_path, local_path, keychain_ref) · never a password field - transfers(id, bookmark_id, direction, local_path, remote_path, bytes_total, bytes_done, state, attempts, error, queued_at) - conflicts(transfer_id, reason, local_mtime, remote_mtime, local_size, remote_size, resolution) - verifications(transfer_id, algorithm, local_digest, remote_digest, verified_at) - The queue is persisted, so quitting mid-transfer and relaunching resumes rather than starting again. ### Phase 1 · Connect and list, with host keys taken seriously Build: Connect over SFTP with key or password authentication, list a directory, and store the server's host key on first connection. On a subsequent connection with a different key, refuse and explain, rather than showing a dialogue people click through. Trap: A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. Done when: A connection to a real server lists a directory. The host key is stored on first connection. Changing the key on the server causes a refusal rather than a warning. Credentials are in the keychain and absent from every file on disk, verified by a grep. Do not build yet: No transfers, no queue, no object storage. ### Phase 2 · Transfers that resume Build: Upload and download with progress. Persist byte offsets so an interrupted transfer resumes rather than restarting. Retry with backoff on a dropped connection. Quitting and relaunching must resume the queue. Trap: Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. Done when: A transfer interrupted by pulling the network cable resumes from its offset rather than from zero. Quitting mid-transfer and relaunching resumes the queue. A resumed file is byte-identical to the source, verified by digest. Repeated failures stop after the attempt cap with the error visible. Do not build yet: No object storage or conflict handling yet. ### Phase 3 · Object storage as a second protocol Build: Add S3 support with signature version 4, multipart upload for large files, and listing with prefixes presented as folders. Be explicit where object storage differs from a filesystem, because pretending it does not is where users lose data. Trap: Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. Done when: A bucket lists with prefixes shown as folders. A file over the multipart threshold uploads in parts and is complete and correct afterwards. Deleting a prefix warns with the object count before proceeding. An interrupted multipart upload is either completed or aborted rather than left as billed incomplete parts. Do not build yet: No conflict resolution or verification yet. ### Phase 4 · Conflicts and verification Build: Before transferring over an existing file, compare size and modification time and present a conflict with both sides shown. Default to refusing rather than overwriting. After transfer, verify with a digest where the protocol allows one. Trap: Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. Done when: Uploading over a newer remote file produces a conflict showing both sizes and timestamps rather than overwriting. Choosing to overwrite proceeds and is logged. A completed upload is verified by digest where the protocol supports it, and a deliberately corrupted transfer fails verification. Do not build yet: No synchronisation or distribution yet. ### Phase 5 · Synchronising a folder Build: A one-way sync with a dry run listing every intended upload, update, and deletion. Deletions require explicit confirmation. Exclusion patterns for things that should never be uploaded. Trap: A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. Done when: A dry run lists every intended change and performs none, verified against the remote listing afterwards. Deletions are counted separately and require their own confirmation. An exclusion pattern keeps matching files out of every operation. Running sync twice with no changes performs no transfers. Do not build yet: Distribution comes last. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the credential handling, because a transfer client holds keys to servers. Trap: Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share. Done when: A notarized build opens on a fresh Mac with no Gatekeeper warning. Exported bookmarks contain no credentials, verified by inspecting the file. The README states where credentials live and what an export does and does not include. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of protocol support in the commercial client, which covers a long tail of servers with their own quirks. - The years of compatibility work behind handling servers that implement standards loosely, which is most of the real cost in this category. - Mounting remote storage as a local disk, which is a filesystem extension and a different kind of project. - Two-way synchronisation with conflict resolution, which is meaningfully harder than the one-way sync here. ### README must contain - That a changed host key causes a refusal rather than a warning, and how to accept a new one deliberately - That credentials live in the keychain and never in a bookmark file or an export - That transfers resume by verified offset rather than by appending blindly - That object storage prefixes are not directories, and what deleting one actually does - That sync deletions are counted and confirmed separately from the sync itself
# Agent instructions · Transmit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, libssh2 through a thin Swift wrapper, The S3 API over HTTPS with signature version 4, The macOS keychain, never a settings file, A persisted transfer queue with byte-level resume. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole.
# Build plan · Transmit A two-pane transfer client that connects over SFTP and to S3-compatible object storage, keeps encrypted bookmarks, queues transfers that survive a dropped connection and resume rather than restart, verifies what it uploaded, and never silently overwrites a newer file. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Connect and list, with host keys taken seriously You can browse a remote server, and an unexpected host key stops you rather than warning you. ### Steps 1. Wrap libssh2 for connect and list Key and password authentication, directory listing with sizes and modification times. Files: `SFTP.swift` 2. Store and check host keys First connection stores; later connections compare. A mismatch is a refusal. Files: `HostKeys.swift` 3. Put credentials in the keychain Bookmarks store a keychain reference, never a secret. Grep your settings file to prove it. Files: `Credentials.swift` ### Done when - [ ] A real server's directory lists correctly - [ ] A changed host key causes a refusal, not a warning - [ ] No credential appears in any file on disk - [ ] Key-based authentication works with a passphrase-protected key ### Watch out - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. ## Phase 2 · Transfers that resume A transfer interrupted at eighty percent continues from eighty percent. ### Steps 1. Persist the queue with byte offsets Written to the database as transfers progress, so a crash loses at most a small window. Files: `Queue.swift` 2. Verify before resuming Check the remote size and, where possible, the overlapping bytes. Trusting the offset alone is how corruption happens. Files: `Resume.swift` 3. Retry with backoff and a cap Exponential backoff, a cap, and the error preserved for the user rather than retried forever. Files: `Queue.swift` ### Done when - [ ] An interrupted transfer resumes from its offset - [ ] Relaunching resumes the queue - [ ] A resumed file matches the source by digest - [ ] Repeated failure stops at the cap with the error shown ### Watch out - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. ## Phase 3 · Object storage as a second protocol An S3-compatible bucket behaves like a folder, with its differences made visible. ### Steps 1. Implement signature version 4 Signing is fiddly and a wrong signature returns an unhelpful error. Test against a known request before building on it. Files: `S3/Sign.swift` 2. Add multipart upload with cleanup Above a threshold, upload in parts. Abort incomplete uploads on failure, because unfinished parts are billed and invisible. Files: `S3/Multipart.swift` 3. Make prefix semantics visible Show the object count before a prefix delete. This is not a directory and the interface should not fully pretend it is. Files: `S3/Delete.swift` ### Done when - [ ] A bucket lists with prefixes as folders - [ ] A large file uploads in parts and is correct - [ ] A prefix delete warns with the object count - [ ] An interrupted multipart upload is aborted rather than abandoned ### Watch out - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. ## Phase 4 · Conflicts and verification Nothing is overwritten by accident, and you know the upload arrived intact. ### Steps 1. Detect conflicts before transferring Compare size and modification time. Default to refusing, never to overwriting. Files: `Conflicts.swift` 2. Show both sides Sizes and timestamps for local and remote. The user decides; the client does not guess. Files: `ConflictSheet.swift` 3. Verify after transfer A digest comparison where available. Test with a deliberately corrupted transfer to confirm it fails. Files: `Verify.swift` ### Done when - [ ] An overwrite of a newer remote file raises a conflict - [ ] Both sides' sizes and timestamps are shown - [ ] A completed upload is verified by digest where supported - [ ] A corrupted transfer fails verification ### Watch out - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. ## Phase 5 · Synchronising a folder You can mirror a local folder to a remote one and know exactly what will change first. ### Steps 1. Compute the change set Compare listings, produce uploads, updates and deletions as a plan before doing anything. Files: `Sync.swift` 2. Confirm deletions separately Their own count and their own confirmation. This is the operation that causes the losses. Files: `SyncSheet.swift` 3. Support exclusions Patterns applied before the plan is built, so excluded files never appear in it. Files: `Sync.swift` ### Done when - [ ] A dry run lists changes and performs none - [ ] Deletions are counted and confirmed separately - [ ] Exclusion patterns keep files out of every operation - [ ] A second run with no changes transfers nothing ### Watch out - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. ## Not in this build - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. ## After v1, if you want it - A menu bar transfer status so long uploads do not need the window open - Server-side move and copy where the protocol supports it, avoiding a round trip - Scheduled sync with a notification summarising what changed
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookmarks and the transfer queue. Contains no secrets by design. BOOKMARKS_DB_PATH=~/Library/Application Support/Transfer/bookmarks.db # Required. Stored host keys. A mismatch here is a refusal, not a warning. HOST_KEYS_PATH=~/Library/Application Support/Transfer/known_hosts # Required. Files above this upload in parts. Below it, a single request is simpler and faster. MULTIPART_THRESHOLD_MB=64 # Required. Retries before a transfer is marked failed with its error preserved. TRANSFER_MAX_ATTEMPTS=5 # Required. Whether to compare digests after transfer where the protocol allows it. VERIFY_AFTER_TRANSFER=true
# Transmit · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Transmit, build a focused SFTP and object-storage client with bookmarks and transfer queue. The hard boundary is protocol compatibility, native polish, sync, security, and ongoing maintenance, plus os integration polish and maintenance. ## Product outcome A transfer client that refuses on a changed host key, keeps no credential outside the keychain, resumes transfers by verified offset, never overwrites without asking, and dry runs every sync before it deletes anything. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS 14 or later - Xcode - only the permissions required by the selected feature ## Explicit non-goals for v1 - The long tail of protocol and server compatibility. - Mounting remote storage as a local disk. - Two-way synchronisation with conflict resolution. - The polish of a client maintained for two decades. - protocol compatibility, native polish, sync, security, and ongoing maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes ## Success criteria - A changed host key is confirmed to cause a refusal - No credential appears in any file, verified by grep - An interrupted large transfer resumes and matches by digest - A sync dry run is confirmed to perform nothing - An exported bookmark file is confirmed to contain no secrets
# Build brief · Transmit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS file transfer client for SFTP and object storage with bookmarks and a resumable queue to replace Transmit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · the native file panel, keychain access and drag and drop all want AppKit - SFTP: libssh2 through a thin Swift wrapper · implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do - Object storage: The S3 API over HTTPS with signature version 4 · one API covers every S3-compatible provider, which is most of them - Credentials: The macOS keychain, never a settings file · a bookmark file containing passwords is a file people sync and share by accident - Queue: A persisted transfer queue with byte-level resume · a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating ### Data model (create this before Phase 1) - bookmarks(id, name, protocol, host, port, username, remote_path, local_path, keychain_ref) · never a password field - transfers(id, bookmark_id, direction, local_path, remote_path, bytes_total, bytes_done, state, attempts, error, queued_at) - conflicts(transfer_id, reason, local_mtime, remote_mtime, local_size, remote_size, resolution) - verifications(transfer_id, algorithm, local_digest, remote_digest, verified_at) - The queue is persisted, so quitting mid-transfer and relaunching resumes rather than starting again. ### Phase 1 · Connect and list, with host keys taken seriously Build: Connect over SFTP with key or password authentication, list a directory, and store the server's host key on first connection. On a subsequent connection with a different key, refuse and explain, rather than showing a dialogue people click through. Trap: A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. Done when: A connection to a real server lists a directory. The host key is stored on first connection. Changing the key on the server causes a refusal rather than a warning. Credentials are in the keychain and absent from every file on disk, verified by a grep. Do not build yet: No transfers, no queue, no object storage. ### Phase 2 · Transfers that resume Build: Upload and download with progress. Persist byte offsets so an interrupted transfer resumes rather than restarting. Retry with backoff on a dropped connection. Quitting and relaunching must resume the queue. Trap: Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. Done when: A transfer interrupted by pulling the network cable resumes from its offset rather than from zero. Quitting mid-transfer and relaunching resumes the queue. A resumed file is byte-identical to the source, verified by digest. Repeated failures stop after the attempt cap with the error visible. Do not build yet: No object storage or conflict handling yet. ### Phase 3 · Object storage as a second protocol Build: Add S3 support with signature version 4, multipart upload for large files, and listing with prefixes presented as folders. Be explicit where object storage differs from a filesystem, because pretending it does not is where users lose data. Trap: Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. Done when: A bucket lists with prefixes shown as folders. A file over the multipart threshold uploads in parts and is complete and correct afterwards. Deleting a prefix warns with the object count before proceeding. An interrupted multipart upload is either completed or aborted rather than left as billed incomplete parts. Do not build yet: No conflict resolution or verification yet. ### Phase 4 · Conflicts and verification Build: Before transferring over an existing file, compare size and modification time and present a conflict with both sides shown. Default to refusing rather than overwriting. After transfer, verify with a digest where the protocol allows one. Trap: Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. Done when: Uploading over a newer remote file produces a conflict showing both sizes and timestamps rather than overwriting. Choosing to overwrite proceeds and is logged. A completed upload is verified by digest where the protocol supports it, and a deliberately corrupted transfer fails verification. Do not build yet: No synchronisation or distribution yet. ### Phase 5 · Synchronising a folder Build: A one-way sync with a dry run listing every intended upload, update, and deletion. Deletions require explicit confirmation. Exclusion patterns for things that should never be uploaded. Trap: A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. Done when: A dry run lists every intended change and performs none, verified against the remote listing afterwards. Deletions are counted separately and require their own confirmation. An exclusion pattern keeps matching files out of every operation. Running sync twice with no changes performs no transfers. Do not build yet: Distribution comes last. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the credential handling, because a transfer client holds keys to servers. Trap: Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share. Done when: A notarized build opens on a fresh Mac with no Gatekeeper warning. Exported bookmarks contain no credentials, verified by inspecting the file. The README states where credentials live and what an export does and does not include. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of protocol support in the commercial client, which covers a long tail of servers with their own quirks. - The years of compatibility work behind handling servers that implement standards loosely, which is most of the real cost in this category. - Mounting remote storage as a local disk, which is a filesystem extension and a different kind of project. - Two-way synchronisation with conflict resolution, which is meaningfully harder than the one-way sync here. ### README must contain - That a changed host key causes a refusal rather than a warning, and how to accept a new one deliberately - That credentials live in the keychain and never in a bookmark file or an export - That transfers resume by verified offset rather than by appending blindly - That object storage prefixes are not directories, and what deleting one actually does - That sync deletions are counted and confirmed separately from the sync itself
# Architecture · Transmit ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | the native file panel, keychain access and drag and drop all want AppKit | | SFTP | libssh2 through a thin Swift wrapper | implementing the protocol yourself means implementing cryptography, which rule one of every build says not to do | | Object storage | The S3 API over HTTPS with signature version 4 | one API covers every S3-compatible provider, which is most of them | | Credentials | The macOS keychain, never a settings file | a bookmark file containing passwords is a file people sync and share by accident | | Queue | A persisted transfer queue with byte-level resume | a transfer that restarts from zero after a dropped connection is the difference between usable and infuriating | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | SFTP | Connection, authentication and host key policy. | libssh2 is one option among several. The refusal-on-mismatch policy is not negotiable whichever library you use. | | S3 | Signing, listing and multipart transfer. | One API covers most providers. The multipart abort on failure must survive any change, because abandoned parts cost money silently. | | Queue | Persistence, resume and retry. | The persisted byte offset is the feature. An in-memory queue is simpler and loses everything on a crash. | | Conflicts | Detecting and surfacing overwrites. | Comparison logic can improve; defaulting to refusal rather than overwrite is the property to keep. | | Sync | Planning and confirming a change set. | Any planner, provided the dry run genuinely performs nothing and deletions confirm separately. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `BOOKMARKS_DB_PATH` · required · Bookmarks and the transfer queue. Contains no secrets by design. - `HOST_KEYS_PATH` · required · Stored host keys. A mismatch here is a refusal, not a warning. - `MULTIPART_THRESHOLD_MB` · required · Files above this upload in parts. Below it, a single request is simpler and faster. - `TRANSFER_MAX_ATTEMPTS` · required · Retries before a transfer is marked failed with its error preserved. - `VERIFY_AFTER_TRANSFER` · required · Whether to compare digests after transfer where the protocol allows it. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Transmit product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, libssh2 through a thin Swift wrapper, The S3 API over HTTPS with signature version 4, The macOS keychain, never a settings file, A persisted transfer queue with byte-level resume. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. - Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share.
# Delivery milestones · Transmit Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Connect and list, with host keys taken seriously You can browse a remote server, and an unexpected host key stops you rather than warning you. ### Steps 1. Wrap libssh2 for connect and list Key and password authentication, directory listing with sizes and modification times. Files: `SFTP.swift` 2. Store and check host keys First connection stores; later connections compare. A mismatch is a refusal. Files: `HostKeys.swift` 3. Put credentials in the keychain Bookmarks store a keychain reference, never a secret. Grep your settings file to prove it. Files: `Credentials.swift` ### Done when - [ ] A real server's directory lists correctly - [ ] A changed host key causes a refusal, not a warning - [ ] No credential appears in any file on disk - [ ] Key-based authentication works with a passphrase-protected key ### Watch out - A changed host key is what a machine-in-the-middle attack looks like, and a warning dialogue with a continue button trains people to ignore exactly the signal that matters. Refuse the connection and require a deliberate action in settings to accept a new key, which is what a careful client does. ## M2 · Transfers that resume A transfer interrupted at eighty percent continues from eighty percent. ### Steps 1. Persist the queue with byte offsets Written to the database as transfers progress, so a crash loses at most a small window. Files: `Queue.swift` 2. Verify before resuming Check the remote size and, where possible, the overlapping bytes. Trusting the offset alone is how corruption happens. Files: `Resume.swift` 3. Retry with backoff and a cap Exponential backoff, a cap, and the error preserved for the user rather than retried forever. Files: `Queue.swift` ### Done when - [ ] An interrupted transfer resumes from its offset - [ ] Relaunching resumes the queue - [ ] A resumed file matches the source by digest - [ ] Repeated failure stops at the cap with the error shown ### Watch out - Resuming an upload by appending without verifying what the server already has will corrupt the file if the partial upload ended mid-write. Check the remote size before resuming and, where the protocol allows, verify the overlapping region rather than trusting the offset. ## M3 · Object storage as a second protocol An S3-compatible bucket behaves like a folder, with its differences made visible. ### Steps 1. Implement signature version 4 Signing is fiddly and a wrong signature returns an unhelpful error. Test against a known request before building on it. Files: `S3/Sign.swift` 2. Add multipart upload with cleanup Above a threshold, upload in parts. Abort incomplete uploads on failure, because unfinished parts are billed and invisible. Files: `S3/Multipart.swift` 3. Make prefix semantics visible Show the object count before a prefix delete. This is not a directory and the interface should not fully pretend it is. Files: `S3/Delete.swift` ### Done when - [ ] A bucket lists with prefixes as folders - [ ] A large file uploads in parts and is correct - [ ] A prefix delete warns with the object count - [ ] An interrupted multipart upload is aborted rather than abandoned ### Watch out - Object storage has no directories: a folder is a shared key prefix, and an empty folder does not exist at all. Presenting prefixes as folders is right, but deleting a folder means deleting every object under it, which is not the same operation as removing an empty directory. Say so before performing it. ## M4 · Conflicts and verification Nothing is overwritten by accident, and you know the upload arrived intact. ### Steps 1. Detect conflicts before transferring Compare size and modification time. Default to refusing, never to overwriting. Files: `Conflicts.swift` 2. Show both sides Sizes and timestamps for local and remote. The user decides; the client does not guess. Files: `ConflictSheet.swift` 3. Verify after transfer A digest comparison where available. Test with a deliberately corrupted transfer to confirm it fails. Files: `Verify.swift` ### Done when - [ ] An overwrite of a newer remote file raises a conflict - [ ] Both sides' sizes and timestamps are shown - [ ] A completed upload is verified by digest where supported - [ ] A corrupted transfer fails verification ### Watch out - Comparing modification times across systems is unreliable: clocks differ, timezones are handled inconsistently, and some servers report second precision while others report better. Treat a newer remote file as a conflict to surface rather than a fact to act on, and show both timestamps so the user can judge. ## M5 · Synchronising a folder You can mirror a local folder to a remote one and know exactly what will change first. ### Steps 1. Compute the change set Compare listings, produce uploads, updates and deletions as a plan before doing anything. Files: `Sync.swift` 2. Confirm deletions separately Their own count and their own confirmation. This is the operation that causes the losses. Files: `SyncSheet.swift` 3. Support exclusions Patterns applied before the plan is built, so excluded files never appear in it. Files: `Sync.swift` ### Done when - [ ] A dry run lists changes and performs none - [ ] Deletions are counted and confirmed separately - [ ] Exclusion patterns keep files out of every operation - [ ] A second run with no changes transfers nothing ### Watch out - A sync with deletion enabled and a mis-set local path will cheerfully empty a remote folder, and the operation is fast. Always dry run first, show the deletion count prominently, and require confirmation specifically for deletions rather than for the sync as a whole. ## M6 · Signing, notarization and updates (production only) Others can install it, and it handles their credentials responsibly. ### Steps 1. Sign and notarize Hardened runtime, Developer ID, notarytool, stapler. ```sh xcrun notarytool submit Transfer.zip --keychain-profile AC --wait ``` 2. Export bookmarks without secrets Connection details only. Inspect the exported file to confirm. Files: `Export.swift` 3. Document credential handling Keychain storage, host key policy, and what an export contains. Files: `README.md` ### Done when - [ ] spctl reports the notarized build accepted - [ ] An exported bookmark file contains no credentials - [ ] The README states the host key policy - [ ] An appcast update installs and relaunches ### Watch out - Exporting bookmarks for backup is a feature people want and a way to leak credentials, because the obvious implementation includes them. Export bookmarks without secrets and say so, so a shared bookmark file is safe to share.
# Operations · Transmit ## Backup Bookmarks and the queue are small and contain no secrets, so they back up trivially. Credentials live in the keychain and are covered by the keychain's own backup, which is worth stating because people assume otherwise. ## Restore Restore the bookmarks database and the keychain. Bookmarks without their keychain entries prompt for credentials again rather than failing silently, which is the behaviour to test. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For unattended sync, watch the failure count and the deletion count. A sudden jump in deletions is the signal that a path is wrong, and it is worth alerting on rather than logging. ## Incident checklist A sync that deleted the wrong things is the incident that matters, and the mitigation is the dry run rather than a recovery path, because remote deletions are usually not recoverable. Treat any change to sync paths as requiring a fresh dry run. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A changed host key is confirmed to cause a refusal - [ ] No credential appears in any file, verified by grep - [ ] An interrupted large transfer resumes and matches by digest - [ ] A sync dry run is confirmed to perform nothing - [ ] An exported bookmark file is confirmed to contain no secrets ## Launch constraint Do not market omitted Transmit capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookmarks and the transfer queue. Contains no secrets by design. BOOKMARKS_DB_PATH=~/Library/Application Support/Transfer/bookmarks.db # Required. Stored host keys. A mismatch here is a refusal, not a warning. HOST_KEYS_PATH=~/Library/Application Support/Transfer/known_hosts # Required. Files above this upload in parts. Below it, a single request is simpler and faster. MULTIPART_THRESHOLD_MB=64 # Required. Retries before a transfer is marked failed with its error preserved. TRANSFER_MAX_ATTEMPTS=5 # Required. Whether to compare digests after transfer where the protocol allows it. VERIFY_AFTER_TRANSFER=true
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Transmit because small utilities are technically reproducible, but customers pay for polish and immediate fixes when an OS update breaks a daily habit. The recurring cost buys permissions, accessibility APIs, sandboxing, signing, notarization, OS regressions, updates, backups, and support, not just the visible interface.
xprotocol compatibility, native polish, sync, security, and ongoing maintenance
xyears of OS-version edge cases
xlarge extension ecosystem
xcross-device sync
xcommercial support and rapid compatibility fixes
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Transmit pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| transmit 5 direct license | custom | — | 1 named user; install on every Mac that user owns or controls; 7-day unrestricted trial |
| transmit 5 mac app store annual | — | $2.08/user | 1 Apple Account; use on all Macs; major updates included while the subscription remains active |
free tierno free tier; 7-day full-featured unrestricted trial
billingone-time $45 direct license or annual-only $24.99 Mac App Store subscription; no monthly plan
hidden costsThe direct license may require a paid future major-version upgrade; the App Store edition stops working after expiry and cannot offer every direct-build integration.
verified 2026-08-14 · source ↗
Vibecode Transmit
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Transmit replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Transmit cost?
Transmit's pricing is usage-based or varies by plan · Sold as a one-time Mac licence..
What do I lose by replacing Transmit?
Honestly: protocol compatibility, native polish, sync, security, and ongoing maintenance; years of OS-version edge cases; large extension ecosystem; cross-device sync; commercial support and rapid compatibility fixes. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Transmit?
Yes: Cyberduck (Transmit's protocol list, bookmarks, and transfer queue, plus a donation nag instead of a receipt.) The prompt is for when you want it exactly your way.