Top-down map of CrocApp. Start here, then follow the links at the bottom for depth. Doubles as a Swift/SwiftUI learning trail through the repo: every type named below is real and worth opening.
CrocApp is a native SwiftUI app (iOS 26 + macOS 26, one multiplatform target) that embeds croc as a Go library rather than shelling out to a CLI. There is one process: the app binary. croc's transfer engine runs in-process as linked Go code (via a gomobile-built .xcframework), not as a subprocess and not as a daemon — iOS forbids spawning processes, so a subprocess model was never an option. A single Swift actor, CrocEngine, is the only thing in the app that calls into that Go code, and TransferController is the only thing in the app that calls CrocEngine. Everything above that is SwiftUI views reacting to one @Observable state machine.
croc v10.5.0 (schollz/croc, go.mod pin)
│ Go function calls (croc.NewCtx, Client polling)
▼
crocmobile/ (Go wrapper package)
│ gobind: scalars, strings, bools, interfaces, struct pointers only
│ (no []string, no unsigned ints, no struct slices — paths/ports/excludes
│ cross as newline- or comma-joined strings; fileList/progress/done cross
│ as JSON strings)
▼
gomobile bind -target ios,iossimulator,macos/arm64
▼
CrocKit/Croc.xcframework (gitignored build artifact)
│ ObjC-bridged Go symbols: CrocmobileOptions, CrocmobileTransfer,
│ CrocmobileDelegateProtocol; import Croc
▼
CrocKit (Swift package: CrocEngine actor, DelegateBridge, Models.swift)
│ AsyncStream<TransferEvent>; delegate callbacks arrive on Go threads,
│ DelegateBridge only yields into the stream — no MainActor hop here
▼
TransferController (@MainActor @Observable, app/CrocApp/Models/)
│ Phase enum; intents (startSend, startReceive, respond, cancel)
▼
SwiftUI views (app/CrocApp/Views/)
switch over controller.phase; call intents; nothing else touches CrocKit
schollz/croc/v10 v10.5.0, pinned in crocmobile/go.mod. PAKE-secured, relay-assisted, end-to-end encrypted transfer protocol. CrocApp does not fork or reimplement it (project non-goal, docs/knowledge/project-overview.md); upgrades are a version bump + rebind, tracked in docs/knowledge/croc-upgrade-playbook.md.
Files: crocmobile.go (public API: Options, Delegate interface, Transfer handle, StartSend/StartReceive), session.go (the engine: session struct, startSession/startReceiveSession, the fd0 prompt pipe, the 10 Hz poller), doc.go (package-level gobind constraint comment), cmd/croctest/main.go (CLI harness, not linked into the app).
Responsibility: adapt croc's CLI-shaped library API (process-global os.Chdir, stdin-bound prompts, os.Stdout-piped text receive) into something callable repeatedly and concurrently-safely from a GUI. Key types: Options (mirrors croc CLI flags, gobind-safe scalar fields only), Delegate (7-method callback interface: OnCodeReady, OnConnected, OnFileList, OnProgress, OnText, OnDone, OnError), Transfer (Cancel(), Respond(accept bool)).
Deliberately does not: run more than one transfer at a time (package-global activeMu mutex — second Start* call returns "another transfer is active"), expose every croc knob (RelayPorts, Curve, HashAlgorithm, ThrottleUpload, NoMultiplexing are settable in Options but unwired above CrocKit — F20-F29 backlog, docs/knowledge/features.md), or let a Go panic cross the gobind boundary uncaught: StartSend/StartReceive/Cancel/Respond each carry their own recover(), run() recovers around xfer() and again around the terminal OnError/OnText/OnDone calls, and poll() recovers per tick around OnConnected/OnFileList/OnProgress (session.go's reportPanic). xfer() itself has no watchdog, though — a hang there past context cancel still holds activeMu forever (docs/known-issues.md).
Gobind surface: the xcframework's ObjC module is Croc (import Croc); Go symbols get an Crocmobile-prefixed ObjC name (CrocmobileOptions, CrocmobileStartSend, protocol CrocmobileDelegateProtocol → Swift CrocmobileDelegateProtocol). Full type-restriction list and the JSON field-by-field contract: docs/knowledge/crocmobile-bridge.md.
Built by scripts/build-xcframework.sh (gomobile bind -target ios,iossimulator,macos/arm64), one output feeding both platforms. Gitignored; not part of this repo's source of truth (see Build-time architecture below).
Package.swift: swift-tools 6.0, platforms .iOS("26.0"), .macOS("26.0"), targets Croc (the binaryTarget), CrocKit (depends on Croc), crockit-verify (executable harness, depends on CrocKit).
CrocEngine.swift—public actor CrocEngine. Owns the oneCrocmobileTransferhandle at a time (activeTransfer), exposesstartSend/startReceive(returnAsyncStream<TransferEvent>),respond(accept:),cancel().crocOptions(from:)maps SwiftEngineOptionstoCrocmobileOptions— this is the actual list of knobs the GUI can reach; anything in Go'sOptionsnot copied here is inert from the app's perspective.CrocEngineError:.transferActive,.startFailed(String).DelegateBridge.swift—final class DelegateBridge: NSObject, CrocmobileDelegateProtocol, @unchecked Sendable. Bridges Go-thread callbacks intoAsyncStream<TransferEvent>.Continuation.yield. Decodes the JSON event payloads intoModels.swiftstructs; a malformedfileListordonepayload becomes.failed(...)and finishes the stream (the Go side is then orphaned until the consumer callsengine.cancel(), per the doc comment onDelegateBridge).Models.swift—EngineOptions(Swift-side settings struct),TransferEventenum (codeReady,connected,fileList,progress,text,done,failed),FileList,TransferProgress,Summary(allCodable, Sendable).crockit-verify— standalone executable (send/receive/twicesubcommands), macOS-only interop harness, not shipped in the app.
Deliberately does not: hop to MainActor itself. DelegateBridge only yields; the consumer (TransferController) does the hop, because @MainActor @Observable state can only be mutated there.
app/CrocApp/Models/TransferController.swift. @MainActor @Observable final class. The only consumer of CrocKit in the app. Owns a private CrocEngine, a BackgroundCoordinator, and the Phase enum every view switches on. Full event-handling invariants (why .progress must not clobber .incoming, the transferActive retry window, copy precedence): docs/knowledge/app-ui-architecture.md.
app/CrocApp/Views/. HomeView is the sole NavigationStack (value-based AppRouter.Route links). SendView/ReceiveView show an input form when !controller.isActive, else TransferStatusView. TransferStatusView renders every non-idle Phase; IncomingRequestView (in the same file) is the accept gate. SettingsScreen (iOS route) and SettingsView (macOS Settings scene) both embed the shared PowerSettingsSections. HistoryView, HowItWorksView, OnboardingView, StagedFilesSheet, QRCodeView, QRScannerView, TrustBadge round out the view layer. Views never import Croc or CrocKit — they only read TransferController.phase and call its intent methods.
Real event names are the TransferEvent cases; Phase is TransferController.Phase.
SendViewcallscontroller.startSend(urls:customCode:).phase = .starting;BackgroundCoordinator.transferStartedwraps the transfer (iOS only);engine.startSendlaunches the Go session.- Go delegate fires
OnCodeReady→ Swift.codeReady(code)→phase = .waiting(code:). The waiting view shows the code and a QR (QRCodeView). - Peer connects:
OnConnected→.connected. Ifsettings.bothSidesConfirm(croc--ask, F19) is on,phase = .confirmSendinstead of.connecting— this is the sender-side "both sides confirm" gate; the user's accept callsrespond(accept: true), decline callscancel(). - Progress ticks arrive at ~10 Hz as
OnProgress→.progress(TransferProgress),stepcyclingwaiting→connected→transferring.TransferControllerignoresstep == "waiting"ticks and, critically, ignores all progress whilephaseis.confirmSend(same guard protects the receive side's.incoming) — croc is blocked inGetInputwaiting forrespond(), and a clobbered phase there means the UI can never call it. - Transfer completes:
OnDone→.done(Summary)→phase = .done(summary, receivedText: nil).finishRecordwrites aTransferRecordtoHistoryStore.
ReceiveViewcallscontroller.startReceive(code:into:folderIsScoped:).options.overwrite = truealways (conflicts are surfaced in the UI, not deferred to croc);options.autoAccept = settings.autoAccept && !settings.bothSidesConfirm(Ask always wins over auto-accept, since engine-levelAutoAcceptcloses the prompt pipe and the resulting EOF would decline instead of accept).OnFileList→.fileList(FileList). If auto-accept is off,phase = .incoming(list, conflicts: [], blocked: [...])immediately (unsafe names, viaReceivedName.isUnsafe, are computed synchronously); a detached task then stats the output folder for name collisions and back-fillsconflictsif the phase is still.incoming. Progress keeps ticking underneath (guarded, per above) because croc is blocked inutils.GetInput, reading the dup2'd fd0 pipe (crocmobile/session.go), waiting onRespond.- User accepts:
TransferController.respond(accept: true)callsengine.respond(accept:)→CrocmobileTransfer.respond→ Go writesy\nto the pipe (once, for receive; per-file for a sender-side Ask, seedocs/knowledge/crocmobile-bridge.md) and closes it.phase = .connecting. OnProgressresumes normally,phase = .transferring(TransferProgress).OnDone→.done. For a text transfer,OnTextarrives just beforeOnDone;receivedTextis threaded intoPhase.done(_, receivedText:).
- User declines
.incoming:respond(accept: false)writesn\n, setsdeclineRequested = true. croc surfaces this to both sides as the string"refused files"—TransferController.friendlyMessagemaps it to "You declined the transfer." locally and "The other side declined the transfer." when it arrives as.failedon the peer's controller. - User cancels mid-transfer:
controller.cancel()setscancelRequested = trueand callsengine.cancel(), which calls the GoTransfer.Cancel()→session.cancel(): context-cancels and closes the prompt pipe (a bare context cancel does not unblock a pendingGetInputread, socancel()must also touch the pipe). - Any terminal event also triggers
Task { await engine.cancel() }fromTransferController.handle(.failed)as a belt-and-suspenders release, since the engine must be free for the next transfer to start.
Full JSON field lists, the fileSent-is-per-current-file wrinkle, and sub-100ms transfers that skip straight to done: docs/knowledge/crocmobile-bridge.md.
CrocAppApp.init() (app/CrocApp/CrocAppApp.swift) builds, in order:
AppSettings()— must exist first;TransferControllertakes it as a constructor dependency.HistoryStore(container:)—ModelContaineris in-memory whenAutoVerify.isHarnessRun, on-disk otherwise.TransferController(settings:), thencontroller.history = historyset explicitly (not a constructor argument, sinceHistoryStoredepends on the harness check that also gates settings).OutputFolderStore(),LocalNetworkChecker(),AppRouter.shared— order among these three doesn't matter; none depend on the others.
WindowGroup content gets all six as .environment(...), plus .modelContainer(history.container) for @Query in HistoryView. The macOS Settings scene is deliberately narrower: only outputFolder and settings are injected — no router, no controller, no localNetwork (Settings has no transfer to be busy with).
@MainActor @Observable types: AppSettings, TransferController, HistoryStore, OutputFolderStore, LocalNetworkChecker, AppRouter (all of them — the module default actor isolation is MainActor, see Concurrency below).
Persistence:
| What | Where | Notes |
|---|---|---|
| Power settings (F13-F19) | UserDefaults, keys prefixed settings. |
AppSettings, didSet-triggered, gated by a persist flag |
| Output folder choice | UserDefaults (security-scoped bookmark) |
OutputFolderStore |
| Transfer history (F12) | SwiftData, TransferRecord @Model |
HistoryStore, in-memory under AutoVerify |
| Share-extension handoff | App Group container (group.com.bakirgdev.CrocApp) |
ShareInbox reads, ShareStager writes; ShareInbox/batch-<UUID>/ + manifest.json |
| Onboarding seen flag | UserDefaults via @AppStorage |
onboarding.seen, read in ContentView |
One SwiftUI target, #if os(iOS) / #if os(macOS) isolated to dedicated files where real divergence exists:
| Concern | iOS | macOS |
|---|---|---|
| Backgrounding | BackgroundCoordinator wraps transfers in BGContinuedProcessingTask; system Live Activity |
no-op (all BackgroundCoordinator bodies are #if os(iOS)) |
| External send entry point | Share extension (CrocShare appex) + ShareInbox/StagedFilesSheet |
Dock-icon drop + window drop, via AppDelegate.application(_:open:) and AppRouter.shared |
| Local-network probe | LocalNetworkChecker (Bonjour self-probe) |
same file, same probe — macOS 15 added its own Local Network privacy pane, enforced as a Network Extension packet filter rather than through TCC |
| QR scan | QRScannerView (VisionKit DataScannerViewController), whole file #if os(iOS) |
not offered (QR generation, QRCodeView, is cross-platform) |
| Settings surface | SettingsScreen (Route.settings, gear toolbar icon) |
SettingsView (native Settings scene, ⌘,) |
| Menu commands | n/a | AppCommands (Send ⌘1, Receive ⌘2, Show Receive Folder ⇧⌘R) |
Two entitlement sets for the main app target, selected by SDK in the pbxproj (CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*] etc.):
app/CrocApp/CrocApp.entitlements(macOS):com.apple.security.app-sandbox,network.client,network.server(needed for the local-only relay listener),files.user-selected.read-write,files.downloads.read-write(default output folder is~/Downloads/CrocApp).app/CrocApp/CrocApp-iOS.entitlements: justcom.apple.security.application-groups(group.com.bakirgdev.CrocApp, for the share-extension handoff).app/CrocShare/CrocShare.entitlements: same App Group group, nothing else (the extension has no network, no sandbox exception beyond the group).
Three Info.plist sources (app/Config/CrocApp-Info.plist for iOS, CrocApp-macOS-Info.plist for macOS, CrocShare-Info.plist for the extension) merged over Xcode's GENERATE_INFOPLIST_FILE=YES output; SDK-scoped INFOPLIST_KEY_* build settings (camera usage, local-network usage, file-sharing, document-types-in-place) live directly in the pbxproj rather than in these files.
Sandbox boundaries that shaped the design, concretely:
- Security-scoped URLs: every send/receive intent in
TransferControllercallsstartAccessingSecurityScopedResource()on user-picked URLs and tracks them inscopedURLsfor release at stream end;OutputFolderStoreandHistoryView's bookmark-resolve path do the same, on both platforms, before anyfileExists/statcall (the sandbox deniesstaton an unopened scope — a defect caught separately on each platform, perdocs/knowledge/app-ui-architecture.md). - App Group container: the only channel between
CrocShare(a separate process, ~120 MB memory cap, no long-running work) and the main app. The extension copies attachment bytes inloadFileRepresentation's synchronous callback (the source temp file is deleted once that callback returns) and never deletes existing batches — a batch may be mid-send. - Output folder: iOS defaults to the app's own Documents (Files-app visible via
UIFileSharingEnabled+LSSupportsOpeningDocumentsInPlace); macOS defaults to~/Downloads/CrocApp, requiring thefiles.downloads.read-writeentitlement — this is why that entitlement exists at all. network.serveron macOS exists solely so croc's local relay listener can bind a port inside the sandbox (--local/onlyLocal); this was proven, not assumed.
Swift 6 language mode (SWIFT_VERSION = 6.0), SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor and SWIFT_APPROACHABLE_CONCURRENCY = YES set for every target in the pbxproj (app, both platform build configs, and CrocShare). Practical effect: any type in the app or share extension that doesn't opt out is implicitly @MainActor — this is why AppSettings, TransferController, HistoryStore, OutputFolderStore, LocalNetworkChecker, and AppRouter all carry an explicit @MainActor (belt-and-suspenders documentation of what the compiler already enforces) rather than needing it to compile.
CrocEngine is the one deliberate exception: it's a plain actor, isolated to its own executor, not MainActor. CrocmobileDelegate callbacks (OnCodeReady, OnProgress, etc.) arrive on arbitrary Go threads — crocmobile's poller goroutine and the transfer goroutine both call the delegate directly, with no serialization guarantee about which Swift/Go thread runs which callback. DelegateBridge (@unchecked Sendable, because NSObject subclasses conforming to an ObjC protocol can't be verified Sendable by the compiler) receives those calls and does exactly one thing: continuation.yield(...) into the AsyncStream. AsyncStream.Continuation.yield is safe to call from any thread. The hop to MainActor happens implicitly at the for await event in stream loop inside TransferController.run(_:), because that method (and the whole class) is @MainActor.
CrocKit/Package.swift declares Croc as a .binaryTarget(path: "Croc.xcframework"); that path is gitignored. A fresh clone therefore builds nothing in CrocKit or the app until scripts/build-xcframework.sh has run once. Full toolchain requirements, CI wiring, and the manual steps: docs/BUILDING.md.
| Question | Document |
|---|---|
| Exact event JSON fields, croc gotchas, verification harnesses | docs/knowledge/crocmobile-bridge.md |
| View-by-view UI invariants, settings/trust details, platform layer specifics | docs/knowledge/app-ui-architecture.md |
| iOS/macOS platform limits (background, multicast, sandbox, App Store review) | docs/knowledge/apple-platform-constraints.md |
| Feature status (shipped / planned / skipped) | docs/knowledge/features.md |
| Fresh-clone build steps | docs/BUILDING.md |
| Design tokens, component specs, SF Symbols mapping | design/CLAUDE.md |
| Term definitions (croc, PAKE, relay, etc.) | docs/GLOSSARY.md |
| Known defects, accepted papercuts | docs/known-issues.md |