From 1947cdf1ebeb1cc1e791dbad835b882b6721ee80 Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Wed, 14 Oct 2026 23:57:17 +0200 Subject: [PATCH 1/8] Add stored transfers to the engine: upload, receive and revoke through croc's store client crocmobile gains StartStoreSend, StartStoreReceive and RevokeStored over storeclient, sharing the transfer lock, with OnStored for the receipt and storeLimit/storeExpired failure codes. Keep Both is settled in the manifest because storeclient's install rename replaces. CrocKit exposes the three calls and TransferEvent.stored; croctest gains store modes and a loopback store server, and verify-interop.sh scenario 14 covers CLI interop both ways, revoke and the file policies. --- CrocKit/Sources/CrocKit/CrocEngine.swift | 54 +- CrocKit/Sources/CrocKit/DelegateBridge.swift | 14 + CrocKit/Sources/CrocKit/Models.swift | 58 ++ CrocKit/Sources/crockit-verify/main.swift | 1 + .../CrocKitTests/DelegateBridgeTests.swift | 49 ++ .../CrocKitTests/EngineFailureTests.swift | 2 +- .../Tests/CrocKitTests/StoreEngineTests.swift | 61 ++ app/CrocApp/Models/TransferController.swift | 8 + app/CrocAppTests/FriendlyMessageTests.swift | 5 + crocmobile/cmd/croctest/main.go | 72 ++- crocmobile/crocmobile.go | 2 + crocmobile/errors.go | 28 + crocmobile/session.go | 6 + crocmobile/session_test.go | 1 + crocmobile/store.go | 388 +++++++++++++ crocmobile/store_test.go | 530 ++++++++++++++++++ docs/ARCHITECTURE.md | 2 +- docs/BUILDING.md | 2 +- docs/knowledge/crocmobile-bridge.md | 7 +- scripts/verify-interop.sh | 82 ++- 20 files changed, 1351 insertions(+), 21 deletions(-) create mode 100644 CrocKit/Tests/CrocKitTests/StoreEngineTests.swift create mode 100644 crocmobile/store.go create mode 100644 crocmobile/store_test.go diff --git a/CrocKit/Sources/CrocKit/CrocEngine.swift b/CrocKit/Sources/CrocKit/CrocEngine.swift index 2335736..e9de9e4 100644 --- a/CrocKit/Sources/CrocKit/CrocEngine.swift +++ b/CrocKit/Sources/CrocKit/CrocEngine.swift @@ -30,13 +30,51 @@ public actor CrocEngine { public func startSend( paths: [String], text: String?, options: EngineOptions ) throws -> EngineTransfer { - try start(send: true, code: "", paths: paths, text: text ?? "", options: options) + let opts = try Self.crocOptions(from: options) + let joined = paths.joined(separator: "\n") + return try start { bridge, error in + CrocmobileStartSend(joined, text ?? "", opts, bridge, error) + } } public func startReceive( code: String, options: EngineOptions ) throws -> EngineTransfer { - try start(send: false, code: code, paths: [], text: "", options: options) + let opts = try Self.crocOptions(from: options) + return try start { bridge, error in CrocmobileStartReceive(code, opts, bridge, error) } + } + + /// Uploads `paths` (regular files) encrypted to `options.server`. Events: + /// `.progress`, then `.stored(receipt)` and `.done`. + public func startStoreSend( + paths: [String], options: StoredSendOptions + ) throws -> EngineTransfer { + let opts = CrocmobileStoreOptions() + opts.server = options.server + opts.downloads = options.downloads + opts.lifetimeSeconds = options.lifetimeSeconds + let joined = paths.joined(separator: "\n") + return try start { bridge, error in + CrocmobileStartStoreSend(joined, opts, bridge, error) + } + } + + /// Opens a stored-transfer link: `.fileList` first, then wait for + /// `respond(accept:policy:)`; on accept the files land in `outDir`. + public func startStoreReceive(link: String, outDir: String) throws -> EngineTransfer { + try start { bridge, error in CrocmobileStartStoreReceive(link, outDir, bridge, error) } + } + + /// Deletes a stored upload from its server; a transfer the server no longer + /// has counts as revoked. Throws `EngineFailure`. Blocks up to 30 seconds on + /// the network, off the caller's actor, and needs no running transfer, so it + /// works while another one is active. + public nonisolated func revokeStored(_ receipt: StoredReceipt) async throws { + var error: NSError? + guard CrocmobileRevokeStored(receipt.origin, receipt.id, receipt.uploadToken, &error) + else { + throw EngineFailure(payload: error?.localizedDescription) + } } /// Answer the accept/decline request raised by a `.fileList` event. On @@ -56,24 +94,16 @@ public actor CrocEngine { func isActive(_ id: TransferID) -> Bool { id == activeID } private func start( - send: Bool, code: String, paths: [String], text: String, options: EngineOptions + launch: (DelegateBridge, NSErrorPointer) -> CrocmobileTransfer? ) throws -> EngineTransfer { guard activeTransfer == nil else { throw CrocEngineError.transferActive } - let opts = try Self.crocOptions(from: options) let (stream, continuation) = AsyncStream.makeStream(of: TransferEvent.self) let bridge = DelegateBridge(continuation: continuation) self.bridge = bridge var error: NSError? - let transfer: CrocmobileTransfer? - if send { - transfer = CrocmobileStartSend( - paths.joined(separator: "\n"), text, opts, bridge, &error) - } else { - transfer = CrocmobileStartReceive(code, opts, bridge, &error) - } - guard let transfer else { + guard let transfer = launch(bridge, &error) else { self.bridge = nil // gobind passes only the Go error's text, which carries the same // payload as OnError. diff --git a/CrocKit/Sources/CrocKit/DelegateBridge.swift b/CrocKit/Sources/CrocKit/DelegateBridge.swift index a063036..e927e52 100644 --- a/CrocKit/Sources/CrocKit/DelegateBridge.swift +++ b/CrocKit/Sources/CrocKit/DelegateBridge.swift @@ -17,6 +17,7 @@ final class DelegateBridge: NSObject, CrocmobileDelegateProtocol, @unchecked Sen // init) and keeps its per-call state local, so concurrent decodes don't // share mutable state. private let decoder = JSONDecoder() + private let receiptDecoder = StoredReceipt.decoder() init(continuation: AsyncStream.Continuation) { self.continuation = continuation @@ -55,6 +56,19 @@ final class DelegateBridge: NSObject, CrocmobileDelegateProtocol, @unchecked Sen func onText(_ text: String?) { continuation.yield(.text(text ?? "")) } + func onStored(_ receiptJSON: String?) { + guard let data = receiptJSON?.data(using: .utf8), + let receipt = try? receiptDecoder.decode(StoredReceipt.self, from: data) + else { + // The upload is on the server but its revoke token is lost; fail + // loudly instead of showing a transfer nobody can take back. + continuation.yield( + .failed(EngineFailure(code: .other, message: "malformed storedReceipt payload"))) + continuation.finish() + return + } + continuation.yield(.stored(receipt)) + } func onDone(_ summaryJSON: String?) { if let json = summaryJSON, let s = decode(json, as: Summary.self) { continuation.yield(.done(s)) diff --git a/CrocKit/Sources/CrocKit/Models.swift b/CrocKit/Sources/CrocKit/Models.swift index 05b3eab..e2d01ae 100644 --- a/CrocKit/Sources/CrocKit/Models.swift +++ b/CrocKit/Sources/CrocKit/Models.swift @@ -64,10 +64,64 @@ public enum TransferEvent: Sendable { case fileList(FileList) case progress(TransferProgress) case text(String) + /// A stored upload finished: the receipt arrives just before `.done`. + case stored(StoredReceipt) case done(Summary) case failed(EngineFailure) } +/// A stored-transfer upload: the file contents are encrypted on this device and +/// kept on `origin` until `expiresAt` or until the download count runs out. +public struct StoredReceipt: Codable, Sendable, Equatable { + public let id: String + public let origin: String + /// Secret: proves the right to revoke. Never log or display it. + public let uploadToken: String + /// Secret: the fragment after `#v1.` is the decryption key. + public let link: String + public let expiresAt: Date + public let downloads: Int + + public init( + id: String, origin: String, uploadToken: String, link: String, expiresAt: Date, + downloads: Int + ) { + self.id = id + self.origin = origin + self.uploadToken = uploadToken + self.link = link + self.expiresAt = expiresAt + self.downloads = downloads + } + + private enum CodingKeys: String, CodingKey { + case id, origin, uploadToken, link, expiresAt, downloads + } + + /// The engine writes `expiresAt` as RFC 3339 in UTC, whole seconds. + static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +extension StoredReceipt: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "StoredReceipt(id: \(id), origin: \(origin), secrets hidden)" } + public var debugDescription: String { description } +} + +/// What a stored upload asks of the server. `server` must be an `https` origin +/// (`http` only on loopback). The server may cap `lifetimeSeconds` and refuses +/// a `downloads` above its limit with `EngineFailure.Code.storeLimit`. +public struct StoredSendOptions: Sendable { + public var server = "https://getcroc.com" + /// 1 to 100. + public var downloads = 1 + public var lifetimeSeconds = 86_400 + public init() {} +} + /// Why a transfer failed, classified by the engine next to croc. Show copy for /// `code`; `message` is croc's own text, for logs only, and changes between /// croc releases. @@ -77,6 +131,10 @@ public struct EngineFailure: Error, Equatable, Sendable { public enum Code: String, Sendable, CaseIterable { case peerVersion, relayUnreachable, noPublicRelay, declined, refused case roomFull, badPassword, wrongCode, cancelled, connectionLost, other + /// Stored transfers: the server's size, file-count, download, quota or rate limit. + case storeLimit + /// Stored transfers: the server no longer has it (expired, revoked, used up). + case storeExpired } public let code: Code diff --git a/CrocKit/Sources/crockit-verify/main.swift b/CrocKit/Sources/crockit-verify/main.swift index db074c2..c68f3ac 100644 --- a/CrocKit/Sources/crockit-verify/main.swift +++ b/CrocKit/Sources/crockit-verify/main.swift @@ -48,6 +48,7 @@ func drive(_ stream: AsyncStream, answer: Bool) async -> Bool { case .progress(let p): print("EVENT progress \(p.step) \(p.fileSent)/\(p.fileSize)") case .text(let t): print("EVENT text \(t)") + case .stored(let receipt): print("EVENT stored \(receipt.id)") case .done(let s): print("EVENT done success=\(s.success)") return s.success diff --git a/CrocKit/Tests/CrocKitTests/DelegateBridgeTests.swift b/CrocKit/Tests/CrocKitTests/DelegateBridgeTests.swift index 55b3e48..a1f2fc9 100644 --- a/CrocKit/Tests/CrocKitTests/DelegateBridgeTests.swift +++ b/CrocKit/Tests/CrocKitTests/DelegateBridgeTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CrocKit @@ -127,6 +128,54 @@ private func events(_ drive: (DelegateBridge) -> Void) async -> [TransferEvent] } } + @Test func decodesStoredReceiptThenDone() async throws { + let got = await events { + $0.onStored( + #"{"id":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://store.test","uploadToken":"tok","link":"https://store.test/s/AAAAAAAAAAAAAAAAAAAAAA#v1.KEY","expiresAt":"2026-10-15T12:30:00Z","downloads":3}"# + ) + $0.onDone(#"{"success":true,"files":1,"totalSize":5}"#) + } + #expect(got.count == 2) + guard case .stored(let receipt) = try #require(got.first) else { + Issue.record("expected .stored, got \(got)") + return + } + #expect(receipt.id == "AAAAAAAAAAAAAAAAAAAAAA") + #expect(receipt.origin == "https://store.test") + #expect(receipt.uploadToken == "tok") + #expect(receipt.link == "https://store.test/s/AAAAAAAAAAAAAAAAAAAAAA#v1.KEY") + #expect(receipt.expiresAt == Date(timeIntervalSince1970: 1_792_067_400)) + #expect(receipt.downloads == 3) + if case .done = got[1] {} else { Issue.record("expected .done, got \(got[1])") } + } + + @Test(arguments: [ + nil, "", "not json", #"{"id":"x"}"#, + #"{"id":"x","origin":"o","uploadToken":"t","link":"l","expiresAt":"tomorrow","downloads":1}"#, + ]) + func malformedStoredReceiptFailsAndFinishes(payload: String?) async { + let got = await events { + $0.onStored(payload) + $0.onDone(#"{"success":true,"files":1,"totalSize":5}"#) + } + #expect(got.count == 1) + if case .failed(let f) = got.first { + #expect(f == EngineFailure(code: .other, message: "malformed storedReceipt payload")) + } else { + Issue.record("\(got)") + } + } + + @Test func receiptDescriptionHidesSecrets() { + let receipt = StoredReceipt( + id: "id1", origin: "https://store.test", uploadToken: "SECRET-TOKEN", + link: "https://store.test/s/id1#v1.SECRET-KEY", expiresAt: .now, downloads: 1) + for text in ["\(receipt)", "\(String(reflecting: receipt))"] { + #expect(!text.contains("SECRET")) + #expect(text.contains("id1")) + } + } + @Test(arguments: [nil, "", "{}", #"{"success":"yes","files":1,"totalSize":1}"#]) func malformedDoneFailsAndFinishes(payload: String?) async { let got = await events { diff --git a/CrocKit/Tests/CrocKitTests/EngineFailureTests.swift b/CrocKit/Tests/CrocKitTests/EngineFailureTests.swift index 42fd144..c8a45f8 100644 --- a/CrocKit/Tests/CrocKitTests/EngineFailureTests.swift +++ b/CrocKit/Tests/CrocKitTests/EngineFailureTests.swift @@ -11,7 +11,7 @@ import Testing } @Test func unknownCodeIsOtherWithMessage() { - let f = EngineFailure(payload: #"{"code":"storeExpired","message":"gone"}"#) + let f = EngineFailure(payload: #"{"code":"fromANewerEngine","message":"gone"}"#) #expect(f == EngineFailure(code: .other, message: "gone")) } diff --git a/CrocKit/Tests/CrocKitTests/StoreEngineTests.swift b/CrocKit/Tests/CrocKitTests/StoreEngineTests.swift new file mode 100644 index 0000000..fa5a3a2 --- /dev/null +++ b/CrocKit/Tests/CrocKitTests/StoreEngineTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing + +@testable import CrocKit + +/// The store entry points through the real Go engine: what Go refuses before it +/// touches the network arrives as `CrocEngineError.startFailed` with its code. +@Suite struct StoreEngineTests { + private func startFailure(_ start: () async throws -> EngineTransfer) async -> EngineFailure? { + do { + _ = try await start() + return nil + } catch CrocEngineError.startFailed(let failure) { + return failure + } catch { + Issue.record("unexpected error \(error)") + return nil + } + } + + @Test func plainHTTPServerIsRefused() async { + var options = StoredSendOptions() + options.server = "http://example.com" + let engine = CrocEngine() + let failure = await startFailure { + try await engine.startStoreSend(paths: [NSTemporaryDirectory()], options: options) + } + #expect(failure?.code == .other) + } + + @Test(arguments: [101, -1]) + func downloadsOutsideTheRangeAreStoreLimit(downloads: Int) async throws { + let file = FileManager.default.temporaryDirectory.appending(path: "store-\(UUID()).txt") + try Data("x".utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + var options = StoredSendOptions() + options.downloads = downloads + let engine = CrocEngine() + let failure = await startFailure { + try await engine.startStoreSend(paths: [file.path], options: options) + } + #expect(failure?.code == .storeLimit) + } + + @Test func badLinkIsRefused() async { + let engine = CrocEngine() + let failure = await startFailure { + try await engine.startStoreReceive(link: "not a link", outDir: NSTemporaryDirectory()) + } + #expect(failure?.code == .other) + } + + @Test func revokeFailureThrowsEngineFailure() async { + let receipt = StoredReceipt( + id: "AAAAAAAAAAAAAAAAAAAAAA", origin: "http://127.0.0.1:1", uploadToken: "t", + link: "", expiresAt: .now, downloads: 1) + await #expect(throws: EngineFailure.self) { + try await CrocEngine().revokeStored(receipt) + } + } +} diff --git a/app/CrocApp/Models/TransferController.swift b/app/CrocApp/Models/TransferController.swift index 9af7eb7..7d85af0 100644 --- a/app/CrocApp/Models/TransferController.swift +++ b/app/CrocApp/Models/TransferController.swift @@ -342,6 +342,9 @@ final class TransferController { phase = .transferring(p) case .text(let t): receivedText = t + case .stored: + // Nothing starts a stored upload until B17 wires the controller. + break case .done(let summary): background.transferEnded(success: true) // cancel() is fire-and-forget, so a small enough transfer can reach @@ -503,6 +506,11 @@ final class TransferController { return "Transfer cancelled." case .connectionLost: return "Lost connection to the other device." + case .storeLimit: + return + "The transfer server refused this upload: it is over the server's size, file count or download limit, or too many uploads were made recently." + case .storeExpired: + return "This link has expired, was revoked, or has no downloads left." case .other: return "Transfer failed. Try again, or check your connection." } diff --git a/app/CrocAppTests/FriendlyMessageTests.swift b/app/CrocAppTests/FriendlyMessageTests.swift index 8c1150d..1058da0 100644 --- a/app/CrocAppTests/FriendlyMessageTests.swift +++ b/app/CrocAppTests/FriendlyMessageTests.swift @@ -36,6 +36,11 @@ import Testing (.wrongCode, "The codes don't match. Check the code on both devices and try again."), (.cancelled, "Transfer cancelled."), (.connectionLost, "Lost connection to the other device."), + ( + .storeLimit, + "The transfer server refused this upload: it is over the server's size, file count or download limit, or too many uploads were made recently." + ), + (.storeExpired, "This link has expired, was revoked, or has no downloads left."), (.other, "Transfer failed. Try again, or check your connection."), ]) func mapsEveryCode(code: EngineFailure.Code, expected: String) { diff --git a/crocmobile/cmd/croctest/main.go b/crocmobile/cmd/croctest/main.go index 0493eda..a9f44dd 100644 --- a/crocmobile/cmd/croctest/main.go +++ b/crocmobile/cmd/croctest/main.go @@ -2,6 +2,10 @@ // // croctest send [-relay ADDR] [-no-local] [-only-local] [-text MSG] [-code CODE] [-relay-index N] [-throttle RATE] [-no-multi] [-transport auto|relay|derp] [PATH...] // croctest receive [-relay ADDR] [-no-local] [-only-local] [-out DIR] [-answer y|n] [-yes] [-policy keepBoth|replace] [-cancel-after MS] CODE +// croctest store-send [-server URL] [-downloads N] [-lifetime SECONDS] PATH... +// croctest store-receive [-out DIR] [-answer y|n] [-policy keepBoth|replace] [-cancel-after MS] LINK +// croctest store-revoke ORIGIN ID TOKEN +// croctest store-server DIR // croctest relay CODE // croctest relays // @@ -19,6 +23,12 @@ // latency probe had picked it. relay prints the pool relay a code maps to; // relays prints the pool in index order. // +// The store modes use a stored-transfer server (default https://getcroc.com). +// store-server runs croc's own server on a loopback port for the harness, +// storing in DIR, and prints "STORE-URL " once it listens. +// store-send prints EVENT stored with the receipt JSON, whose link and +// uploadToken are secrets; store-receive answers the preview like receive does. +// // Events print as lines: EVENT . With the public pool in use, // EVENT relay names the relay the code maps to. package main @@ -26,6 +36,8 @@ package main import ( "flag" "fmt" + "net" + "net/http" "os" "strings" "time" @@ -33,6 +45,7 @@ import ( "github.com/bakirgdev/CrocApp/crocmobile" "github.com/bakirgdev/CrocApp/crocmobile/internal/relaycache" "github.com/schollz/croc/v11/src/publicrelay" + "github.com/schollz/croc/v11/src/store" ) type printDelegate struct { @@ -40,6 +53,7 @@ type printDelegate struct { policy string pool bool transfer **crocmobile.Transfer + ready chan struct{} // closed once *transfer is set, so a fast OnFileList can respond } // Events print to stderr, not stdout: crocmobile's receive session swaps the @@ -56,6 +70,7 @@ func (p *printDelegate) OnCodeReady(code string) { func (p *printDelegate) OnConnected() { fmt.Fprintln(os.Stderr, "EVENT connected") } func (p *printDelegate) OnFileList(j string) { fmt.Fprintf(os.Stderr, "EVENT filelist %s\n", j) + <-p.ready if *p.transfer != nil { (*p.transfer).Respond(p.answer == "y", p.policy) fmt.Fprintf(os.Stderr, "EVENT responded %s %s\n", p.answer, p.policy) @@ -67,18 +82,40 @@ func (p *printDelegate) OnDone(j string) { fmt.Fprintf(os.Stderr, "EVENT done %s\n", j) os.Exit(0) } +func (p *printDelegate) OnStored(j string) { fmt.Fprintf(os.Stderr, "EVENT stored %s\n", j) } func (p *printDelegate) OnError(m string) { fmt.Fprintf(os.Stderr, "EVENT error %s\n", m) os.Exit(1) } +// serveStore never returns. The limits are lifted far enough for the harness +// to ask for several downloads; the rate limit would otherwise trip on a run +// that revokes and re-uploads. +func serveStore(dir string) { + svc, err := store.New(store.Config{ + Root: dir, MaxDownloads: 100, MinFreeBytes: 1, CreatePerHour: 1000, MaxActiveUploads: 10, DisableRootLock: true, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "EVENT error %s\n", err) + os.Exit(1) + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Fprintf(os.Stderr, "EVENT error %s\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "STORE-URL http://%s\n", l.Addr()) + fmt.Fprintf(os.Stderr, "EVENT error %s\n", http.Serve(l, svc)) + os.Exit(1) +} + func printRelay(code string) { fmt.Fprintf(os.Stderr, "EVENT relay %s\n", crocmobile.RelayForCode(code)) } func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: croctest send|receive|relay|relays ...") + fmt.Fprintln(os.Stderr, "usage: croctest send|receive|store-send|store-receive|store-revoke|store-server|relay|relays ...") os.Exit(2) } mode := os.Args[1] @@ -90,6 +127,13 @@ func main() { } fmt.Println(crocmobile.RelayForCode(os.Args[2])) return + case "store-server": + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "store-server needs DIR") + os.Exit(2) + } + serveStore(os.Args[2]) + return case "relays": for _, r := range publicrelay.Relays() { fmt.Println(r) @@ -97,6 +141,9 @@ func main() { return } fs := flag.NewFlagSet(mode, flag.ExitOnError) + server := fs.String("server", "https://getcroc.com", "stored-transfer server (store-send)") + downloads := fs.Int("downloads", 1, "downloads allowed (store-send)") + lifetime := fs.Int("lifetime", 3600, "storage lifetime in seconds (store-send)") relay := fs.String("relay", "", "relay address override") noLocal := fs.Bool("no-local", false, "disable LAN discovery") onlyLocal := fs.Bool("only-local", false, "LAN only") @@ -144,9 +191,29 @@ func main() { opts.Transport = *transport var tr *crocmobile.Transfer - d := &printDelegate{answer: *answer, policy: *policy, pool: *relay == "" && !*onlyLocal, transfer: &tr} + d := &printDelegate{answer: *answer, policy: *policy, pool: *relay == "" && !*onlyLocal, transfer: &tr, ready: make(chan struct{})} var err error switch mode { + case "store-send": + tr, err = crocmobile.StartStoreSend(strings.Join(fs.Args(), "\n"), + &crocmobile.StoreOptions{Server: *server, Downloads: *downloads, LifetimeSeconds: *lifetime}, d) + case "store-receive": + if fs.NArg() != 1 { + fmt.Fprintln(os.Stderr, "store-receive needs LINK") + os.Exit(2) + } + tr, err = crocmobile.StartStoreReceive(fs.Arg(0), *out, d) + case "store-revoke": + if fs.NArg() != 3 { + fmt.Fprintln(os.Stderr, "store-revoke needs ORIGIN ID TOKEN") + os.Exit(2) + } + if err := crocmobile.RevokeStored(fs.Arg(0), fs.Arg(1), fs.Arg(2)); err != nil { + fmt.Fprintf(os.Stderr, "EVENT error %s\n", err) + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "EVENT revoked") + return case "send": tr, err = crocmobile.StartSend(strings.Join(fs.Args(), "\n"), *text, opts, d) case "receive": @@ -162,6 +229,7 @@ func main() { fmt.Fprintln(os.Stderr, "unknown mode", mode) os.Exit(2) } + close(d.ready) if err != nil { fmt.Fprintf(os.Stderr, "EVENT error %s\n", err) os.Exit(1) diff --git a/crocmobile/crocmobile.go b/crocmobile/crocmobile.go index 2988aeb..ccd540a 100644 --- a/crocmobile/crocmobile.go +++ b/crocmobile/crocmobile.go @@ -86,6 +86,8 @@ type Delegate interface { OnText(text string) OnDone(summaryJSON string) OnError(message string) + // OnStored delivers a finished stored upload's receipt, just before OnDone. + OnStored(receiptJSON string) } // Transfer is a handle to one active transfer. diff --git a/crocmobile/errors.go b/crocmobile/errors.go index 9bed4e1..27628bf 100644 --- a/crocmobile/errors.go +++ b/crocmobile/errors.go @@ -5,10 +5,12 @@ import ( "encoding/json" "errors" "io" + "net/http" "strings" "syscall" "github.com/schollz/croc/v11/src/croc" + "github.com/schollz/croc/v11/src/storeclient" "github.com/schollz/croc/v11/src/tcp" ) @@ -27,6 +29,8 @@ const ( codeWrongCode = "wrongCode" // both peers met but the code phrases differ (PAKE failed) codeCancelled = "cancelled" // this side cancelled codeConnectionLost = "connectionLost" // the connection dropped mid-transfer + codeStoreLimit = "storeLimit" // stored transfer: the server's size, file-count, download, quota or rate limit + codeStoreExpired = "storeExpired" // stored transfer: the server no longer has it (expired, revoked, used up) codeOther = "other" ) @@ -72,6 +76,12 @@ func classify(err error) string { if errors.As(err, &coded) { return coded.code } + var httpErr *storeclient.HTTPError + if errors.As(err, &httpErr) { + if code := storeHTTPCode(httpErr); code != "" { + return code + } + } m := strings.ToLower(err.Error()) switch { case strings.Contains(m, "unsupported pake protocol version"): @@ -114,3 +124,21 @@ func classify(err error) string { } return codeOther } + +// storeHTTPCode maps a stored-transfer server's refusal to a failure code, or +// "" for statuses with no code of their own. The server answers 400 "invalid +// stored-transfer declaration" for a size, file count, download count or +// lifetime outside its configuration, the same refusal as 413 and 507. +func storeHTTPCode(e *storeclient.HTTPError) string { + switch e.StatusCode { + case http.StatusNotFound, http.StatusGone: + return codeStoreExpired + case http.StatusRequestEntityTooLarge, http.StatusTooManyRequests, http.StatusInsufficientStorage: + return codeStoreLimit + case http.StatusBadRequest: + if strings.Contains(strings.ToLower(e.Message), "declaration") { + return codeStoreLimit + } + } + return "" +} diff --git a/crocmobile/session.go b/crocmobile/session.go index 7c7f973..8304cfb 100644 --- a/crocmobile/session.go +++ b/crocmobile/session.go @@ -49,6 +49,8 @@ type session struct { done chan struct{} pollDone chan struct{} // closed when poll() returns; run() waits on it before the terminal delegate calls + + storeAnswer chan storeReply // stored receive only: carries Respond to the goroutine waiting at the preview } func buildCrocOptions(sender bool, secret string, o *Options) (croc.Options, error) { @@ -509,6 +511,10 @@ func (s *session) cancel() { } func (s *session) respond(accept bool, policy filePolicy) { + if s.storeAnswer != nil { + s.answerStore(accept, policy) + return + } s.promptM.Lock() defer s.promptM.Unlock() if s.promptW == nil { diff --git a/crocmobile/session_test.go b/crocmobile/session_test.go index c3450e1..c6abff7 100644 --- a/crocmobile/session_test.go +++ b/crocmobile/session_test.go @@ -678,6 +678,7 @@ func (d *errorDelegate) OnProgress(string) {} func (d *errorDelegate) OnText(string) {} func (d *errorDelegate) OnDone(string) {} func (d *errorDelegate) OnError(m string) { d.errors = append(d.errors, m) } +func (d *errorDelegate) OnStored(string) {} func mkSendFolder(t *testing.T, dir string) string { t.Helper() diff --git a/crocmobile/store.go b/crocmobile/store.go new file mode 100644 index 0000000..b8f72d0 --- /dev/null +++ b/crocmobile/store.go @@ -0,0 +1,388 @@ +package crocmobile + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime/debug" + "strings" + "time" + + "github.com/schollz/croc/v11/src/receivefs" + "github.com/schollz/croc/v11/src/storeclient" + "github.com/schollz/croc/v11/src/storecrypto" +) + +// Limits a stored upload checks before it hashes or uploads anything. They +// mirror croc's store defaults (store.DefaultMaxTransfer, storecrypto.MaxFiles), +// so a server configured with a lower cap still refuses at upload time with +// codeStoreLimit. +const ( + storeMaxBytes int64 = 1 << 30 + storeMaxFiles = storecrypto.MaxFiles + storeMaxDownloads = 100 + storeRevokeWait = 30 * time.Second +) + +// StoreOptions configures StartStoreSend. +type StoreOptions struct { + Server string // stored-transfer origin; https, or http on loopback + Downloads int // verified downloads allowed, 1 to 100; 0 means 1 + LifetimeSeconds int // storage lifetime; 0 means one day, the server may cap it +} + +// storedReceipt is the OnStored payload: everything the sender needs to share +// the transfer (link) and to take it back (origin, id, uploadToken). link +// carries the decryption key in its fragment, so it is a secret. +type storedReceipt struct { + ID string `json:"id"` + Origin string `json:"origin"` + UploadToken string `json:"uploadToken"` + Link string `json:"link"` + ExpiresAt string `json:"expiresAt"` // RFC 3339, UTC, whole seconds + Downloads int `json:"downloads"` +} + +// storeReply is what Respond hands a stored receive that waits at its preview. +type storeReply struct { + accept bool + policy filePolicy +} + +// validateStoreOrigin applies the https rule (loopback exempt) to a server +// origin the way storecrypto applies it to a link, without a real share. +func validateStoreOrigin(origin string) error { + probe := storecrypto.Share{ + Origin: origin, + ID: storecrypto.EncodeBase64URL(make([]byte, storecrypto.TransferIDLen)), + MasterKey: make([]byte, storecrypto.KeySize), + } + _, err := probe.BrowserURL() + return err +} + +// checkStoreLimits refuses what the server would refuse after the upload has +// been hashed: too many files or too many bytes. Anything else about the paths +// (folders, symlinks, duplicate names) is left to storeclient. +func checkStoreLimits(paths []string) error { + if len(paths) == 0 { + return errors.New("nothing to send") + } + if len(paths) > storeMaxFiles { + return &codedError{codeStoreLimit, fmt.Errorf("a stored transfer holds at most %d files", storeMaxFiles)} + } + var total int64 + for _, p := range paths { + info, err := os.Lstat(p) + if err != nil { + return err + } + if info.Mode().IsRegular() { + total += info.Size() + } + } + if total > storeMaxBytes { + return &codedError{codeStoreLimit, fmt.Errorf("a stored transfer holds at most %d bytes", storeMaxBytes)} + } + return nil +} + +// StartStoreSend uploads the newline-joined files to a stored-transfer server +// and reports the receipt through OnStored, then OnDone. The file contents are +// encrypted on this device; the server never sees names or keys. Shares +// activeMu with live transfers: one transfer at a time. +func StartStoreSend(pathsJoined string, o *StoreOptions, d Delegate) (t *Transfer, err error) { + defer func() { + if r := recover(); r != nil { + t, err = nil, startError(fmt.Errorf("croc panic: %v\n%s", r, debug.Stack())) + } + }() + if o == nil { + return nil, startError(errors.New("store options required")) + } + if d == nil { + return nil, startError(errors.New("delegate required")) + } + paths := splitNonEmpty(pathsJoined, "\n") + if err := checkStoreLimits(paths); err != nil { + return nil, startError(err) + } + downloads := o.Downloads + if downloads == 0 { + downloads = 1 + } + if downloads < 1 || downloads > storeMaxDownloads { + return nil, startError(&codedError{codeStoreLimit, fmt.Errorf("downloads must be between 1 and %d", storeMaxDownloads)}) + } + if o.LifetimeSeconds < 0 { + return nil, startError(errors.New("lifetime cannot be negative")) + } + if err := validateStoreOrigin(o.Server); err != nil { + return nil, startError(err) + } + server, lifetime := o.Server, time.Duration(o.LifetimeSeconds)*time.Second + t, err = startStoreSession(d, func(ctx context.Context, s *session) (storeSummary, error) { + result, err := new(storeclient.Client).UploadWithOptions(ctx, server, paths, + storeclient.UploadOptions{Downloads: downloads, Expiration: lifetime}, s.storeCallbacks()) + if err != nil { + return storeSummary{}, err + } + link, err := result.Share.BrowserURL() + if err != nil { + return storeSummary{}, err + } + b, err := json.Marshal(storedReceipt{ + ID: result.Share.ID, Origin: result.Share.Origin, UploadToken: result.UploadToken, Link: link, + ExpiresAt: result.ExpiresAt.UTC().Truncate(time.Second).Format(time.RFC3339), Downloads: result.Downloads, + }) + if err != nil { + return storeSummary{}, err + } + s.delegate.OnStored(string(b)) + return storeSummary{files: len(paths), totalSize: sizeOfFiles(paths)}, nil + }) + if err != nil { + return nil, startError(err) + } + return t, nil +} + +// StartStoreReceive opens a stored-transfer link: it reads the manifest +// without claiming the transfer, reports it through OnFileList, and waits for +// Respond. On accept it downloads into outDir, applying Respond's policy to +// names that already exist, then reports OnDone. Decline ends it with the +// "refused" failure and leaves the transfer unclaimed. +func StartStoreReceive(link, outDir string, d Delegate) (t *Transfer, err error) { + defer func() { + if r := recover(); r != nil { + t, err = nil, startError(fmt.Errorf("croc panic: %v\n%s", r, debug.Stack())) + } + }() + if d == nil { + return nil, startError(errors.New("delegate required")) + } + if outDir == "" { + return nil, startError(errors.New("output directory required")) + } + share, err := storecrypto.ParseShare(link) + if err != nil { + return nil, startError(err) + } + t, err = startStoreSession(d, func(ctx context.Context, s *session) (storeSummary, error) { + client := new(storeclient.Client) + manifest, _, err := client.Inspect(ctx, share) + if err != nil { + return storeSummary{}, err + } + if err = storecrypto.ValidateManifest(manifest, 1<<40); err != nil { + return storeSummary{}, err + } + s.delegate.OnFileList(storeFileListJSON(manifest)) + var answer storeReply + select { + case answer = <-s.storeAnswer: + case <-ctx.Done(): + return storeSummary{}, ctx.Err() + } + if !answer.accept { + return storeSummary{}, &codedError{codeRefused, errors.New("refused files")} + } + if answer.policy == keepBoth { + keepBothNames(outDir, manifest.Files) + } + if err = client.Receive(ctx, share, manifest, outDir, s.storeCallbacks()); err != nil { + return storeSummary{}, err + } + return storeSummary{files: len(manifest.Files), totalSize: manifestTotal(manifest)}, nil + }) + if err != nil { + return nil, startError(err) + } + return t, nil +} + +// RevokeStored deletes a stored transfer from its server using the sender's +// receipt. A transfer the server no longer has (404, 410) counts as revoked. +// Blocking; it does not take the transfer lock, so a History row can revoke +// while another transfer runs. +func RevokeStored(origin, id, token string) (err error) { + defer func() { + if r := recover(); r != nil { + err = startError(fmt.Errorf("croc panic: %v\n%s", r, debug.Stack())) + } + }() + // Revoke authenticates with the token alone, but storeclient validates the + // whole share first, so a zero key stands in for the one the receipt keeps + // in its link. + share := storecrypto.Share{Origin: origin, ID: id, MasterKey: make([]byte, storecrypto.KeySize)} + ctx, cancel := context.WithTimeout(context.Background(), storeRevokeWait) + defer cancel() + err = new(storeclient.Client).Revoke(ctx, share, token) + var httpErr *storeclient.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusGone) { + return nil + } + return startError(err) +} + +type storeSummary struct { + files int + totalSize int64 +} + +// startStoreSession takes the process-global lock and runs work on a new +// goroutine, which releases the lock after the terminal delegate call. Unlike +// startSession it touches no cwd, stdin or stdout: storeclient takes explicit +// paths. +func startStoreSession(d Delegate, work func(context.Context, *session) (storeSummary, error)) (*Transfer, error) { + if !activeMu.TryLock() { + return nil, errors.New("another transfer is active") + } + ctx, cancel := context.WithCancel(context.Background()) + s := &session{ + ctxCancel: cancel, + delegate: d, + done: make(chan struct{}), + pollDone: make(chan struct{}), + storeAnswer: make(chan storeReply, 1), + } + close(s.pollDone) // no poll goroutine + go s.runStore(ctx, work) + return &Transfer{s: s}, nil +} + +// runStore is run's counterpart for stored transfers: the same release and +// recover ordering, so the lock outlives the terminal event and a panicking +// delegate cannot leak it. +func (s *session) runStore(ctx context.Context, work func(context.Context, *session) (storeSummary, error)) { + defer activeMu.Unlock() + defer s.ctxCancel() + defer func() { + if r := recover(); r != nil { + reportPanic(s, r) + } + }() + var ( + sum storeSummary + err error + ) + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("croc panic: %v\n%s", r, debug.Stack()) + } + }() + sum, err = work(ctx, s) + }() + close(s.done) + if err != nil { + s.delegate.OnError(errorJSON(err)) + return + } + b, _ := json.Marshal(map[string]any{"success": true, "files": sum.files, "totalSize": sum.totalSize}) + s.delegate.OnDone(string(b)) +} + +// answerStore hands a Respond to the stored receive waiting at its preview. +// Only the first answer counts; the channel holds one, so a second never blocks. +func (s *session) answerStore(accept bool, policy filePolicy) { + select { + case s.storeAnswer <- storeReply{accept: accept, policy: policy}: + default: + } +} + +// storeCallbacks turns storeclient's callbacks into OnProgress events. They run +// on storeclient's worker goroutines, where a delegate panic would take the +// process down, so each is recovered. +func (s *session) storeCallbacks() storeclient.Callbacks { + return storeclient.Callbacks{ + Status: func(msg string) { + if strings.HasPrefix(msg, "Hashing ") { + s.emitProgress(progressState{Step: "preparing"}) + } + }, + Progress: func(p storeclient.Progress) { s.emitProgress(storeProgress(p)) }, + } +} + +func (s *session) emitProgress(p progressState) { + defer func() { recover() }() //nolint:errcheck // a delegate panic must not escape into storeclient's goroutines + b, _ := json.Marshal(p) + s.delegate.OnProgress(string(b)) +} + +// storeProgress maps storeclient's cumulative byte counts onto the progress +// payload, clamped so a count past its total cannot show more than 100%. +func storeProgress(p storeclient.Progress) progressState { + fileSent := min(max(p.FileBytes, 0), p.FileSize) + finished := min(max(p.TotalBytes-p.FileBytes, 0), p.TotalSize) + return progressState{ + CurrentFile: p.FileIndex, TotalFiles: p.FileCount, FileName: p.FileName, + FileSent: fileSent, FileSize: p.FileSize, BytesFinished: finished, + TotalSize: p.TotalSize, Step: "transferring", + } +} + +func storeFileListJSON(m storecrypto.Manifest) string { + files := make([]fileEntry, len(m.Files)) + for i, f := range m.Files { + files[i] = fileEntry{Name: f.Name, Size: f.Size} + } + b, _ := json.Marshal(map[string]any{"files": files, "emptyFolders": 0, "totalSize": manifestTotal(m)}) + return string(b) +} + +func manifestTotal(m storecrypto.Manifest) int64 { + var total int64 + for _, f := range m.Files { + total += f.Size + } + return total +} + +func sizeOfFiles(paths []string) int64 { + var total int64 + for _, p := range paths { + if info, err := os.Lstat(p); err == nil { + total += info.Size() + } + } + return total +} + +// keepBothNames renames, in place, each manifest file whose name already exists +// in dir to croc's Keep Both form ("name (1).ext"). storeclient installs with a +// rename that replaces whatever is there, so Keep Both has to be settled in the +// manifest before the download. The names are local only: the server and the +// chunk encryption never see them. +func keepBothNames(dir string, files []storecrypto.ManifestFile) { + taken := make(map[string]bool, len(files)) + for _, f := range files { + taken[receivefs.CollisionKey(f.Name)] = true + } + exists := func(name string) bool { + _, err := os.Lstat(filepath.Join(dir, name)) + return err == nil + } + for i := range files { + name := files[i].Name + if !exists(name) { + continue + } + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + for n := 1; ; n++ { + candidate := fmt.Sprintf("%s (%d)%s", base, n, ext) + if !exists(candidate) && !taken[receivefs.CollisionKey(candidate)] { + files[i].Name = candidate + taken[receivefs.CollisionKey(candidate)] = true + break + } + } + } +} diff --git a/crocmobile/store_test.go b/crocmobile/store_test.go new file mode 100644 index 0000000..c3868f1 --- /dev/null +++ b/crocmobile/store_test.go @@ -0,0 +1,530 @@ +package crocmobile + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/schollz/croc/v11/src/store" + "github.com/schollz/croc/v11/src/storeclient" + "github.com/schollz/croc/v11/src/storecrypto" +) + +// storeRecorder collects a stored transfer's delegate calls. wait returns once +// the terminal one (OnDone or OnError) has arrived. +type storeRecorder struct { + mu sync.Mutex + fileList string + receipt string + done string + errMsg string + progress []string + finished chan struct{} + fileReady chan struct{} + onceFile sync.Once + onceEnd sync.Once +} + +func newStoreRecorder() *storeRecorder { + return &storeRecorder{finished: make(chan struct{}), fileReady: make(chan struct{})} +} + +func (r *storeRecorder) OnCodeReady(string) {} +func (r *storeRecorder) OnConnected() {} +func (r *storeRecorder) OnText(string) {} +func (r *storeRecorder) OnFileList(j string) { + r.mu.Lock() + r.fileList = j + r.mu.Unlock() + r.onceFile.Do(func() { close(r.fileReady) }) +} +func (r *storeRecorder) OnProgress(j string) { + r.mu.Lock() + r.progress = append(r.progress, j) + r.mu.Unlock() +} +func (r *storeRecorder) OnStored(j string) { + r.mu.Lock() + r.receipt = j + r.mu.Unlock() +} +func (r *storeRecorder) OnDone(j string) { + r.mu.Lock() + r.done = j + r.mu.Unlock() + r.onceEnd.Do(func() { close(r.finished) }) +} +func (r *storeRecorder) OnError(m string) { + r.mu.Lock() + r.errMsg = m + r.mu.Unlock() + r.onceEnd.Do(func() { close(r.finished) }) +} + +func (r *storeRecorder) wait(t *testing.T) { + t.Helper() + select { + case <-r.finished: + case <-time.After(30 * time.Second): + t.Fatal("stored transfer did not finish") + } + // run releases activeMu after the terminal call; wait for it so the next + // start in the same test cannot see "another transfer is active". + for i := 0; i < 200 && !activeMu.TryLock(); i++ { + time.Sleep(10 * time.Millisecond) + } + activeMu.Unlock() +} + +func (r *storeRecorder) failureCode(t *testing.T) string { + t.Helper() + var f failure + if err := json.Unmarshal([]byte(r.errMsg), &f); err != nil { + t.Fatalf("OnError payload %q is not JSON: %v", r.errMsg, err) + } + return f.Code +} + +func startTestStore(t *testing.T, cfg store.Config) string { + t.Helper() + cfg.Root = t.TempDir() + cfg.MaxTransferBytes = 8 << 20 + cfg.MaxTotalBytes = 32 << 20 + cfg.MinFreeBytes = 1 + cfg.CreatePerHour = 100 + cfg.MaxActiveUploads = 10 + cfg.DisableRootLock = true + if cfg.MaxDownloads == 0 { + cfg.MaxDownloads = 10 + } + svc, err := store.New(cfg) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(svc) + t.Cleanup(func() { + srv.Close() + _ = svc.Close() + }) + return srv.URL +} + +func writeFiles(t *testing.T, dir string, files map[string]string) []string { + t.Helper() + var paths []string + for name, body := range files { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + paths = append(paths, p) + } + return paths +} + +func storeSend(t *testing.T, server string, o StoreOptions, paths []string) (*storeRecorder, storedReceipt) { + t.Helper() + o.Server = server + rec := newStoreRecorder() + if _, err := StartStoreSend(strings.Join(paths, "\n"), &o, rec); err != nil { + t.Fatalf("StartStoreSend: %v", err) + } + rec.wait(t) + var r storedReceipt + if rec.errMsg == "" { + if err := json.Unmarshal([]byte(rec.receipt), &r); err != nil { + t.Fatalf("receipt %q: %v", rec.receipt, err) + } + } + return rec, r +} + +func storeReceive(t *testing.T, link, out string, accept bool, policy string) *storeRecorder { + t.Helper() + rec := newStoreRecorder() + tr, err := StartStoreReceive(link, out, rec) + if err != nil { + t.Fatalf("StartStoreReceive: %v", err) + } + select { + case <-rec.fileReady: + case <-rec.finished: + rec.wait(t) + return rec + case <-time.After(30 * time.Second): + t.Fatal("no file list") + } + tr.Respond(accept, policy) + rec.wait(t) + return rec +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestStoreSendReceiveRevoke(t *testing.T) { + server := startTestStore(t, store.Config{}) + src := t.TempDir() + paths := writeFiles(t, src, map[string]string{"a.txt": "alpha", "b.txt": "bravo"}) + + rec, receipt := storeSend(t, server, StoreOptions{Downloads: 2, LifetimeSeconds: 3600}, paths) + if rec.errMsg != "" { + t.Fatalf("send failed: %s", rec.errMsg) + } + if receipt.Origin != server || receipt.ID == "" || receipt.UploadToken == "" || receipt.Downloads != 2 { + t.Fatalf("receipt = %+v", receipt) + } + if !strings.HasPrefix(receipt.Link, server+"/s/"+receipt.ID+"#v1.") { + t.Fatalf("link = %q", receipt.Link) + } + exp, err := time.Parse(time.RFC3339, receipt.ExpiresAt) + if err != nil || time.Until(exp) < 50*time.Minute || time.Until(exp) > 70*time.Minute { + t.Fatalf("expiresAt = %q (%v); want about one hour out", receipt.ExpiresAt, err) + } + var sum map[string]any + if err := json.Unmarshal([]byte(rec.done), &sum); err != nil || sum["success"] != true || sum["files"] != float64(2) || sum["totalSize"] != float64(10) { + t.Fatalf("done = %q (%v)", rec.done, err) + } + if len(rec.progress) == 0 { + t.Fatal("no OnProgress events") + } + + out := t.TempDir() + got := storeReceive(t, receipt.Link, out, true, PolicyKeepBoth) + if got.errMsg != "" { + t.Fatalf("receive failed: %s", got.errMsg) + } + if readFile(t, filepath.Join(out, "a.txt")) != "alpha" || readFile(t, filepath.Join(out, "b.txt")) != "bravo" { + t.Fatal("received content differs") + } + var list struct { + Files []fileEntry `json:"files"` + Total int64 `json:"totalSize"` + } + if err := json.Unmarshal([]byte(got.fileList), &list); err != nil || len(list.Files) != 2 || list.Total != 10 { + t.Fatalf("file list = %q (%v)", got.fileList, err) + } + + if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken); err != nil { + t.Fatalf("RevokeStored: %v", err) + } + gone := storeReceive(t, receipt.Link, t.TempDir(), true, PolicyKeepBoth) + if c := gone.failureCode(t); c != codeStoreExpired { + t.Fatalf("receive after revoke: code %q (%s), want %q", c, gone.errMsg, codeStoreExpired) + } +} + +func TestStoreRevokeTreatsMissingAsSuccess(t *testing.T) { + server := startTestStore(t, store.Config{}) + src := t.TempDir() + _, receipt := storeSend(t, server, StoreOptions{}, writeFiles(t, src, map[string]string{"a.txt": "x"})) + if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken); err != nil { + t.Fatal(err) + } + if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken); err != nil { + t.Fatalf("second revoke: %v", err) + } +} + +func TestStoreRevokeFailureIsCoded(t *testing.T) { + err := RevokeStored("http://127.0.0.1:1", strings.Repeat("A", 22), "token") + if err == nil { + t.Fatal("revoke against a closed port succeeded") + } + var f failure + if json.Unmarshal([]byte(err.Error()), &f) != nil || f.Code == "" { + t.Fatalf("error %q is not a failure payload", err) + } +} + +func TestStoreReceiveKeepBothAndReplace(t *testing.T) { + server := startTestStore(t, store.Config{}) + src := t.TempDir() + _, receipt := storeSend(t, server, StoreOptions{Downloads: 5}, + writeFiles(t, src, map[string]string{"a.txt": "new-a", "a (1).txt": "new-a1", "b.txt": "new-b"})) + + out := t.TempDir() + writeFiles(t, out, map[string]string{"a.txt": "old-a"}) + + if rec := storeReceive(t, receipt.Link, out, true, PolicyKeepBoth); rec.errMsg != "" { + t.Fatalf("keep both: %s", rec.errMsg) + } + if got := readFile(t, filepath.Join(out, "a.txt")); got != "old-a" { + t.Fatalf("a.txt = %q; Keep Both must leave it alone", got) + } + // "a (1).txt" is also a member, so the colliding "a.txt" must skip to (2). + if got := readFile(t, filepath.Join(out, "a (1).txt")); got != "new-a1" { + t.Fatalf("a (1).txt = %q; the member of that name must keep it", got) + } + if got := readFile(t, filepath.Join(out, "a (2).txt")); got != "new-a" { + t.Fatalf("a (2).txt = %q, want the renamed copy", got) + } + if got := readFile(t, filepath.Join(out, "b.txt")); got != "new-b" { + t.Fatalf("b.txt = %q", got) + } + + out2 := t.TempDir() + writeFiles(t, out2, map[string]string{"a.txt": "old-a", "b.txt": "old-b"}) + if rec := storeReceive(t, receipt.Link, out2, true, PolicyReplace); rec.errMsg != "" { + t.Fatalf("replace: %s", rec.errMsg) + } + if readFile(t, filepath.Join(out2, "a.txt")) != "new-a" || readFile(t, filepath.Join(out2, "b.txt")) != "new-b" { + t.Fatal("Replace did not overwrite") + } + if _, err := os.Stat(filepath.Join(out2, "a (2).txt")); !os.IsNotExist(err) { + t.Fatal("Replace left a renamed copy") + } +} + +func TestStoreReceiveDeclineLeavesTransferUnclaimed(t *testing.T) { + server := startTestStore(t, store.Config{}) + src := t.TempDir() + _, receipt := storeSend(t, server, StoreOptions{}, writeFiles(t, src, map[string]string{"a.txt": "x"})) + + out := t.TempDir() + rec := storeReceive(t, receipt.Link, out, false, PolicyKeepBoth) + if c := rec.failureCode(t); c != codeRefused { + t.Fatalf("code = %q, want %q", c, codeRefused) + } + if entries, _ := os.ReadDir(out); len(entries) != 0 { + t.Fatalf("decline wrote %d entries", len(entries)) + } + // Unclaimed means the one download is still there. + if rec := storeReceive(t, receipt.Link, out, true, PolicyKeepBoth); rec.errMsg != "" { + t.Fatalf("receive after decline: %s", rec.errMsg) + } +} + +func TestStoreReceiveCancelWhileWaiting(t *testing.T) { + server := startTestStore(t, store.Config{}) + src := t.TempDir() + _, receipt := storeSend(t, server, StoreOptions{}, writeFiles(t, src, map[string]string{"a.txt": "x"})) + + rec := newStoreRecorder() + tr, err := StartStoreReceive(receipt.Link, t.TempDir(), rec) + if err != nil { + t.Fatal(err) + } + <-rec.fileReady + tr.Cancel() + rec.wait(t) + if c := rec.failureCode(t); c != codeCancelled { + t.Fatalf("code = %q, want %q", c, codeCancelled) + } +} + +func TestStoreSendCancel(t *testing.T) { + server := startTestStore(t, store.Config{}) + // 7 MiB is two chunks, so there is an upload to interrupt. + src := t.TempDir() + big := filepath.Join(src, "big.bin") + if err := os.WriteFile(big, bytes.Repeat([]byte{7}, 7<<20), 0o644); err != nil { + t.Fatal(err) + } + rec := newStoreRecorder() + tr, err := StartStoreSend(big, &StoreOptions{Server: server}, rec) + if err != nil { + t.Fatal(err) + } + tr.Cancel() + rec.wait(t) + if rec.receipt != "" { + t.Fatal("cancelled upload produced a receipt") + } + if c := rec.failureCode(t); c != codeCancelled { + t.Fatalf("code = %q (%s), want %q", c, rec.errMsg, codeCancelled) + } +} + +func TestStoreSendLimitsFailBeforeUpload(t *testing.T) { + src := t.TempDir() + one := writeFiles(t, src, map[string]string{"a.txt": "x"})[0] + cases := []struct { + name string + paths string + opts StoreOptions + code string + }{ + {"too many files", strings.Repeat(one+"\n", storeMaxFiles+1), StoreOptions{Server: "https://example.com"}, codeStoreLimit}, + {"downloads over 100", one, StoreOptions{Server: "https://example.com", Downloads: 101}, codeStoreLimit}, + {"negative downloads", one, StoreOptions{Server: "https://example.com", Downloads: -1}, codeStoreLimit}, + {"plain http server", one, StoreOptions{Server: "http://example.com"}, codeOther}, + {"missing file", filepath.Join(src, "nope"), StoreOptions{Server: "https://example.com"}, codeOther}, + {"nothing to send", " \n", StoreOptions{Server: "https://example.com"}, codeOther}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := StartStoreSend(c.paths, &c.opts, newStoreRecorder()) + if err == nil { + t.Fatal("start succeeded") + } + var f failure + if json.Unmarshal([]byte(err.Error()), &f) != nil || f.Code != c.code { + t.Fatalf("error %q, want code %q", err, c.code) + } + }) + } +} + +func TestStoreSendOversizeFailsBeforeHashing(t *testing.T) { + src := t.TempDir() + big := filepath.Join(src, "big.bin") + f, err := os.Create(big) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(storeMaxBytes + 1); err != nil { // sparse: no disk used + t.Fatal(err) + } + f.Close() + _, err = StartStoreSend(big, &StoreOptions{Server: "https://example.com"}, newStoreRecorder()) + var fl failure + if err == nil || json.Unmarshal([]byte(err.Error()), &fl) != nil || fl.Code != codeStoreLimit { + t.Fatalf("err = %v, want code %q", err, codeStoreLimit) + } +} + +func TestStoreSendServerLimitIsCoded(t *testing.T) { + server := startTestStore(t, store.Config{MaxDownloads: 1}) + src := t.TempDir() + rec, _ := storeSend(t, server, StoreOptions{Downloads: 5}, writeFiles(t, src, map[string]string{"a.txt": "x"})) + if c := rec.failureCode(t); c != codeStoreLimit { + t.Fatalf("code = %q (%s), want %q", c, rec.errMsg, codeStoreLimit) + } +} + +func TestStoreReceiveRejectsBadLinks(t *testing.T) { + for _, link := range []string{ + "", + "not a link", + "http://example.com/s/AAAAAAAAAAAAAAAAAAAAAA#v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "https://example.com/s/short#v1.key", + } { + if _, err := StartStoreReceive(link, t.TempDir(), newStoreRecorder()); err == nil { + t.Errorf("link %q accepted", link) + } + } + if _, err := StartStoreReceive("https://example.com/s/x", "", newStoreRecorder()); err == nil { + t.Error("empty output directory accepted") + } +} + +func TestStoreHoldsTheTransferLock(t *testing.T) { + server := startTestStore(t, store.Config{}) + src := t.TempDir() + _, receipt := storeSend(t, server, StoreOptions{}, writeFiles(t, src, map[string]string{"a.txt": "x"})) + + rec := newStoreRecorder() + tr, err := StartStoreReceive(receipt.Link, t.TempDir(), rec) + if err != nil { + t.Fatal(err) + } + <-rec.fileReady + if _, err := StartStoreSend(writeFiles(t, src, map[string]string{"b.txt": "y"})[0], &StoreOptions{Server: server}, newStoreRecorder()); err == nil || + !strings.Contains(err.Error(), "another transfer is active") { + t.Fatalf("second start while one waits: err = %v", err) + } + tr.Cancel() + rec.wait(t) +} + +func TestClassifyStoreHTTP(t *testing.T) { + cases := []struct { + status int + msg string + want string + }{ + {http.StatusNotFound, "", codeStoreExpired}, + {http.StatusGone, "stored transfer is no longer available", codeStoreExpired}, + {http.StatusRequestEntityTooLarge, "stored transfer exceeds the configured limit", codeStoreLimit}, + {http.StatusTooManyRequests, "stored-transfer creation rate exceeded", codeStoreLimit}, + {http.StatusInsufficientStorage, "stored-transfer quota is full", codeStoreLimit}, + {http.StatusBadRequest, "invalid stored-transfer declaration", codeStoreLimit}, + {http.StatusBadRequest, "invalid stored-transfer request", codeOther}, + {http.StatusLocked, "stored transfer is already claimed", codeOther}, + } + for _, c := range cases { + err := &storeclient.HTTPError{StatusCode: c.status, Message: c.msg} + if got := classify(err); got != c.want { + t.Errorf("classify(%d %q) = %q, want %q", c.status, c.msg, got, c.want) + } + // Wrapped, as storeclient does with %w. + if got := classify(errors.Join(errors.New("upload"), err)); got != c.want { + t.Errorf("classify(wrapped %d) = %q, want %q", c.status, got, c.want) + } + } +} + +func TestStoreProgressBounds(t *testing.T) { + cases := []struct { + name string + in storeclient.Progress + want progressState + }{ + {"mid file", storeclient.Progress{FileIndex: 1, FileCount: 3, FileName: "b", FileBytes: 40, FileSize: 100, TotalBytes: 140, TotalSize: 300}, + progressState{CurrentFile: 1, TotalFiles: 3, FileName: "b", FileSent: 40, FileSize: 100, BytesFinished: 100, TotalSize: 300, Step: "transferring"}}, + {"counts past totals", storeclient.Progress{FileBytes: 150, FileSize: 100, TotalBytes: 900, TotalSize: 300}, + progressState{FileSent: 100, FileSize: 100, BytesFinished: 300, TotalSize: 300, Step: "transferring"}}, + {"negative", storeclient.Progress{FileBytes: -5, FileSize: 100, TotalBytes: -9, TotalSize: 300}, + progressState{FileSize: 100, TotalSize: 300, Step: "transferring"}}, + } + for _, c := range cases { + if got := storeProgress(c.in); got != c.want { + t.Errorf("%s: got %+v, want %+v", c.name, got, c.want) + } + } +} + +func TestKeepBothNames(t *testing.T) { + dir := t.TempDir() + writeFiles(t, dir, map[string]string{"a.txt": "", "A (1).txt": "", "noext": ""}) + files := []storecrypto.ManifestFile{{Name: "a.txt"}, {Name: "noext"}, {Name: "a (2).txt"}, {Name: "fresh.txt"}} + keepBothNames(dir, files) + var got []string + for _, f := range files { + got = append(got, f.Name) + } + // "a (1).txt" exists case-insensitively on macOS but not on Linux; either + // way the renamed copy must not equal any other member or existing file. + seen := map[string]bool{} + for i, name := range got { + if seen[strings.ToLower(name)] { + t.Fatalf("duplicate name %q in %v", name, got) + } + seen[strings.ToLower(name)] = true + if i != 3 && name == files[i].Name && (name == "a.txt" || name == "noext") { + t.Fatalf("colliding %q kept its name", name) + } + } + if got[3] != "fresh.txt" || got[2] != "a (2).txt" { + t.Fatalf("non-colliding names changed: %v", got) + } + if !strings.HasPrefix(got[1], "noext (") || strings.Contains(got[1], ".") { + t.Fatalf("extensionless rename = %q", got[1]) + } +} + +func TestStoreCallbacksSurviveAPanickingDelegate(t *testing.T) { + s := &session{delegate: &panicProgressDelegate{}} + cb := s.storeCallbacks() + cb.Progress(storeclient.Progress{}) + cb.Status("Hashing 1/1: a") +} + +type panicProgressDelegate struct{ errorDelegate } + +func (*panicProgressDelegate) OnProgress(string) { panic("delegate boom") } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cfcffe2..1f0ac61 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,7 +45,7 @@ SwiftUI views (app/CrocApp/Views/) 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, policy string)`, policy `PolicyKeepBoth` or `PolicyReplace`). `OnError` receives a JSON `{"code","message"}` payload, and `StartSend`/`StartReceive` return the same JSON as their error text (gobind hands Swift only the text); `errors.go` classifies croc and package errors into codes (`peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `other`) from croc's sentinels, this package's `codedError`, and croc v11.5.4 error text, so the app never reads croc's wording. +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` (8-method callback interface: `OnCodeReady`, `OnConnected`, `OnFileList`, `OnProgress`, `OnText`, `OnDone`, `OnError`, `OnStored`), `Transfer` (`Cancel()`, `Respond(accept bool, policy string)`, policy `PolicyKeepBoth` or `PolicyReplace`). `OnError` receives a JSON `{"code","message"}` payload, and `StartSend`/`StartReceive` return the same JSON as their error text (gobind hands Swift only the text); `errors.go` classifies croc and package errors into codes (`peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `storeLimit`, `storeExpired`, `other`) from croc's sentinels, this package's `codedError`, and croc v11.5.4 error text, so the app never reads croc's wording. `store.go` adds stored transfers (`StartStoreSend`, `StartStoreReceive`, `RevokeStored`) over croc's `storeclient`, on the same `Transfer` handle and `activeMu` (`docs/knowledge/crocmobile-bridge.md`). 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`; a recovered panic reports code `other`). `xfer()` itself has no watchdog, though — a hang there past context cancel still holds `activeMu` forever (`docs/known-issues.md`). diff --git a/docs/BUILDING.md b/docs/BUILDING.md index c526ca8..4e7b490 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -109,7 +109,7 @@ A green build is **not** evidence a transfer works. CI runs only `verify-interop | Harness | Proves | Needs | Env vars | |---|---|---|---| -| `scripts/verify-interop.sh` | 13 scenarios, crocmobile (via its `croctest` CLI) ↔ croc CLI: file/folder/text both directions, decline, cancel both directions, forced relay, LAN-only, a croc v10 peer in both directions (R8: crocmobile reports failure code `peerVersion`), every public pool relay in both directions (LAN off, asserting which relay each side used), and the receive file policy (12: a file onto an existing one with Keep Both and Replace, answered at the prompt and preset with `-yes`, then a zipped folder received twice with each policy and accept mode), and the transport (13: a 130 MiB send in each of `auto` and `relay`, both directions, asserting byte equality and that the CLI `--debug` log shows a `tailcat-offer` under `auto` and none under `relay`). With `RELAY` set every transfer uses that relay and the pool (11) and transport (13) scenarios are skipped | croc CLI, croc v10 CLI (`scripts/croc-v10.version`), outbound network (public relay) unless `RELAY` is set | `CROC`, `CROC_V10`, `RELAY` | +| `scripts/verify-interop.sh` | 14 scenarios, crocmobile (via its `croctest` CLI) ↔ croc CLI: file/folder/text both directions, decline, cancel both directions, forced relay, LAN-only, a croc v10 peer in both directions (R8: crocmobile reports failure code `peerVersion`), every public pool relay in both directions (LAN off, asserting which relay each side used), and the receive file policy (12: a file onto an existing one with Keep Both and Replace, answered at the prompt and preset with `-yes`, then a zipped folder received twice with each policy and accept mode), and the transport (13: a 130 MiB send in each of `auto` and `relay`, both directions, asserting byte equality and that the CLI `--debug` log shows a `tailcat-offer` under `auto` and none under `relay`), and stored transfers (14: against a loopback `croctest store-server`, a wrapper upload downloaded by the CLI, a CLI upload downloaded by the wrapper, a revoke after which the download fails, and an existing name with Keep Both, Replace and a declined preview). With `RELAY` set every transfer uses that relay and the pool (11) and transport (13) scenarios are skipped; 14 needs no relay and still runs | croc CLI, croc v10 CLI (`scripts/croc-v10.version`), outbound network (public relay) unless `RELAY` is set | `CROC`, `CROC_V10`, `RELAY` | | `scripts/verify-app-mac.sh` | macOS app, 8 scenarios: CLI→app receive (plus SwiftData history), app→CLI send, `--local`, custom self-hosted relay, `--no-compress`, `--ask` (both sides confirm), and a CLI send onto an existing file received with `--when-exists keepBoth|replace` (prints `MAC-COLLIDE-OK`), and a 130 MiB transfer in each direction under `--transport auto` and `--transport relay` (prints `MAC-TRANSPORT-OK`); in the app-as-sender scenarios the CLI receiver starts only after the app writes `verify-code-ready.txt` | outbound network (scenario 8 also reaches `tailcat.dev`), croc CLI, a macOS app build | `CROC` | | `scripts/verify-app-sim.sh` | iOS simulator, CLI→app via the `--auto-receive` launch argument, gated on a byte-identical diff | outbound network, croc CLI, an iOS Simulator | `CROC`, `SIM` | | `scripts/verify-share-sim.sh` | Share-extension handoff: files staged into the App Group container the way `CrocShare` does, app launched with `--auto-share-send`, received by the croc CLI (the receiver starts only after the app writes `verify-code-ready.txt`, since a receiver that joins the room first fails) | outbound network, croc CLI, an iOS Simulator | `CROC`, `SIM` | diff --git a/docs/knowledge/crocmobile-bridge.md b/docs/knowledge/crocmobile-bridge.md index 61559cd..8f78a41 100644 --- a/docs/knowledge/crocmobile-bridge.md +++ b/docs/knowledge/crocmobile-bridge.md @@ -24,8 +24,11 @@ CrocKit Swift package (CrocEngine actor, AsyncStream) → app - Sub-100ms transfers may deliver `done` with no `connected`/`progress`/`fileList` events — UI keys off `done`/`failed` only. - `progress` ticks are deduplicated: `poll()` samples at 10 Hz but only emits when a field actually changed, so idle and plateau stretches (notably the unanswered accept prompt, where nothing moves) go quiet instead of repeating. Cadence only — the JSON schema is unchanged, and no consumer may treat a tick as a heartbeat. Consumers must still not let a tick clobber their prompt state (croc blocks in GetInput until `Respond`). - `Respond(accept bool, policy string)` returns immediately and answers on its own goroutine. `policy` is `PolicyKeepBoth` (`"keepBoth"`) or `PolicyReplace` (`"replace"`), only read on a receive accept; Swift mirrors the strings as `FilePolicy` and `CrocEngine.respond(accept:policy:)`. An accept writes one `y\n` per file for a sender-side Ask, which can outgrow the pipe buffer and block until croc drains it; the caller is CrocKit's `CrocEngine` actor, so a synchronous write would queue a following `Cancel` behind it. Do not make it synchronous again for "ordering": `session.respond` serialises on `promptM` and `closePrompt` is idempotent. -- error `{"code","message"}` (`crocmobile/errors.go`): `OnError` gets this JSON, and `StartSend`/`StartReceive` errors carry the same JSON as their text (gobind exposes only the text, as `NSError.localizedDescription`), which CrocKit decodes into `EngineFailure`. `code` is one of `peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `other`; an unknown code or a non-JSON payload decodes as `other` with the raw text. `message` is croc's text for logs only. A recovered panic reports `other`. +- error `{"code","message"}` (`crocmobile/errors.go`): `OnError` gets this JSON, and `StartSend`/`StartReceive` errors carry the same JSON as their text (gobind exposes only the text, as `NSError.localizedDescription`), which CrocKit decodes into `EngineFailure`. `code` is one of `peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `storeLimit`, `storeExpired`, `other`; an unknown code or a non-JSON payload decodes as `other` with the raw text. `message` is croc's text for logs only. A recovered panic reports `other`. - `.failed` semantics: classification uses croc's sentinels (`croc.ErrRelayConnection`, `croc.ErrDERPConnection`, `tcp.ErrAdmissionLimited`) via `errors.Is`, a `codedError` for this package's own errors (`noPublicRelay`, from `generatePublicCode`), and croc v11.5.4 text otherwise (`errors_test.go` pins the live ones against real croc clients and an in-process relay). `declined` is the peer's `peer error: refusing files`, and a peer's cancel is indistinguishable from a peer's decline on the wire. `refused` is this side's `refused files` (prompt answered no, or EOF). `roomFull` also covers the control connection's `relay admission rejected`. `badPassword` is the relay password rejected (`bad password`), not the code. `wrongCode` is peers that met with different codes (`password mismatch` on the sender, `could not secure channel` on the receiver, also `pake not successful`, `message authentication failed`). `cancelled` is `errors.Is(err, context.Canceled)`; croc returns the context error unwrapped, which `errors_test.go` checks with a live cancel. A local `Cancel()` in the accept-prompt window cancels the context before it closes the prompt pipe, and `GetInputContext` returns the context error whenever the context is done, so by source reading that failure is `cancelled`, not `refused` (not re-tested live). Map wording in the UI layer, off the code. +- Stored transfers (`store.go`): `StartStoreSend(paths, *StoreOptions, Delegate)`, `StartStoreReceive(link, outDir, Delegate)` and the blocking `RevokeStored(origin, id, token)` wrap croc v11's `storeclient`. A send ends with `OnStored(receiptJSON)` then `OnDone`; the receipt (`id`, `origin`, `uploadToken`, `link`, `expiresAt` RFC 3339 UTC, `downloads`) is the only copy of the revoke token and of the key in `link`, so the app must keep it in the Keychain. A receive reads the manifest without claiming it, raises `OnFileList` (same payload as a live receive), and waits for `Respond`; decline ends with `refused` and leaves the transfer unclaimed. Both use the same `Transfer` handle and `activeMu` as live transfers, but no cwd, stdin or stdout. `RevokeStored` takes no lock and treats 404/410 as success; it passes a zero key because storeclient validates a whole share and revoke authenticates by token only (the croc CLI does the same). Progress `step` is `preparing` (hashing) then `transferring`; storeclient's callbacks run on its worker goroutines, so each is recovered. +- Stored receive conflicts: storeclient installs each file with a rename that replaces whatever exists, so Replace needs nothing and Keep Both is settled before the download by renaming manifest entries to `name (1).ext` (`keepBothNames`; the names are local, the server and chunk encryption never see them). A failed or cancelled receive leaves storeclient's resume state (`.croc-store-.json`, `..croc-.part`) in `outDir`, which is the only way back in within the server's 30-minute claim lock; resuming after Keep Both renamed an installed file starts a fresh state and a new claim, which the lock refuses until it expires. +- Stored limits: `StartStoreSend` refuses more than 100 files, more than 1 GiB or more than 100 downloads before hashing (`storeLimit`; croc's store defaults, so a server with lower limits still refuses at upload time). The server's HTTP refusals map in `storeHTTPCode`: 404/410 `storeExpired`; 413, 429, 507 and 400 "declaration" `storeLimit`. Network failures reuse `relayUnreachable`, so the app words that copy by context. - `os.Chdir` restore failures are logged (`restoreWD` in `session.go`), and an `os.Getwd` failure fails the start instead of being ignored. ## Engine behavior invariants @@ -56,7 +59,7 @@ CrocKit Swift package (CrocEngine actor, AsyncStream) → app ## Verification harnesses - `scripts/lib.sh` — sourced by every `verify-*.sh`: the `timeout(1)` shim, `fail`/`pass`, `require_croc`, `kill_jobs` (EXIT trap, so a failed assertion cannot leak a croc/relay/app process), `local_ip`. -- `scripts/verify-interop.sh`: 13 scenarios crocmobile↔CLI (file/folder/text both ways, decline, cancel both ways mid-wire via `--throttleUpload 200k`, forced relay, LAN via `--ip`, a croc v10 peer both ways expecting failure code `peerVersion`, every public pool relay). `RELAY=host:port` routes every transfer through one relay and skips the transport (13) and pool (11) scenarios. Cancel scenarios assert received-file mismatch (unfakeable by log noise). Scenario 11 covers each of the 4 pool relays: a custom code that maps to it sent wrapper→CLI and CLI→wrapper, and a wrapper-generated code forced onto it with `croctest send -relay-index N`; plus one CLI-generated code (the CLI caches its probed relay, so that covers one index). LAN is off on both sides (`-no-local` / `send --no-local`) so only matching relays meet, and it asserts croctest's `EVENT relay ` and the CLI `--debug` line `public relay index N selected: `. It waits 60 s first, runs with multiplexing off (`-no-multi` / `--no-multi`) and makes one pass over the pool per kind of transfer, because a relay admits 30 connections a minute from one address (`docs/known-issues.md`). Scenario 12 is the file policy: a CLI sends a file onto an existing one and `croctest` receives with Keep Both and Replace, answered at the prompt and preset with `-yes` (4 cases), then a zipped folder (`croc send --zip`) received twice into one folder with each policy and each accept mode (4 cases); it runs in `RELAY` mode too, so CI covers it. `croctest receive` takes `-policy keepBoth|replace` (default `keepBoth`, other values exit 2). Scenario 13 is the transport: a 130 MiB send in each of `auto` and `relay`, wrapper→CLI and CLI→wrapper (4 cases), against public infrastructure only (Tailcat bootstraps from tailcat.dev, so it is skipped with `RELAY`). It asserts byte equality, and in the CLI `--debug` log a `tailcat-offer` under auto (or `selected=derp`, the CLI's size decision, when the CLI sends) and none under relay. `croctest send` takes `-transport auto|relay|derp` (default `auto`). `croctest` also has `relay CODE` and `relays`; the relay cache lives in `crocmobile/internal/relaycache`, so croctest can seed it without adding to the gobind API. +- `scripts/verify-interop.sh`: 14 scenarios crocmobile↔CLI (file/folder/text both ways, decline, cancel both ways mid-wire via `--throttleUpload 200k`, forced relay, LAN via `--ip`, a croc v10 peer both ways expecting failure code `peerVersion`, every public pool relay). `RELAY=host:port` routes every transfer through one relay and skips the transport (13) and pool (11) scenarios. Scenario 14 (stored transfers) needs neither a relay nor the internet, so it runs under `RELAY` too: it starts `croctest store-server` on a loopback port and checks wrapper upload → CLI download, wrapper revoke → CLI download fails, CLI upload → wrapper download, CLI revoke → `storeExpired`, and an existing name with Keep Both, Replace and a declined preview; the CLI's revoke receipts go to a throwaway `CROC_CONFIG_DIR`. Cancel scenarios assert received-file mismatch (unfakeable by log noise). Scenario 11 covers each of the 4 pool relays: a custom code that maps to it sent wrapper→CLI and CLI→wrapper, and a wrapper-generated code forced onto it with `croctest send -relay-index N`; plus one CLI-generated code (the CLI caches its probed relay, so that covers one index). LAN is off on both sides (`-no-local` / `send --no-local`) so only matching relays meet, and it asserts croctest's `EVENT relay ` and the CLI `--debug` line `public relay index N selected: `. It waits 60 s first, runs with multiplexing off (`-no-multi` / `--no-multi`) and makes one pass over the pool per kind of transfer, because a relay admits 30 connections a minute from one address (`docs/known-issues.md`). Scenario 12 is the file policy: a CLI sends a file onto an existing one and `croctest` receives with Keep Both and Replace, answered at the prompt and preset with `-yes` (4 cases), then a zipped folder (`croc send --zip`) received twice into one folder with each policy and each accept mode (4 cases); it runs in `RELAY` mode too, so CI covers it. `croctest receive` takes `-policy keepBoth|replace` (default `keepBoth`, other values exit 2). Scenario 13 is the transport: a 130 MiB send in each of `auto` and `relay`, wrapper→CLI and CLI→wrapper (4 cases), against public infrastructure only (Tailcat bootstraps from tailcat.dev, so it is skipped with `RELAY`). It asserts byte equality, and in the CLI `--debug` log a `tailcat-offer` under auto (or `selected=derp`, the CLI's size decision, when the CLI sends) and none under relay. `croctest send` takes `-transport auto|relay|derp` (default `auto`). `croctest` also has `relay CODE` and `relays`; the relay cache lives in `crocmobile/internal/relaycache`, so croctest can seed it without adding to the gobind API. - `scripts/verify-app-sim.sh` — boots sim, installs app, CLI→app via `--auto-receive CODE` launch arg, gates on exact `ok success=true` + byte diff. The launch args drive the real UI state machine (`AutoVerify` → `TransferController`, accept via `respond(true)`), not engine-level autoAccept — the point is that the verified path is the one users take. - `scripts/verify-app-mac.sh` — macOS app both directions plus local-only: CLI→app (`--auto-receive`), app→CLI (`--auto-send PATH --code CODE`, custom-code path; source file must live in the app container — sandbox), app→CLI with `--local` (sandboxed relay-listener proof, CLI receives via `--ip`), app→CLI via custom self-hosted relay (`croc relay --ports 9021,9022,9023` + `--relay localhost:9021`), `--no-compress` path, `--ask` both-sides-confirm (auto-answered via `AutoVerify` `.confirmSend`), and scenario 7, which prints `MAC-COLLIDE-OK`: the CLI sends onto an existing file in the app's Documents and the app receives with `--when-exists keepBoth|replace` (the answer `AutoVerify` gives the incoming sheet; the setting stays Keep Both), each diffed. Scenario 8 prints `MAC-TRANSPORT-OK`: a 130 MiB transfer in each direction under `--transport auto` and `--transport relay` (the `AutoVerify` flag that sets `settings.transport`), with the same offer assertions as interop scenario 13; public internet only. The app declares document types, so bare adjacent argv tokens are document-open candidates — flags only (`--code CODE`), and every launch needs `-ApplePersistenceIgnoreState YES` (headless window-restoration hang). - `crockit-verify` (CrocKit executable) — Swift-layer send/receive/cancel-after-ms/`twice` (two transfers one process, the second receiving with `.replace` — proves fd0/stdout/cwd/mutex restoration composes). diff --git a/scripts/verify-interop.sh b/scripts/verify-interop.sh index 5219c34..55c82ce 100755 --- a/scripts/verify-interop.sh +++ b/scripts/verify-interop.sh @@ -3,8 +3,9 @@ # Covers: file/folder/text both directions, decline, cancel both directions, # forced relay (LAN disabled), LAN-only, a croc v10 peer both directions, a # receive onto existing files with Keep Both and Replace (R24), a 130 MiB send -# in each transport mode (R17), and every public pool relay (custom, -# wrapper-generated and CLI-generated codes). +# in each transport mode (R17), stored transfers against a local store server +# (R18-R20), and every public pool relay (custom, wrapper-generated and +# CLI-generated codes). # Exits non-zero on first failure. # # RELAY=host:port runs every transfer through that relay instead of the public @@ -290,6 +291,83 @@ grep -q 'EVENT error {"code":"refused"' "$TMP/12ask.log" || fail "sender --ask u [ ! -e "$TMP/dst12-ask/a.txt" ] || fail "sender --ask under -yes with replace: file arrived" pass "sender --ask under -yes with replace: refused" +# 14. stored transfers (R18-R20) against a local croc store server, no relay and +# no internet: crocmobile uploads and the CLI downloads, the CLI uploads and +# crocmobile downloads, and a revoked transfer no longer downloads. Every upload +# lasts an hour, allows the downloads the case needs, and is revoked at the end. +# The CLI's revoke receipts go to a throwaway config dir. +SCENARIO=14 +mkdir -p "$TMP/store14" "$TMP/croc-config14" +"$CT" store-server "$TMP/store14" > "$TMP/14srv.log" 2>&1 & +STORE_PID=$! +STORE="" +for _ in $(seq 1 50); do + STORE=$(sed -n 's/^STORE-URL //p' "$TMP/14srv.log") + [ -n "$STORE" ] && break + sleep 0.2 +done +[ -n "$STORE" ] || fail "store server did not start" +field() { # field LOGFILE KEY: a string value from the EVENT stored receipt + sed -n "s/.*\"$2\":\"\\([^\"]*\\)\".*/\\1/p" "$1" | head -1 +} +cli_store() { CROC_CONFIG_DIR="$TMP/croc-config14" "$CROC" --disable-clipboard "$@"; } + +# 14a. wrapper uploads two files (the 5 MiB one spans two chunks), CLI downloads +"$CT" store-send -server "$STORE" -downloads 1 -lifetime 3600 "$TMP/src/a.txt" "$TMP/src/big.bin" > "$TMP/14a.log" 2>&1 || fail "store send (wrapper)" +LINK=$(field "$TMP/14a.log" link); ID=$(field "$TMP/14a.log" id); TOKEN=$(field "$TMP/14a.log" uploadToken) +[ -n "$LINK" ] && [ -n "$ID" ] && [ -n "$TOKEN" ] || fail "store send (wrapper): no receipt" +grep -q 'EVENT done {"files":2,"success":true' "$TMP/14a.log" || fail "store send (wrapper): no success summary" +mkdir -p "$TMP/dst14a" +CROC_STORE_TOKEN="$LINK" timeout 60 "$CROC" --yes --out "$TMP/dst14a" > "$TMP/14a.cli" 2>&1 || true +diff "$TMP/src/a.txt" "$TMP/dst14a/a.txt" > /dev/null || fail "store send (wrapper) -> CLI receive: a.txt" +diff "$TMP/src/big.bin" "$TMP/dst14a/big.bin" > /dev/null || fail "store send (wrapper) -> CLI receive: big.bin" +pass "store: wrapper upload, CLI download" + +# 14b. revoke from the wrapper, then the transfer is gone for the CLI +"$CT" store-revoke "$STORE" "$ID" "$TOKEN" > "$TMP/14b.log" 2>&1 || fail "store revoke (wrapper)" +grep -q 'EVENT revoked' "$TMP/14b.log" || fail "store revoke (wrapper): no revoked event" +"$CT" store-revoke "$STORE" "$ID" "$TOKEN" > "$TMP/14b2.log" 2>&1 || fail "store revoke twice: a missing transfer must count as revoked" +mkdir -p "$TMP/dst14b" +CROC_STORE_TOKEN="$LINK" timeout 60 "$CROC" --yes --out "$TMP/dst14b" > "$TMP/14b.cli" 2>&1 && fail "revoked transfer still downloads (CLI)" +[ -z "$(ls -A "$TMP/dst14b")" ] || fail "revoked transfer wrote files (CLI)" +pass "store: revoke, then CLI download fails" + +# 14c. CLI uploads, wrapper downloads, then the CLI revokes and the wrapper's +# download fails with storeExpired +cli_store --ignore-stdin store --url "$STORE" --downloads 1 --expiration 1h "$TMP/src/a.txt" "$TMP/src/big.bin" > "$TMP/14c.cli" 2>&1 || fail "store send (CLI)" +LINK=$(grep -o 'http://127.0.0.1:[0-9]*/s/[A-Za-z0-9_-]*#v1\.[A-Za-z0-9_-]*' "$TMP/14c.cli" | head -1) +ID=${LINK#*/s/}; ID=${ID%%#*} +[ -n "$LINK" ] && [ -n "$ID" ] || fail "store send (CLI): no link in its output" +timeout 60 "$CT" store-receive -out "$TMP/dst14c" -answer y "$LINK" > "$TMP/14c.log" 2>&1 || true +grep -q 'EVENT filelist' "$TMP/14c.log" || fail "store receive: no filelist event" +diff "$TMP/src/a.txt" "$TMP/dst14c/a.txt" > /dev/null || fail "store send (CLI) -> wrapper receive: a.txt" +diff "$TMP/src/big.bin" "$TMP/dst14c/big.bin" > /dev/null || fail "store send (CLI) -> wrapper receive: big.bin" +cli_store --revoke "$ID" > "$TMP/14c.revoke" 2>&1 || fail "store revoke (CLI)" +timeout 60 "$CT" store-receive -out "$TMP/dst14c2" -answer y "$LINK" > "$TMP/14c2.log" 2>&1 && fail "revoked transfer still downloads (wrapper)" +grep -q 'EVENT error {"code":"storeExpired"' "$TMP/14c2.log" || fail "revoked transfer: not storeExpired" +pass "store: CLI upload, wrapper download, revoke, then wrapper download fails" + +# 14d. a receive onto an existing name: Keep Both adds "a (1).txt", Replace +# overwrites, and a declined preview leaves the transfer downloadable +"$CT" store-send -server "$STORE" -downloads 3 -lifetime 3600 "$TMP/src/a.txt" > "$TMP/14d.log" 2>&1 || fail "store send for collision" +LINK=$(field "$TMP/14d.log" link); ID=$(field "$TMP/14d.log" id); TOKEN=$(field "$TMP/14d.log" uploadToken) +mkdir -p "$TMP/dst14d" +echo "existing file" > "$TMP/dst14d/a.txt" +timeout 60 "$CT" store-receive -out "$TMP/dst14d" -answer n "$LINK" > "$TMP/14d0.log" 2>&1 && fail "declined store receive exited 0" +grep -q 'EVENT error {"code":"refused"' "$TMP/14d0.log" || fail "declined store receive: not refused" +[ "$(cat "$TMP/dst14d/a.txt")" = "existing file" ] || fail "declined store receive changed the existing file" +[ "$(find "$TMP/dst14d" -type f | wc -l | tr -d ' ')" = 1 ] || fail "declined store receive wrote files" +timeout 60 "$CT" store-receive -out "$TMP/dst14d" -answer y -policy keepBoth "$LINK" > "$TMP/14d1.log" 2>&1 || fail "store receive, keepBoth" +[ "$(cat "$TMP/dst14d/a.txt")" = "existing file" ] || fail "store keepBoth: existing file changed" +diff "$TMP/src/a.txt" "$TMP/dst14d/a (1).txt" > /dev/null || fail "store keepBoth: no a (1).txt with the new content" +timeout 60 "$CT" store-receive -out "$TMP/dst14d" -answer y -policy replace "$LINK" > "$TMP/14d2.log" 2>&1 || fail "store receive, replace" +diff "$TMP/src/a.txt" "$TMP/dst14d/a.txt" > /dev/null || fail "store replace: file not replaced" +"$CT" store-revoke "$STORE" "$ID" "$TOKEN" > "$TMP/14d3.log" 2>&1 || fail "store revoke after collision cases" +pass "store: existing file with Keep Both and Replace, decline keeps the transfer" +# Later scenarios end in a bare `wait`, which would wait on the server forever. +kill "$STORE_PID" +wait "$STORE_PID" 2>/dev/null || true + if [ -n "${RELAY:-}" ]; then echo "ALL INTEROP CHECKS PASSED (relay $RELAY; transport and public pool scenarios skipped)" exit 0 From 7b30703b466acf1577925e8d809a932cd7e158a2 Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Thu, 15 Oct 2026 00:12:17 +0200 Subject: [PATCH 2/8] Keep stored-upload receipts in the Keychain and drive stored transfers from the controller StoredTransferVault holds each upload's receipt (token and link) in one Keychain item, purges expired ones at launch, and keeps earlier receipts when a write fails. TransferController gains startStoreSend, startStoreReceive, revoke and Phase.stored; history records carry the upload id and expiry as optional fields, and a stored link's hint is only its host. A stored transfer never offers resume: a background expiry says the upload was interrupted and to try again. AppSettings gains the stored-transfer server, and harness runs use an in-memory vault. --- app/CrocApp/ContentView.swift | 5 +- app/CrocApp/CrocAppApp.swift | 3 +- app/CrocApp/Models/AppSettings.swift | 6 + app/CrocApp/Models/StoredTransferVault.swift | 85 ++++++++ app/CrocApp/Models/TransferController.swift | 186 ++++++++++++++---- app/CrocApp/Models/TransferHistory.swift | 21 +- app/CrocApp/Support/KeychainStore.swift | 18 ++ app/CrocApp/Views/TransferStatusView.swift | 23 +++ .../AppSettingsPersistenceTests.swift | 6 + app/CrocAppTests/CodeHintTests.swift | 15 ++ app/CrocAppTests/FriendlyMessageTests.swift | 23 +++ .../StoredTransferVaultTests.swift | 127 ++++++++++++ docs/ARCHITECTURE.md | 4 +- docs/GLOSSARY.md | 3 +- docs/knowledge/app-ui-architecture.md | 14 +- docs/known-issues.md | 2 +- 16 files changed, 496 insertions(+), 45 deletions(-) create mode 100644 app/CrocApp/Models/StoredTransferVault.swift create mode 100644 app/CrocAppTests/StoredTransferVaultTests.swift diff --git a/app/CrocApp/ContentView.swift b/app/CrocApp/ContentView.swift index 953c879..7c553f4 100644 --- a/app/CrocApp/ContentView.swift +++ b/app/CrocApp/ContentView.swift @@ -149,7 +149,10 @@ struct ContentView: View { #Preview { ContentView() - .environment(TransferController(settings: AppSettings())) + .environment( + TransferController( + settings: AppSettings(), vault: StoredTransferVault(secrets: MemorySecretStore())) + ) .environment(OutputFolderStore()) .environment(LocalNetworkChecker()) .environment(AppRouter.shared) diff --git a/app/CrocApp/CrocAppApp.swift b/app/CrocApp/CrocAppApp.swift index d6e29f9..89e1afa 100644 --- a/app/CrocApp/CrocAppApp.swift +++ b/app/CrocApp/CrocAppApp.swift @@ -29,7 +29,8 @@ struct CrocAppApp: App { let harness = AutoVerify.isHarnessRun let history = HistoryStore(container: HistoryStore.makeContainer(inMemory: harness)) _history = State(initialValue: history) - let controller = TransferController(settings: settings) + let vault = StoredTransferVault(secrets: harness ? MemorySecretStore() : KeychainStore()) + let controller = TransferController(settings: settings, vault: vault) controller.history = history _controller = State(initialValue: controller) // AppRouter.shared is constructed before this init runs; attach the diff --git a/app/CrocApp/Models/AppSettings.swift b/app/CrocApp/Models/AppSettings.swift index ec3f8c3..d21246e 100644 --- a/app/CrocApp/Models/AppSettings.swift +++ b/app/CrocApp/Models/AppSettings.swift @@ -44,6 +44,10 @@ final class AppSettings { var transport: Transport { didSet { save(transport.rawValue, "settings.transport") } } + /// Where "Send as link" uploads (R21). The engine refuses `http` except + /// on loopback. + var storedServer: String { didSet { save(storedServer, "settings.storedServer") } } + static let defaultStoredServer = StoredSendOptions().server init(defaults d: UserDefaults = .standard, secrets: any SecretStore = KeychainStore()) { defaults = d @@ -66,6 +70,7 @@ final class AppSettings { ?? .keepBoth transport = d.string(forKey: "settings.transport").flatMap(Transport.init(rawValue:)) ?? .auto + storedServer = d.string(forKey: "settings.storedServer") ?? Self.defaultStoredServer } var relayKind: RelayKind { @@ -100,6 +105,7 @@ final class AppSettings { bothSidesConfirm = false whenFileExists = .keepBoth transport = .auto + storedServer = Self.defaultStoredServer } /// One-time move of the relay password off UserDefaults (plaintext diff --git a/app/CrocApp/Models/StoredTransferVault.swift b/app/CrocApp/Models/StoredTransferVault.swift new file mode 100644 index 0000000..0b33855 --- /dev/null +++ b/app/CrocApp/Models/StoredTransferVault.swift @@ -0,0 +1,85 @@ +import CrocKit +import Foundation +import OSLog + +/// Receipts of this device's stored uploads (R19). Each carries the upload +/// token (the right to revoke) and the link (whose fragment is the decryption +/// key), so they live only in the Keychain. All receipts share one item as a +/// JSON array, because `SecretStore` cannot list accounts. A receipt leaves +/// on revoke, or at the first launch after it expires. +@MainActor +final class StoredTransferVault { + private static let account = "storedReceipts" + private static let log = Logger(subsystem: "com.bakirgdev.CrocApp", category: "vault") + + private let secrets: any SecretStore + private var receipts: [StoredReceipt] + + init(secrets: any SecretStore = KeychainStore(), now: Date = .now) { + self.secrets = secrets + receipts = Self.load(from: secrets) + purgeExpired(now: now) + } + + func receipt(id: String) -> StoredReceipt? { + receipts.first { $0.id == id } + } + + /// Returns false when the Keychain write fails. The receipt is then kept + /// for this launch only: revoke works until the app quits. + @discardableResult + func add(_ receipt: StoredReceipt) -> Bool { + receipts.removeAll { $0.id == receipt.id } + receipts.append(receipt) + return persist("add") + } + + @discardableResult + func remove(id: String) -> Bool { + guard receipts.contains(where: { $0.id == id }) else { return true } + receipts.removeAll { $0.id == id } + return persist("remove") + } + + func purgeExpired(now: Date = .now) { + let count = receipts.count + receipts.removeAll { $0.expiresAt <= now } + if receipts.count != count { persist("purge") } + } + + @discardableResult + private func persist(_ operation: String) -> Bool { + let stored: Bool + if receipts.isEmpty { + stored = secrets.delete(account: Self.account) + } else { + do { + let data = try JSONEncoder().encode(receipts) + stored = secrets.set(String(decoding: data, as: UTF8.self), account: Self.account) + } catch { + Self.log.error( + "receipts \(operation, privacy: .public) encode failed: \(String(describing: error), privacy: .public)" + ) + return false + } + } + if !stored { + Self.log.error("receipts \(operation, privacy: .public) not saved to the Keychain") + } + return stored + } + + /// An unreadable item is logged and treated as empty; the next write + /// replaces it. + private static func load(from secrets: any SecretStore) -> [StoredReceipt] { + guard let json = secrets.get(account: account) else { return [] } + do { + return try JSONDecoder().decode([StoredReceipt].self, from: Data(json.utf8)) + } catch { + log.error( + "receipts unreadable, starting empty: \(String(describing: error), privacy: .private)" + ) + return [] + } + } +} diff --git a/app/CrocApp/Models/TransferController.swift b/app/CrocApp/Models/TransferController.swift index 7d85af0..61391e8 100644 --- a/app/CrocApp/Models/TransferController.swift +++ b/app/CrocApp/Models/TransferController.swift @@ -18,14 +18,20 @@ final class TransferController { case incoming(FileList, conflicts: [String], blocked: [String]) case transferring(TransferProgress) case done(Summary, receivedText: String?) + /// A stored upload finished. The receipt holds the link to share. + case stored(StoredReceipt) case failed(String) } private(set) var phase: Phase = .idle private(set) var direction: Direction = .send private(set) var speedBytesPerSec: Double = 0 + /// The transfer goes through a stored-transfer server (R18-R20) rather + /// than a croc peer. + private(set) var isStoredTransfer = false let settings: AppSettings + let vault: StoredTransferVault /// Relay path of the transfer in flight, captured at start so mid-transfer /// settings edits don't lie in the trust UI. @@ -66,6 +72,8 @@ final class TransferController { /// True once any payload bytes moved -- gates the "resume" hint so it /// never appears on failures before the transfer started. private var sawTransferBytes = false + /// Arrives just before `.done` on a stored upload. + private var storedReceipt: StoredReceipt? /// History sink; set once at app startup. nil in previews. @ObservationIgnored var history: HistoryStore? @@ -81,10 +89,13 @@ final class TransferController { var totalBytes: Int64 var codeHint: String var bookmarks: [Data] + var storedID: String? + var expiresAt: Date? } - init(settings: AppSettings) { + init(settings: AppSettings, vault: StoredTransferVault) { self.settings = settings + self.vault = vault } // MARK: - Intents @@ -105,9 +116,43 @@ final class TransferController { func startSend(urls: [URL], customCode: String) { guard !isActive else { return } direction = .send + isStoredTransfer = false activeRelay = settings.relayKind sendConfirmArmed = settings.bothSidesConfirm autoAcceptActive = false + beginFileSend(urls: urls, codeHint: Self.codeHint(customCode)) + let paths = urls.map(\.path) + var options = baseOptions() + options.customCode = customCode + options.workDir = FileManager.default.temporaryDirectory.path + options.zipFolder = settings.zipFolder + options.gitIgnore = settings.useGitIgnore + options.exclude = settings.excludeList + run { try await self.engine.startSend(paths: paths, text: nil, options: options) } + } + + /// Uploads `urls` (regular files only) encrypted to the stored-transfer + /// server in Settings (R18). Ends in `.stored(receipt)`; the receipt also + /// goes to the vault so History can revoke it. + func startStoreSend(urls: [URL], downloads: Int, lifetimeSeconds: Int) { + guard !isActive else { return } + direction = .send + isStoredTransfer = true + activeRelay = settings.relayKind + sendConfirmArmed = false + autoAcceptActive = false + let server = settings.storedServer + beginFileSend( + urls: urls, codeHint: URL(string: server).map(Self.codeHint(storedLink:)) ?? "") + let paths = urls.map(\.path) + var options = StoredSendOptions() + options.server = server + options.downloads = downloads + options.lifetimeSeconds = lifetimeSeconds + run { try await self.engine.startStoreSend(paths: paths, options: options) } + } + + private func beginFileSend(urls: [URL], codeHint: String) { // startAccessing returns false for non-scoped URLs (e.g. some drops); // keep every path regardless, only track the ones needing release. scopedURLs = urls.filter { $0.startAccessingSecurityScopedResource() } @@ -115,7 +160,7 @@ final class TransferController { isSend: true, isText: false, names: urls.prefix(TransferRecord.maxNames).map(\.lastPathComponent), fileCount: urls.count, totalBytes: 0, - codeHint: Self.codeHint(customCode), + codeHint: codeHint, bookmarks: []) // Up to maxBookmarks (200) bookmarkData calls, off the main actor so a // slow volume can't stall the send tap. Security scope is held until @@ -133,19 +178,12 @@ final class TransferController { self.pendingRecord?.bookmarks = all } } - let paths = urls.map(\.path) - var options = baseOptions() - options.customCode = customCode - options.workDir = FileManager.default.temporaryDirectory.path - options.zipFolder = settings.zipFolder - options.gitIgnore = settings.useGitIgnore - options.exclude = settings.excludeList - run { try await self.engine.startSend(paths: paths, text: nil, options: options) } } func startSendText(_ text: String, customCode: String) { guard !isActive else { return } direction = .send + isStoredTransfer = false activeRelay = settings.relayKind sendConfirmArmed = settings.bothSidesConfirm autoAcceptActive = false @@ -161,15 +199,8 @@ final class TransferController { func startReceive(code: String, into folder: URL, folderIsScoped: Bool) { guard !isActive else { return } direction = .receive - activeRelay = settings.relayKind - sendConfirmArmed = false - outDir = folder - if folderIsScoped, folder.startAccessingSecurityScopedResource() { - scopedURLs.append(folder) - } - pendingRecord = PendingRecord( - isSend: false, isText: false, names: [], fileCount: 0, totalBytes: 0, - codeHint: Self.codeHint(code), bookmarks: []) + isStoredTransfer = false + beginReceive(into: folder, folderIsScoped: folderIsScoped, codeHint: Self.codeHint(code)) var options = baseOptions() options.outDir = folder.path // Auto-accept uses the setting; otherwise the incoming sheet's answer @@ -183,6 +214,55 @@ final class TransferController { run { try await self.engine.startReceive(code: code, options: options) } } + /// Opens a stored link (R20): the incoming preview, then the same accept + /// and Keep Both / Replace answer as a live receive. Auto-accept does not + /// apply; the engine always waits for `respond`. + func startStoreReceive(link: URL, into folder: URL, folderIsScoped: Bool) { + guard !isActive else { return } + direction = .receive + isStoredTransfer = true + beginReceive( + into: folder, folderIsScoped: folderIsScoped, codeHint: Self.codeHint(storedLink: link)) + autoAcceptActive = false + let path = folder.path + let link = link.absoluteString + run { try await self.engine.startStoreReceive(link: link, outDir: path) } + } + + private func beginReceive(into folder: URL, folderIsScoped: Bool, codeHint: String) { + activeRelay = settings.relayKind + sendConfirmArmed = false + outDir = folder + if folderIsScoped, folder.startAccessingSecurityScopedResource() { + scopedURLs.append(folder) + } + pendingRecord = PendingRecord( + isSend: false, isText: false, names: [], fileCount: 0, totalBytes: 0, + codeHint: codeHint, bookmarks: []) + } + + /// Whether History can offer Revoke: an unexpired, unrevoked upload whose + /// receipt this device still holds. + func canRevoke(_ record: TransferRecord, now: Date = .now) -> Bool { + guard let id = record.storedID, record.revokedAt == nil, + let expiresAt = record.expiresAt, expiresAt > now + else { return false } + return vault.receipt(id: id) != nil + } + + /// Deletes a stored upload from its server and marks its record (R19). A + /// server that no longer has it counts as revoked. Runs alongside any + /// active transfer. Throws `EngineFailure`; show + /// `friendlyMessage(for:…, stored: true)`. + func revoke(_ record: TransferRecord) async throws { + guard let id = record.storedID, let receipt = vault.receipt(id: id) else { + throw EngineFailure(code: .other, message: "no receipt for this upload") + } + try await engine.revokeStored(receipt) + vault.remove(id: id) + history?.markRevoked(record) + } + /// Answer the accept/decline prompt raised by `.incoming` or `.confirmSend`. /// `policy` decides what an accepted receive does with files that already /// exist; a send ignores it. @@ -212,6 +292,7 @@ final class TransferController { streamTask = nil releaseScopedURLs() receivedText = nil + storedReceipt = nil outDir = nil speedBytesPerSec = 0 backgroundExpired = false @@ -222,9 +303,14 @@ final class TransferController { private func run(_ start: @escaping () async throws -> EngineTransfer) { phase = .starting - background.transferStarted( - title: direction == .send ? "Sending with CrocApp" : "Receiving with CrocApp" - ) { [weak self] in + let title = + switch (direction, isStoredTransfer) { + case (.send, false): "Sending with CrocApp" + case (.send, true): "Uploading with CrocApp" + case (.receive, false): "Receiving with CrocApp" + case (.receive, true): "Downloading with CrocApp" + } + background.transferStarted(title: title) { [weak self] in guard let self else { return } self.backgroundExpired = true self.cancel() @@ -235,6 +321,7 @@ final class TransferController { transferGeneration += 1 sawTransferBytes = false receivedText = nil + storedReceipt = nil speedBytesPerSec = 0 lastProgressDate = nil lastProgressBytes = 0 @@ -261,7 +348,7 @@ final class TransferController { // as a thrown error must keep the resume hint, not fall back // to generic copy. if backgroundExpired { - phase = .failed(Self.backgroundExpiredCopy) + phase = .failed(backgroundExpiredCopy) } else { let failure: EngineFailure if case CrocEngineError.startFailed(let f) = error { @@ -272,7 +359,7 @@ final class TransferController { phase = .failed( Self.friendlyMessage( for: failure, cancelRequested: cancelRequested, - declineRequested: declineRequested)) + declineRequested: declineRequested, stored: isStoredTransfer)) } finishRecord(cancelRequested ? .cancelled : .failed, summary: nil) } @@ -342,9 +429,13 @@ final class TransferController { phase = .transferring(p) case .text(let t): receivedText = t - case .stored: - // Nothing starts a stored upload until B17 wires the controller. - break + case .stored(let receipt): + storedReceipt = receipt + // A failed write is logged by the vault; the link still works, + // only a revoke after relaunch is lost. + vault.add(receipt) + pendingRecord?.storedID = receipt.id + pendingRecord?.expiresAt = receipt.expiresAt case .done(let summary): background.transferEnded(success: true) // cancel() is fire-and-forget, so a small enough transfer can reach @@ -354,6 +445,9 @@ final class TransferController { if blockedAutoAccept { phase = .failed(Self.blockedAutoAcceptCopy) finishRecord(.failed, summary: summary) + } else if let storedReceipt { + phase = .stored(storedReceipt) + finishRecord(.completed, summary: summary) } else { phase = .done(summary, receivedText: receivedText) finishRecord(summary.success ? .completed : .failed, summary: summary) @@ -363,7 +457,7 @@ final class TransferController { if blockedAutoAccept { phase = .failed(Self.blockedAutoAcceptCopy) } else if backgroundExpired { - phase = .failed(Self.backgroundExpiredCopy) + phase = .failed(backgroundExpiredCopy) } else if autoAcceptActive, !cancelRequested, failure.code == .refused { // This side's prompt refused without anyone answering it. croc // forces the receiver prompt whenever the remote sender ran @@ -378,8 +472,9 @@ final class TransferController { } else { var copy = Self.friendlyMessage( for: failure, cancelRequested: cancelRequested, - declineRequested: declineRequested) - if sawTransferBytes && !cancelRequested && !declineRequested { + declineRequested: declineRequested, stored: isStoredTransfer) + // A stored transfer cannot resume; it starts over. + if sawTransferBytes && !cancelRequested && !declineRequested && !isStoredTransfer { copy += Self.resumeHint } phase = .failed(copy) @@ -432,6 +527,12 @@ final class TransferController { }.value } + /// A stored link carries its id in the path and its key in the fragment, + /// so history keeps only the server's host. + static func codeHint(storedLink url: URL) -> String { + url.host(percentEncoded: false) ?? "" + } + /// Enough to recognise a transfer in history, not enough to reuse the /// code. A generated code's first word ("wiry-…", or "7291-…" for a /// legacy code) is a third of it at most; any other shape is likely a @@ -458,7 +559,8 @@ final class TransferController { TransferRecord( isSend: p.isSend, status: status, isText: p.isText, fileCount: p.fileCount, totalBytes: p.totalBytes, - names: p.names, codeHint: p.codeHint, bookmarks: p.bookmarks)) + names: p.names, codeHint: p.codeHint, bookmarks: p.bookmarks, + storedID: p.storedID, expiresAt: p.expiresAt)) } // MARK: - Error copy @@ -468,20 +570,34 @@ final class TransferController { private static let blockedAutoAcceptCopy = "Blocked: this transfer contained unsafe file names, so auto-accept cancelled it." - /// Used from both `handle(.failed)` and `run()`'s catch. - private static let backgroundExpiredCopy = - "iOS paused the transfer in the background." + resumeHint + /// Used from both `handle(.failed)` and `run()`'s catch. A stored + /// transfer cannot resume (concern 4), so it says to start over. + private var backgroundExpiredCopy: String { + switch (isStoredTransfer, direction) { + case (false, _): "iOS paused the transfer in the background." + Self.resumeHint + case (true, .send): "Upload interrupted: iOS stopped it in the background. Try again." + case (true, .receive): + "Download interrupted: iOS stopped it in the background. Open the link again." + } + } /// Keep Both saves a partly received file as a new copy, so only Replace /// resumes it. private static let resumeHint = " Start the same transfer again; replacing the partly received files resumes it." + /// `stored` words network failures for a stored-transfer server (a store + /// call reports them as `relayUnreachable`) instead of a relay or peer. static func friendlyMessage( - for failure: EngineFailure, cancelRequested: Bool, declineRequested: Bool + for failure: EngineFailure, cancelRequested: Bool, declineRequested: Bool, + stored: Bool = false ) -> String { if cancelRequested { return "Transfer cancelled." } if declineRequested { return "You declined the transfer." } + if stored, failure.code == .relayUnreachable || failure.code == .connectionLost { + return + "Couldn't reach the transfer server. Check your internet connection and try again." + } // Never echo failure.message: croc adds new error text every release, // and a recovered Go panic arrives with a full stack trace attached. switch failure.code { diff --git a/app/CrocApp/Models/TransferHistory.swift b/app/CrocApp/Models/TransferHistory.swift index bae30c2..bc9b1dc 100644 --- a/app/CrocApp/Models/TransferHistory.swift +++ b/app/CrocApp/Models/TransferHistory.swift @@ -5,8 +5,9 @@ import SwiftData /// One finished (or failed) transfer (F12). Local only — never synced. /// Privacy: stores at most `maxNames` file names, a code *hint* -/// (`TransferController.codeHint`), and, for sends, bookmarks so history can -/// re-stage the same files. Never file contents, never the full code phrase. +/// (`TransferController.codeHint`; only the server host for a stored link), +/// and, for sends, bookmarks so history can re-stage the same files. Never +/// file contents, never the full code phrase, never a stored link or token. /// Bookmarks are stored only for sends of at most `maxBookmarks` items and /// only when every bookmark resolves — all-or-nothing at capture time. Re-send /// still drops files that have vanished since (the user sees the staged list @@ -34,6 +35,12 @@ final class TransferRecord { var names: [String] var codeHint: String var bookmarks: [Data] + /// Stored uploads only (R19): the server's id for the upload, which keys + /// its receipt in `StoredTransferVault`. These three are optional so + /// stores written before stored transfers migrate without a schema step. + var storedID: String? + var expiresAt: Date? + var revokedAt: Date? var status: Status { Status(rawValue: statusRaw) ?? .failed } @@ -43,7 +50,8 @@ final class TransferRecord { init( isSend: Bool, status: Status, isText: Bool, fileCount: Int, - totalBytes: Int64, names: [String], codeHint: String, bookmarks: [Data] + totalBytes: Int64, names: [String], codeHint: String, bookmarks: [Data], + storedID: String? = nil, expiresAt: Date? = nil ) { self.date = Date() self.isSend = isSend @@ -54,6 +62,8 @@ final class TransferRecord { self.names = names self.codeHint = codeHint self.bookmarks = bookmarks + self.storedID = storedID + self.expiresAt = expiresAt } } @@ -121,6 +131,11 @@ final class HistoryStore { save("prune") } + func markRevoked(_ record: TransferRecord, at date: Date = .now) { + record.revokedAt = date + save("revoke") + } + func delete(_ record: TransferRecord) { container.mainContext.delete(record) save("delete") diff --git a/app/CrocApp/Support/KeychainStore.swift b/app/CrocApp/Support/KeychainStore.swift index 4b51758..19f791e 100644 --- a/app/CrocApp/Support/KeychainStore.swift +++ b/app/CrocApp/Support/KeychainStore.swift @@ -98,3 +98,21 @@ struct KeychainStore: SecretStore { return true } } + +/// Secrets that never leave the process, for harness runs, which must not +/// touch the real Keychain. +final class MemorySecretStore: SecretStore { + private var items: [String: String] = [:] + + func get(account: String) -> String? { items[account] } + + func set(_ value: String, account: String) -> Bool { + items[account] = value + return true + } + + func delete(account: String) -> Bool { + items[account] = nil + return true + } +} diff --git a/app/CrocApp/Views/TransferStatusView.swift b/app/CrocApp/Views/TransferStatusView.swift index 0a0e08c..4cb9cbb 100644 --- a/app/CrocApp/Views/TransferStatusView.swift +++ b/app/CrocApp/Views/TransferStatusView.swift @@ -75,6 +75,8 @@ struct TransferStatusView: View { transferringView(progress) case .done(let summary, let receivedText): doneView(summary, receivedText: receivedText) + case .stored(let receipt): + storedView(receipt) case .failed(let message): failedView(message) } @@ -361,6 +363,27 @@ struct TransferStatusView: View { } } + // Placeholder until step 41's result screen (QR, Share). + private func storedView(_ receipt: StoredReceipt) -> some View { + VStack(spacing: Spacing.space5) { + StatusBlock( + kind: .success, title: "Uploaded", + detail: + "Link works until \(receipt.expiresAt.formatted(date: .abbreviated, time: .shortened))" + ) + Text(receipt.link) + .font(.body.monospaced()) + .textSelection(.enabled) + Button { + Clipboard.copy(receipt.link) + } label: { + Label("Copy Link", systemImage: "doc.on.doc") + } + Button("Done") { controller.reset() } + .buttonStyle(.borderedProminent) + } + } + private func failedView(_ message: String) -> some View { VStack(spacing: Spacing.space5) { StatusBlock(kind: .error, title: message) diff --git a/app/CrocAppTests/AppSettingsPersistenceTests.swift b/app/CrocAppTests/AppSettingsPersistenceTests.swift index 54f85a3..6615e00 100644 --- a/app/CrocAppTests/AppSettingsPersistenceTests.swift +++ b/app/CrocAppTests/AppSettingsPersistenceTests.swift @@ -33,6 +33,7 @@ import Testing #expect(!s.bothSidesConfirm) #expect(s.whenFileExists == .keepBoth) #expect(s.transport == .auto) + #expect(s.storedServer == "https://getcroc.com") #expect(s.relayKind == .publicDefault) } @@ -50,6 +51,7 @@ import Testing s.bothSidesConfirm = true s.whenFileExists = .replace s.transport = .relay + s.storedServer = "https://store.example" let r = AppSettings(defaults: defaults, secrets: secrets) #expect(r.relayAddress == "relay.example:9009") @@ -63,6 +65,7 @@ import Testing #expect(r.bothSidesConfirm) #expect(r.whenFileExists == .replace) #expect(r.transport == .relay) + #expect(r.storedServer == "https://store.example") } /// A value this build does not know (a later build's policy, a corrupt @@ -109,6 +112,7 @@ import Testing s.autoAccept = true s.whenFileExists = .replace s.transport = .relay + s.storedServer = "https://store.example" s.resetToDefaults() s.zipFolder = true @@ -120,6 +124,8 @@ import Testing #expect(s.whenFileExists == .keepBoth, "reset restores Keep Both") #expect(r.transport == .auto) #expect(s.transport == .auto, "reset restores Automatic") + #expect(r.storedServer == "https://getcroc.com") + #expect(s.storedServer == "https://getcroc.com", "reset restores getcroc.com") #expect(defaults.persistentDomain(forName: suiteName)?.isEmpty ?? true) } } diff --git a/app/CrocAppTests/CodeHintTests.swift b/app/CrocAppTests/CodeHintTests.swift index d2e5c03..7588542 100644 --- a/app/CrocAppTests/CodeHintTests.swift +++ b/app/CrocAppTests/CodeHintTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CrocApp @@ -26,4 +27,18 @@ import Testing @Test func emptyCodeHasNoHint() { #expect(TransferController.codeHint(" ") == "") } + + /// A stored link's id is in its path and its key in the fragment. + @Test(arguments: [ + ("https://getcroc.com/s/AbCdEf0123456789#v1.c2VjcmV0LWtleS1tYXRlcmlhbA", "getcroc.com"), + ("http://localhost:8080/s/AbCdEf0123456789#v1.c2VjcmV0", "localhost"), + ("https://getcroc.com", "getcroc.com"), + ]) + func storedLinkKeepsOnlyTheHost(link: String, hint: String) throws { + let url = try #require(URL(string: link)) + let h = TransferController.codeHint(storedLink: url) + #expect(h == hint) + #expect(!h.contains("AbCdEf")) + #expect(!h.contains("c2Vj")) + } } diff --git a/app/CrocAppTests/FriendlyMessageTests.swift b/app/CrocAppTests/FriendlyMessageTests.swift index 1058da0..7cae2ad 100644 --- a/app/CrocAppTests/FriendlyMessageTests.swift +++ b/app/CrocAppTests/FriendlyMessageTests.swift @@ -47,6 +47,29 @@ import Testing #expect(message(code) == expected) } + /// A store call reports a network failure as `relayUnreachable`; no relay is involved. + @Test(arguments: [EngineFailure.Code.relayUnreachable, .connectionLost]) + func storedNetworkFailureNamesTheServer(code: EngineFailure.Code) { + let copy = TransferController.friendlyMessage( + for: EngineFailure(code: code, message: "x"), cancelRequested: false, + declineRequested: false, stored: true) + #expect( + copy + == "Couldn't reach the transfer server. Check your internet connection and try again." + ) + } + + @Test func storedKeepsStoreCodesAndLocalIntent() { + let expired = TransferController.friendlyMessage( + for: EngineFailure(code: .storeExpired, message: "x"), cancelRequested: false, + declineRequested: false, stored: true) + #expect(expired == "This link has expired, was revoked, or has no downloads left.") + let cancelled = TransferController.friendlyMessage( + for: EngineFailure(code: .relayUnreachable, message: "x"), cancelRequested: true, + declineRequested: false, stored: true) + #expect(cancelled == "Transfer cancelled.") + } + @Test(arguments: ["", "room full", "panic: runtime error\ngoroutine 1 [running]:"]) func neverEchoesEngineText(text: String) { #expect(message(.other, text) == "Transfer failed. Try again, or check your connection.") diff --git a/app/CrocAppTests/StoredTransferVaultTests.swift b/app/CrocAppTests/StoredTransferVaultTests.swift new file mode 100644 index 0000000..76aa427 --- /dev/null +++ b/app/CrocAppTests/StoredTransferVaultTests.swift @@ -0,0 +1,127 @@ +import CrocKit +import Foundation +import Testing + +@testable import CrocApp + +/// Receipts carry the upload token and the link key, so they live only in the +/// Keychain (R12d, R19). Every test runs over an in-memory `SecretStore`. +@MainActor +@Suite struct StoredTransferVaultTests { + private static let account = "storedReceipts" + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func receipt(_ id: String, expiresIn seconds: TimeInterval = 3600) -> StoredReceipt { + StoredReceipt( + id: id, origin: "https://getcroc.com", uploadToken: "token-\(id)", + link: "https://getcroc.com/s/\(id)#v1.key-\(id)", + expiresAt: now.addingTimeInterval(seconds), downloads: 1) + } + + @Test func receiptSurvivesARelaunch() { + let store = FakeSecretStore() + let r = receipt("a") + #expect(StoredTransferVault(secrets: store, now: now).add(r)) + + let reopened = StoredTransferVault(secrets: store, now: now) + #expect(reopened.receipt(id: "a") == r) + #expect(reopened.receipt(id: "b") == nil) + } + + @Test func removeDeletesTheItemWhenEmpty() { + let store = FakeSecretStore() + let vault = StoredTransferVault(secrets: store, now: now) + vault.add(receipt("a")) + vault.add(receipt("b")) + + #expect(vault.remove(id: "a")) + #expect(StoredTransferVault(secrets: store, now: now).receipt(id: "a") == nil) + #expect(StoredTransferVault(secrets: store, now: now).receipt(id: "b") != nil) + + vault.remove(id: "b") + #expect(store.items.isEmpty, "no empty item left behind") + #expect(vault.remove(id: "missing"), "removing what is not there succeeds") + } + + @Test func addingTheSameIDReplacesIt() { + let store = FakeSecretStore() + let vault = StoredTransferVault(secrets: store, now: now) + vault.add(receipt("a", expiresIn: 60)) + vault.add(receipt("a", expiresIn: 600)) + + let reopened = StoredTransferVault(secrets: store, now: now) + #expect(reopened.receipt(id: "a")?.expiresAt == now.addingTimeInterval(600)) + vault.remove(id: "a") + #expect(store.items.isEmpty, "only one copy was stored") + } + + @Test func expiredReceiptsArePurgedAtLaunch() { + let store = FakeSecretStore() + let vault = StoredTransferVault(secrets: store, now: now) + vault.add(receipt("short", expiresIn: 60)) + vault.add(receipt("long", expiresIn: 7200)) + + let later = StoredTransferVault(secrets: store, now: now.addingTimeInterval(3600)) + #expect(later.receipt(id: "short") == nil) + #expect(later.receipt(id: "long") != nil) + // The purge was written back, not only applied in memory. + let fresh = StoredTransferVault(secrets: store, now: now) + #expect(fresh.receipt(id: "short") == nil) + } + + /// A failed Keychain write must not destroy receipts stored earlier, and + /// the new one still works for this launch. + @Test func failedWriteKeepsEarlierReceipts() { + let store = FakeSecretStore() + let vault = StoredTransferVault(secrets: store, now: now) + vault.add(receipt("a")) + store.failWrites = true + + #expect(!vault.add(receipt("b"))) + #expect(vault.receipt(id: "b") != nil, "usable until the app quits") + + let reopened = StoredTransferVault(secrets: store, now: now) + #expect(reopened.receipt(id: "a") != nil) + #expect(reopened.receipt(id: "b") == nil) + } + + @Test func unreadableItemStartsEmpty() { + let store = FakeSecretStore() + store.items[Self.account] = "not json" + + let vault = StoredTransferVault(secrets: store, now: now) + #expect(vault.receipt(id: "a") == nil) + #expect(vault.add(receipt("a"))) + #expect(StoredTransferVault(secrets: store, now: now).receipt(id: "a") != nil) + } + + /// The token and the link may sit in the vault's own item and nowhere else. + @Test func secretsStayInTheVaultItem() { + let store = FakeSecretStore() + StoredTransferVault(secrets: store, now: now).add(receipt("a")) + #expect(Array(store.items.keys) == [Self.account]) + } + + @Test func revokeNeedsAnUnexpiredUnrevokedReceipt() throws { + let vault = StoredTransferVault(secrets: FakeSecretStore(), now: now) + vault.add(receipt("a")) + // Nothing is written to these defaults: the test sets no setting. + let defaults = try #require(UserDefaults(suiteName: "CrocAppTests.\(UUID().uuidString)")) + let controller = TransferController( + settings: AppSettings(defaults: defaults, secrets: FakeSecretStore()), vault: vault) + func record(_ id: String?, expiresIn seconds: TimeInterval = 3600) -> TransferRecord { + TransferRecord( + isSend: true, status: .completed, isText: false, fileCount: 1, totalBytes: 1, + names: ["a.txt"], codeHint: "getcroc.com", bookmarks: [], storedID: id, + expiresAt: id == nil ? nil : now.addingTimeInterval(seconds)) + } + + #expect(controller.canRevoke(record("a"), now: now)) + #expect(!controller.canRevoke(record(nil), now: now), "a live send") + #expect(!controller.canRevoke(record("b"), now: now), "no receipt on this device") + #expect(!controller.canRevoke(record("a", expiresIn: -1), now: now), "expired") + let revoked = record("a") + revoked.revokedAt = now + #expect(!controller.canRevoke(revoked, now: now), "already revoked") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1f0ac61..5b1fd63 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,8 @@ CrocKit (Swift package: CrocEngine actor, DelegateBridge, Models.swift) │ DelegateBridge only yields into the stream — no MainActor hop here ▼ TransferController (@MainActor @Observable, app/CrocApp/Models/) - │ Phase enum; intents (startSend, startReceive, respond, cancel) + │ Phase enum; intents (startSend, startReceive, startStoreSend, + │ startStoreReceive, respond, cancel, revoke) ▼ SwiftUI views (app/CrocApp/Views/) switch over controller.phase; call intents; nothing else touches CrocKit @@ -122,6 +123,7 @@ Persistence: | Power settings (F13-F19, F41) | `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` | +| Stored-upload receipts (R19) | Keychain, one item `storedReceipts` | `StoredTransferVault`; purged after expiry or revoke; in memory under `AutoVerify` | | Share-extension handoff | App Group container (`group.com.bakirgdev.CrocApp`) | `ShareInbox` reads, `ShareStager` writes; `ShareInbox/batch-/` + `manifest.json` | | Onboarding seen flag | `UserDefaults` via `@AppStorage` | `onboarding.seen`, read in `ContentView` | diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 33b6288..c8c1f2b 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -50,9 +50,10 @@ Terms a new contributor or a fresh AI session hits in this repo and cannot infer | `CrocApp Direct` / `DIRECT` | the Developer ID (DMG) macOS target, bundle ID `com.bakirgdev.CrocApp.direct`, arm64 only; builds the `app/CrocApp/` sources plus `app/CrocAppDirect/` (Sparkle updater) under the Swift condition `DIRECT`. Product is `CrocApp.app`, same name as the App Store target (`docs/BUILDING.md` section 7). | | output folder store | `OutputFolderStore` (`app/CrocApp/Models/OutputFolderStore.swift`), tracks the receive destination folder and its security-scope bookmark; defaults to `~/Downloads/CrocApp` on macOS, app Documents on iOS. | | Keep Both / Replace | the receive file policy (`CrocKit.FilePolicy`, `AppSettings.whenFileExists`, Go consts `PolicyKeepBoth`/`PolicyReplace`) for a file whose name is taken: Keep Both saves the new one as `name (1).ext` (croc `--rename`), Replace overwrites and resumes (croc `--overwrite`). Chosen on the incoming sheet, or by the setting under auto-accept (`docs/knowledge/crocmobile-bridge.md`). | -| `Phase` | the enum on `TransferController` driving every view: `idle` → `starting` → `waiting`/`connecting` → `confirmSend` or `incoming` → `transferring` → `done` or `failed` (`app/CrocApp/Models/TransferController.swift`). | +| `Phase` | the enum on `TransferController` driving every view: `idle` → `starting` → `waiting`/`connecting` → `confirmSend` or `incoming` → `transferring` → `done`, `stored` (a stored upload finished) or `failed` (`app/CrocApp/Models/TransferController.swift`). | | security-scoped resource | an Apple sandbox URL (from `fileImporter` or a bookmark) that needs `startAccessingSecurityScopedResource()` before use; both `TransferController` and `HistoryView` call it before touching a URL. | | share extension (appex) | `CrocShare`, the iOS-only app extension (`app/CrocShare/`) that stages shared files into the App Group for later send; ~120 MB memory cap, no long-running work (`docs/knowledge/app-ui-architecture.md`). | +| `StoredTransferVault` | the Keychain store of stored-upload receipts (`app/CrocApp/Models/StoredTransferVault.swift`): upload token and link, which History needs to revoke and must never hold itself. | | `SwiftData` `TransferRecord` | the `@Model` type (`app/CrocApp/Models/TransferHistory.swift`) persisting one finished/failed transfer for local-only history (F12); never stores file contents or the full code phrase. | | `TransferLink` | the one parser (`app/CrocApp/Models/TransferLink.swift`) for every form a code arrives in: bare code, `crocapp://receive?code=`, `croc://`, `https://crocapp.dev/r/#`, `https://getcroc.com/?code=`. `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. Opening one prefills Receive and never connects. | | trust badge | `TrustBadge` view (`app/CrocApp/Views/TrustBadge.swift`), shown in waiting/transferring/confirm-send screens, reading `TransferController.activeRelay` captured at transfer start (F36). | diff --git a/docs/knowledge/app-ui-architecture.md b/docs/knowledge/app-ui-architecture.md index abb3cf5..e31f7c2 100644 --- a/docs/knowledge/app-ui-architecture.md +++ b/docs/knowledge/app-ui-architecture.md @@ -24,7 +24,7 @@ Construction order in `CrocAppApp.init` matters: `AppSettings` first, then `Tran `@MainActor @Observable`, and the only consumer of `CrocKit` in the app. -`Phase`: `idle` → `starting` → `waiting(code)` / `connecting` → `confirmSend` | `incoming(FileList, conflicts, blocked)` → `transferring(TransferProgress)` → `done(Summary, receivedText?)` | `failed(String)`. Views switch on it. `reset()` is unconditional — no guard — but is only meant to be called from a terminal phase, which is the only place the UI offers it. +`Phase`: `idle` → `starting` → `waiting(code)` / `connecting` → `confirmSend` | `incoming(FileList, conflicts, blocked)` → `transferring(TransferProgress)` → `done(Summary, receivedText?)` | `stored(StoredReceipt)` | `failed(String)`. Views switch on it. `reset()` is unconditional — no guard — but is only meant to be called from a terminal phase, which is the only place the UI offers it. ### Event handling, learned the hard way @@ -63,9 +63,19 @@ Relay strings use `""` to mean "croc default" (the `TextField` prompt reads "Pub `Phase.confirmSend` is the sender side of croc `--ask`: entered at `.connected` when `bothSidesConfirm` is on, progress-guarded like `.incoming`, accept is `respond(true)`, decline is `cancel()`. +### Stored transfers (R18-R20) + +`isStoredTransfer` marks a transfer that goes through a stored-transfer server instead of a croc peer; it reuses `run()`, `handle(_:)`, the `BGContinuedProcessingTask` wrap and the history record. +- `startStoreSend(urls:downloads:lifetimeSeconds:)` uploads to `AppSettings.storedServer` (UserDefaults `settings.storedServer`, default `https://getcroc.com`). The engine sends `.progress`, then `.stored(receipt)` just before `.done`; the controller puts the receipt in the vault, keeps its id and expiry for the record, and turns `.done` into `Phase.stored(receipt)`. +- `startStoreReceive(link:into:folderIsScoped:)` takes the same path as a live receive: `.incoming` with the conflict scan, then `respond(accept:policy:)`. Auto-accept never applies; the engine always waits. +- A stored transfer cannot resume, so no failure copy offers it. A background expiry reads "Upload interrupted … Try again" (send) or "Download interrupted … Open the link again" (receive), concern 4. `friendlyMessage(…, stored: true)` names the transfer server for `relayUnreachable` and `connectionLost`, which is how a store call reports a network failure. +- `revoke(_ record:)` (throws `EngineFailure`) calls `CrocEngine.revokeStored`, which needs no free engine slot, then drops the receipt and sets `revokedAt`. `canRevoke(_:)` is true for an unexpired, unrevoked record whose receipt this device still holds. + +`Models/StoredTransferVault.swift` keeps receipts (id, origin, upload token, link, expiry) in the Keychain, all in one item (`storedReceipts`, a JSON array), because `SecretStore` cannot list accounts. Expired receipts are purged when the vault opens (each launch). A failed Keychain write is logged, never lost silently, and leaves the receipts already stored untouched; the new receipt then lasts until the app quits. Harness runs and previews use `MemorySecretStore`. History never holds the token or link: a stored record's `codeHint` is the server host (`codeHint(storedLink:)`). + ## History -`Models/TransferHistory.swift` — `TransferRecord` is a SwiftData `@Model` (fields including `statusRaw` string storage for the enum). `HistoryStore` is `@MainActor @Observable`, wraps `container.mainContext`, exposes `add` / `delete` / `clear` / `recordCount`. The container is in-memory when `AutoVerify.isHarnessRun`, and is injected twice: as `.environment(history)` and as `.modelContainer(history.container)`. +`Models/TransferHistory.swift` — `TransferRecord` is a SwiftData `@Model` (fields including `statusRaw` string storage for the enum). Stored uploads add optional `storedID`, `expiresAt` and `revokedAt`; optional so a store written by an older build opens without a migration step. `HistoryStore` is `@MainActor @Observable`, wraps `container.mainContext`, exposes `add` / `delete` / `clear` / `recordCount` / `markRevoked`. The container is in-memory when `AutoVerify.isHarnessRun`, and is injected twice: as `.environment(history)` and as `.modelContainer(history.container)`. `Views/HistoryView.swift` (`Route.history`, clock toolbar icon on both platforms): `@Query` sorted date-descending, swipe and context delete, Clear behind a confirmation, and "Send Again" for send records that carry bookmarks — resolve bookmarks, then `router.openSend`. diff --git a/docs/known-issues.md b/docs/known-issues.md index 7a4b1b7..67dd55b 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -71,4 +71,4 @@ Engine and bridge invariants live in `knowledge/crocmobile-bridge.md`; UI state- - **The macOS local-network denial path is hard to exercise during development.** Accepted: a development-procedure note, not an app defect. Enforcement reportedly only engages for builds resident in `/Applications` (single developer-forum thread FB16077972, not Apple documentation — strong inference, not verified here), so a DerivedData-launched Debug build can sit at `.ready` and read as falsely granted. Copy the app to `/Applications` before trying to reproduce a denial. - **`AppRouter` is a singleton with no reset hook, so multiple macOS windows share one navigation path.** Accepted: per-window navigation needs `@FocusedValue` plumbing to reach `AppDelegate` and `Commands`, which sit outside the environment graph, and multi-window reachability has never actually been observed. Revisit if someone reports it. - **croc prints the code phrase, and a non-default relay password, to stderr on every send.** croc builds the "On the other computer run" line (`croc `, plus `--pass ` when the password is not the default) with `Quiet=false`, which crocmobile needs (`knowledge/crocmobile-bridge.md`), and its debug logger prints file names and paths. Nothing in the app captures stderr: a build launched from Finder, the Dock or the Home Screen has it connected to `/dev/null` (strong inference, not measured on a device), so it reaches no log store or crash report. A harness run or an Xcode debug session does capture it (`verify-app-mac.sh` writes it to its temp logs). Accepted for 1.0; revisit if croc gains a way to silence that line without `Quiet`. -- **Stored-transfer links parse but cannot be received.** `TransferLink` recognises `https:///s/#v1.` (R22), but nothing receives it until the stored-transfer change lands; `ReceiveView` prefills only from a code and ignores it. Deferred on purpose, not a defect. +- **Stored-transfer links parse but cannot be received.** `TransferLink` recognises `https:///s/#v1.` (R22), and `TransferController.startStoreReceive` can receive it, but `ReceiveView` prefills only from a code and ignores it until P10 step 41 wires the preview. Deferred on purpose, not a defect. From 80409664247e33c00710032f647b6ac5526fffb6 Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Thu, 15 Oct 2026 00:21:52 +0200 Subject: [PATCH 3/8] Add Send as link, the link result screen, stored-link receive, History revoke and the stored-server setting --- app/CrocApp/Models/AppSettings.swift | 18 +++ app/CrocApp/Models/TransferController.swift | 2 +- app/CrocApp/Models/TransferLink.swift | 2 +- app/CrocApp/Support/Clipboard.swift | 12 +- app/CrocApp/Views/HistoryView.swift | 64 +++++++++- app/CrocApp/Views/PowerSettingsSections.swift | 26 +++++ app/CrocApp/Views/QRCodeView.swift | 3 +- app/CrocApp/Views/ReceiveView.swift | 74 +++++++++--- app/CrocApp/Views/SendView.swift | 109 ++++++++++++++++-- app/CrocApp/Views/TransferStatusView.swift | 54 +++++++-- .../StoredServerValidationTests.swift | 30 +++++ 11 files changed, 350 insertions(+), 44 deletions(-) create mode 100644 app/CrocAppTests/StoredServerValidationTests.swift diff --git a/app/CrocApp/Models/AppSettings.swift b/app/CrocApp/Models/AppSettings.swift index d21246e..af1c3e2 100644 --- a/app/CrocApp/Models/AppSettings.swift +++ b/app/CrocApp/Models/AppSettings.swift @@ -49,6 +49,24 @@ final class AppSettings { var storedServer: String { didSet { save(storedServer, "settings.storedServer") } } static let defaultStoredServer = StoredSendOptions().server + /// Why `text` cannot be a stored-transfer server, or nil when it can (R12e). + /// Mirrors the engine: `https`, or `http` on loopback only, and nothing + /// after the host. + static func storedServerError(_ text: String) -> String? { + let address = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let parts = URLComponents(string: address), + let scheme = parts.scheme?.lowercased(), scheme == "https" || scheme == "http", + let host = parts.host?.lowercased().trimmingCharacters(in: ["[", "]"]), !host.isEmpty + else { return "Enter a server address such as \(defaultStoredServer)." } + guard parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, + parts.path.isEmpty || parts.path == "/" + else { return "Enter the server's address only, with nothing after the host name." } + if scheme == "http", !TransferLink.loopbackHosts.contains(host) { + return "The server must use https. Plain http works only for localhost." + } + return nil + } + init(defaults d: UserDefaults = .standard, secrets: any SecretStore = KeychainStore()) { defaults = d self.secrets = secrets diff --git a/app/CrocApp/Models/TransferController.swift b/app/CrocApp/Models/TransferController.swift index 61391e8..b31fe36 100644 --- a/app/CrocApp/Models/TransferController.swift +++ b/app/CrocApp/Models/TransferController.swift @@ -141,7 +141,7 @@ final class TransferController { activeRelay = settings.relayKind sendConfirmArmed = false autoAcceptActive = false - let server = settings.storedServer + let server = settings.storedServer.trimmingCharacters(in: .whitespacesAndNewlines) beginFileSend( urls: urls, codeHint: URL(string: server).map(Self.codeHint(storedLink:)) ?? "") let paths = urls.map(\.path) diff --git a/app/CrocApp/Models/TransferLink.swift b/app/CrocApp/Models/TransferLink.swift index 3bcb2f5..b0622bf 100644 --- a/app/CrocApp/Models/TransferLink.swift +++ b/app/CrocApp/Models/TransferLink.swift @@ -11,7 +11,7 @@ enum TransferLink: Equatable { static let webHost = "crocapp.dev" static let browserReceiveHost = "getcroc.com" - private static let loopbackHosts: Set = ["localhost", "127.0.0.1", "::1"] + static let loopbackHosts: Set = ["localhost", "127.0.0.1", "::1"] static func parse(_ raw: String) -> TransferLink? { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/app/CrocApp/Support/Clipboard.swift b/app/CrocApp/Support/Clipboard.swift index b11d995..d36281e 100644 --- a/app/CrocApp/Support/Clipboard.swift +++ b/app/CrocApp/Support/Clipboard.swift @@ -7,9 +7,17 @@ import AppKit #endif enum Clipboard { - static func copy(_ string: String) { + /// `thisDeviceOnly` keeps the text off Universal Clipboard (iOS only; the + /// macOS pasteboard has no such option). For a stored link, whose + /// fragment is the decryption key. + static func copy(_ string: String, thisDeviceOnly: Bool = false) { #if os(iOS) - UIPasteboard.general.string = string + if thisDeviceOnly { + UIPasteboard.general.setItems( + [[UIPasteboard.typeAutomatic: string]], options: [.localOnly: true]) + } else { + UIPasteboard.general.string = string + } #else NSPasteboard.general.clearContents() NSPasteboard.general.setString(string, forType: .string) diff --git a/app/CrocApp/Views/HistoryView.swift b/app/CrocApp/Views/HistoryView.swift index c51d618..15f9104 100644 --- a/app/CrocApp/Views/HistoryView.swift +++ b/app/CrocApp/Views/HistoryView.swift @@ -1,16 +1,21 @@ +import CrocKit import SwiftData import SwiftUI /// F12: local transfer history. Re-send re-stages a past send's files via /// AppRouter (same path as dock drops); records whose files vanished get an -/// explanatory alert instead of a silent no-op. +/// explanatory alert instead of a silent no-op. Stored uploads (R19) show when +/// their link expires and can be revoked here. struct HistoryView: View { + @Environment(TransferController.self) private var controller @Environment(AppRouter.self) private var router @Environment(HistoryStore.self) private var history @Query(sort: \TransferRecord.date, order: .reverse) private var records: [TransferRecord] @State private var confirmClear = false @State private var resendUnavailable = false + @State private var revokeCandidate: TransferRecord? + @State private var revokeFailure: String? var body: some View { Group { @@ -35,11 +40,19 @@ struct HistoryView: View { if record.canResend { Button("Send Again") { resend(record) } } + if controller.canRevoke(record) { + Button("Revoke Link", role: .destructive) { + revokeCandidate = record + } + } Button("Delete", role: .destructive) { history.delete(record) } } #if os(iOS) .swipeActions(edge: .trailing) { Button("Delete", role: .destructive) { history.delete(record) } + if controller.canRevoke(record) { + Button("Revoke") { revokeCandidate = record }.tint(.orange) + } } .swipeActions(edge: .leading) { if record.canResend { @@ -63,6 +76,18 @@ struct HistoryView: View { } message: { Text("Removes the list only — received files stay where they are.") } + .confirmationDialog( + "Revoke this link?", isPresented: revokeDialogShown, presenting: revokeCandidate + ) { record in + Button("Revoke Link", role: .destructive) { revoke(record) } + } message: { _ in + Text("The files are deleted from the server and the link stops working for everyone.") + } + .alert("Couldn't revoke the link", isPresented: revokeFailureShown) { + Button("OK", role: .cancel) {} + } message: { + Text(revokeFailure ?? "") + } .alert("Those files aren't available anymore", isPresented: $resendUnavailable) { Button("OK", role: .cancel) {} } message: { @@ -70,6 +95,27 @@ struct HistoryView: View { } } + private var revokeDialogShown: Binding { + Binding(get: { revokeCandidate != nil }, set: { if !$0 { revokeCandidate = nil } }) + } + + private var revokeFailureShown: Binding { + Binding(get: { revokeFailure != nil }, set: { if !$0 { revokeFailure = nil } }) + } + + private func revoke(_ record: TransferRecord) { + Task { + do { + try await controller.revoke(record) + } catch let failure as EngineFailure { + revokeFailure = TransferController.friendlyMessage( + for: failure, cancelRequested: false, declineRequested: false, stored: true) + } catch { + revokeFailure = "Try again in a moment." + } + } + } + private func resend(_ record: TransferRecord) { // See SecurityScopedBookmark.resolveIfReachable for why the scope // must be started before the existence probe -- startSend opens its @@ -108,6 +154,11 @@ private struct HistoryRow: View { } .font(.caption) .foregroundStyle(.secondary) + if let linkState { + Text(linkState) + .font(.caption) + .foregroundStyle(.secondary) + } } Spacer() statusBadge @@ -116,6 +167,17 @@ private struct HistoryRow: View { .accessibilityElement(children: .combine) } + /// Where a stored upload's link stands. Revoked beats expired. + private var linkState: String? { + guard record.storedID != nil else { return nil } + if let revokedAt = record.revokedAt { + return "Link revoked \(revokedAt.formatted(date: .abbreviated, time: .shortened))" + } + guard let expiresAt = record.expiresAt else { return nil } + let when = expiresAt.formatted(date: .abbreviated, time: .shortened) + return expiresAt > .now ? "Link expires \(when)" : "Link expired \(when)" + } + private var title: String { if record.isText { return "Text snippet" } guard let first = record.names.first else { diff --git a/app/CrocApp/Views/PowerSettingsSections.swift b/app/CrocApp/Views/PowerSettingsSections.swift index aeb103e..42518ac 100644 --- a/app/CrocApp/Views/PowerSettingsSections.swift +++ b/app/CrocApp/Views/PowerSettingsSections.swift @@ -31,6 +31,32 @@ struct PowerSettingsSections: View { #endif } + Section { + TextField( + "Server", text: $settings.storedServer, + prompt: Text(AppSettings.defaultStoredServer) + ) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + #endif + if let error = AppSettings.storedServerError(settings.storedServer) { + StatusBanner(kind: .error, title: error) + } + if settings.storedServer != AppSettings.defaultStoredServer { + Button("Reset to \(AppSettings.defaultStoredServer)") { + settings.storedServer = AppSettings.defaultStoredServer + } + } + } header: { + Text("Send as link") + } footer: { + Text( + "Send as link uploads encrypted files to this server. The server holds them until the link expires and cannot read them; the key is in the link." + ) + } + Section { Picker("Connection", selection: $settings.transport) { Text("Automatic").tag(Transport.auto) diff --git a/app/CrocApp/Views/QRCodeView.swift b/app/CrocApp/Views/QRCodeView.swift index 5089088..865ece9 100644 --- a/app/CrocApp/Views/QRCodeView.swift +++ b/app/CrocApp/Views/QRCodeView.swift @@ -6,6 +6,7 @@ import SwiftUI /// the scanner side accepts both the bare code and the prefixed form. struct QRCodeView: View { let content: String + var label = "QR code for the transfer code" @State private var image: CGImage? var body: some View { @@ -37,7 +38,7 @@ struct QRCodeView: View { // layers; .shadowMd() stacks two .shadow() calls to // reproduce both rather than approximate with one. .shadowMd() - .accessibilityLabel("QR code for the transfer code") + .accessibilityLabel(label) } else { // Must be a real view, not the implicit EmptyView: SwiftUI // gives EmptyView no identity in the render tree, so the diff --git a/app/CrocApp/Views/ReceiveView.swift b/app/CrocApp/Views/ReceiveView.swift index f022858..8ea8ab2 100644 --- a/app/CrocApp/Views/ReceiveView.swift +++ b/app/CrocApp/Views/ReceiveView.swift @@ -8,6 +8,8 @@ struct ReceiveView: View { @Environment(AppRouter.self) private var router @State private var code = "" + /// A stored link (R20) replaces the code field until cleared. + @State private var storedLink: URL? @State private var showScanner = false @State private var showFolderPicker = false @@ -23,7 +25,14 @@ struct ReceiveView: View { .onChange(of: router.pendingReceive, initial: true) { _, link in guard let link else { return } router.pendingReceive = nil - if case .code(let linked) = link { code = linked } + switch link { + case .code(let linked): + code = linked + storedLink = nil + case .stored(let url): + storedLink = url + code = "" + } } } } @@ -32,15 +41,21 @@ struct ReceiveView: View { private var form: some View { VStack(spacing: 16) { - CodeField( - placeholder: "Code phrase", - text: $code, - isInvalid: !code.isEmpty && code.count < EngineConstraints.minCodeLength - ) { pasted in - if let link = TransferLink.parse(pasted) { router.open(link) } - } - .onChange(of: code) { _, typed in - if typed.contains("://"), let link = TransferLink.parse(typed) { router.open(link) } + if let storedLink { + storedLinkRow(storedLink) + } else { + CodeField( + placeholder: "Code phrase", + text: $code, + isInvalid: !code.isEmpty && code.count < EngineConstraints.minCodeLength + ) { pasted in + if let link = TransferLink.parse(pasted) { router.open(link) } + } + .onChange(of: code) { _, typed in + if typed.contains("://"), let link = TransferLink.parse(typed) { + router.open(link) + } + } } #if os(iOS) @@ -85,10 +100,16 @@ struct ReceiveView: View { .font(.callout) Button { - controller.startReceive( - code: code.trimmingCharacters(in: .whitespacesAndNewlines), - into: outputFolder.url, - folderIsScoped: outputFolder.isUserSelected) + if let storedLink { + controller.startStoreReceive( + link: storedLink, into: outputFolder.url, + folderIsScoped: outputFolder.isUserSelected) + } else { + controller.startReceive( + code: code.trimmingCharacters(in: .whitespacesAndNewlines), + into: outputFolder.url, + folderIsScoped: outputFolder.isUserSelected) + } } label: { Label("Receive", systemImage: "arrow.down.circle.fill") .frame(maxWidth: .infinity) @@ -96,10 +117,31 @@ struct ReceiveView: View { .buttonStyle(.borderedProminent) .controlSize(.large) .disabled( - code.trimmingCharacters(in: .whitespacesAndNewlines).count - < EngineConstraints.minCodeLength) + storedLink == nil + && code.trimmingCharacters(in: .whitespacesAndNewlines).count + < EngineConstraints.minCodeLength + ) } .padding() .frame(maxWidth: 480) } + + private func storedLinkRow(_ url: URL) -> some View { + let host = TransferController.codeHint(storedLink: url) + return HStack(spacing: Spacing.space3) { + Label("Download link from \(host)", systemImage: "link") + .lineLimit(2) + Spacer() + Button("Clear") { storedLink = nil } + } + .padding(.horizontal, Spacing.space4) + .frame(minHeight: ControlHeight.large) + .background( + Color.surfaceBase, in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.md, style: .continuous) + .strokeBorder(Color.separatorToken, lineWidth: BorderWidth.hairline) + ) + } } diff --git a/app/CrocApp/Views/SendView.swift b/app/CrocApp/Views/SendView.swift index 72fd973..4be64e2 100644 --- a/app/CrocApp/Views/SendView.swift +++ b/app/CrocApp/Views/SendView.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers struct SendView: View { @Environment(TransferController.self) private var controller @Environment(AppRouter.self) private var router + @Environment(AppSettings.self) private var settings enum Mode: String, CaseIterable { case files = "Files" @@ -18,6 +19,24 @@ struct SendView: View { @State private var showFileImporter = false @State private var showFolderImporter = false @State private var isDropTargeted = false + @State private var sendAsLink = false + @State private var linkLifetime = LinkLifetime.day + @State private var linkDownloads = 1 + + /// How long a stored upload stays on the server (R18). + private enum LinkLifetime: Int, CaseIterable { + case hour = 3_600 + case day = 86_400 + case week = 604_800 + + var label: String { + switch self { + case .hour: "1 hour" + case .day: "1 day" + case .week: "7 days" + } + } + } var body: some View { Group { @@ -48,18 +67,26 @@ struct SendView: View { textSection } - TextField( - "Custom code (optional, at least \(EngineConstraints.minCodeLength) characters)", - text: $customCode - ) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled() - #if os(iOS) - .textInputAutocapitalization(.never) - #endif + linkOptions + + if !linkSelected { + TextField( + "Custom code (optional, at least \(EngineConstraints.minCodeLength) characters)", + text: $customCode + ) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } Button { - if mode == .files { + if linkSelected { + controller.startStoreSend( + urls: pickedURLs, downloads: linkDownloads, + lifetimeSeconds: linkLifetime.rawValue) + } else if mode == .files { controller.startSend( urls: pickedURLs, customCode: customCode.trimmingCharacters(in: .whitespaces)) @@ -68,8 +95,11 @@ struct SendView: View { text, customCode: customCode.trimmingCharacters(in: .whitespaces)) } } label: { - Label("Send", systemImage: "arrow.up.circle.fill") - .frame(maxWidth: .infinity) + Label( + linkSelected ? "Upload" : "Send", + systemImage: linkSelected ? "link.circle.fill" : "arrow.up.circle.fill" + ) + .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -79,7 +109,62 @@ struct SendView: View { .frame(maxWidth: LayoutCap.contentMaxWidth) } + /// Why "Send as link" cannot be used for what is staged, or nil (R18). + private var linkUnavailableReason: String? { + if mode == .text { return "Text can't be sent as a link. Switch to Files." } + if pickedURLs.contains(where: Self.isFolder) { + return "Folders can't be sent as a link. Remove the folders, or send them with a code." + } + return nil + } + + private var linkSelected: Bool { sendAsLink && linkUnavailableReason == nil } + + private static func isFolder(_ url: URL) -> Bool { + url.hasDirectoryPath + || (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + + @ViewBuilder private var linkOptions: some View { + VStack(alignment: .leading, spacing: Spacing.space3) { + Toggle("Send as link", isOn: $sendAsLink) + .disabled(linkUnavailableReason != nil) + if let reason = linkUnavailableReason { + Text(reason).font(.caption).foregroundStyle(.secondary) + } else if linkSelected { + Picker("Link lasts", selection: $linkLifetime) { + ForEach(LinkLifetime.allCases, id: \.self) { Text($0.label) } + } + Stepper(value: $linkDownloads, in: 1...100) { + Text("Downloads allowed: \(linkDownloads)") + } + if let error = AppSettings.storedServerError(settings.storedServer) { + StatusBanner(kind: .error, title: error + " Change it in Settings.") + } else { + Text( + "Files are encrypted on this device and uploaded to \(serverHost). Anyone with the link can download them until it expires or runs out." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("Upload encrypted files and share a link instead of a code.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var serverHost: String { + URL(string: settings.storedServer.trimmingCharacters(in: .whitespacesAndNewlines)) + .map(TransferController.codeHint(storedLink:)) ?? settings.storedServer + } + private var canStart: Bool { + if linkSelected { + return !pickedURLs.isEmpty + && AppSettings.storedServerError(settings.storedServer) == nil + } let codeOK = customCode.isEmpty || customCode.trimmingCharacters(in: .whitespaces).count diff --git a/app/CrocApp/Views/TransferStatusView.swift b/app/CrocApp/Views/TransferStatusView.swift index 4cb9cbb..c84bfc9 100644 --- a/app/CrocApp/Views/TransferStatusView.swift +++ b/app/CrocApp/Views/TransferStatusView.swift @@ -238,7 +238,7 @@ struct TransferStatusView: View { .font(.system(size: 18, weight: .semibold)) .foregroundStyle(Color.accentFill) .accessibilityHidden(true) - Text(controller.direction == .send ? "Sending" : "Receiving") + Text(transferHeading) .font(.headline) } VStack(alignment: .leading, spacing: Spacing.space3) { @@ -281,7 +281,25 @@ struct TransferStatusView: View { } .accessibilityElement(children: .combine) .accessibilityValue(percent(fraction(p.bytesFinished + p.fileSent, of: p.totalSize))) - TrustBadge(relay: controller.activeRelay) + if controller.isStoredTransfer { + // A stored transfer meets no relay, so the relay badge would + // describe a path it does not take. + Text("Encrypted on this device. The server only holds the encrypted files.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } else { + TrustBadge(relay: controller.activeRelay) + } + } + } + + private var transferHeading: String { + switch (controller.direction, controller.isStoredTransfer) { + case (.send, false): "Sending" + case (.send, true): "Uploading" + case (.receive, false): "Receiving" + case (.receive, true): "Downloading" } } @@ -363,22 +381,38 @@ struct TransferStatusView: View { } } - // Placeholder until step 41's result screen (QR, Share). private func storedView(_ receipt: StoredReceipt) -> some View { VStack(spacing: Spacing.space5) { StatusBlock( - kind: .success, title: "Uploaded", + kind: .success, title: "Link ready", detail: - "Link works until \(receipt.expiresAt.formatted(date: .abbreviated, time: .shortened))" + "Works until \(receipt.expiresAt.formatted(date: .abbreviated, time: .shortened)), for \(receipt.downloads) download(s)" ) + QRCodeView(content: receipt.link, label: "QR code for the download link") Text(receipt.link) - .font(.body.monospaced()) + .font(.caption.monospaced()) + .lineLimit(3) + .truncationMode(.middle) .textSelection(.enabled) - Button { - Clipboard.copy(receipt.link) - } label: { - Label("Copy Link", systemImage: "doc.on.doc") + .accessibilityLabel("Download link") + HStack(spacing: Spacing.space5) { + Button { + Clipboard.copy(receipt.link, thisDeviceOnly: true) + } label: { + Label("Copy Link", systemImage: "doc.on.doc") + } + if let url = URL(string: receipt.link) { + ShareLink(item: url) { + Label("Share Link", systemImage: "square.and.arrow.up") + } + } } + Text( + "Anyone who gets this link can download the files until it expires or runs out. Revoke it any time in History." + ) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) Button("Done") { controller.reset() } .buttonStyle(.borderedProminent) } diff --git a/app/CrocAppTests/StoredServerValidationTests.swift b/app/CrocAppTests/StoredServerValidationTests.swift new file mode 100644 index 0000000..c6e42c3 --- /dev/null +++ b/app/CrocAppTests/StoredServerValidationTests.swift @@ -0,0 +1,30 @@ +import Testing + +@testable import CrocApp + +@Suite struct StoredServerValidationTests { + @Test(arguments: [ + "https://getcroc.com", "https://getcroc.com/", " https://example.org:8443 ", + "http://localhost:8080", "http://127.0.0.1:9", "http://[::1]:8080", + ]) + func acceptsAnOrigin(_ text: String) { + #expect(AppSettings.storedServerError(text) == nil) + } + + @Test(arguments: [ + "http://example.com", "http://localhost.example.com", "ftp://getcroc.com", "getcroc.com", + "", " ", "https://", "https://getcroc.com/s/abc", "https://getcroc.com?x=1", + "https://getcroc.com#frag", "https://user:pw@getcroc.com", + ]) + func rejectsAnythingElse(_ text: String) { + #expect(AppSettings.storedServerError(text) != nil) + } + + @Test func plainHttpErrorNamesTheRule() { + #expect(AppSettings.storedServerError("http://example.com")?.contains("https") == true) + } + + @Test func defaultServerIsValid() { + #expect(AppSettings.storedServerError(AppSettings.defaultStoredServer) == nil) + } +} From 0be4f8085b4cd1973d2049bdeb08116268d6b588 Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Thu, 15 Oct 2026 00:37:53 +0200 Subject: [PATCH 4/8] Prove stored transfers through the app: harness modes, a revoke scenario, a 0.9.9 history upgrade test and the docs page --- CHANGELOG.md | 1 + app/CrocApp/Support/AutoVerify.swift | 76 +++++++++++- app/CrocAppTests/HistoryUpgradeTests.swift | 74 ++++++++++++ docs/GLOSSARY.md | 3 + docs/knowledge/app-ui-architecture.md | 7 +- docs/knowledge/device-test-checklist.md | 10 +- docs/knowledge/features.md | 3 +- docs/known-issues.md | 3 +- scripts/verify-app-mac.sh | 68 ++++++++++- web/docs/content/guide/history.md | 6 +- web/docs/content/guide/power-options.md | 2 +- web/docs/content/guide/receiving.md | 2 + web/docs/content/guide/sending.md | 4 + web/docs/content/guide/settings-and-trust.md | 8 +- web/docs/content/guide/stored-transfers.md | 115 +++++++++++++++++++ web/docs/content/index.md | 2 +- web/docs/content/reference/feature-matrix.md | 1 + web/docs/content/security-and-privacy.md | 1 + 18 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 app/CrocAppTests/HistoryUpgradeTests.swift create mode 100644 web/docs/content/guide/stored-transfers.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0b4db..4806056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Receiving a file whose name is already taken now offers Keep Both (saves the new one as `name (1).ext`) or Replace. With auto-accept on, the setting "When a file already exists" under Confirmation chooses; it defaults to Keep Both. - A Connection setting, Automatic (the default) or Relay only. With Automatic, a send of 128 MB or more can also take a direct path between the devices; Relay only keeps every transfer on croc's relays. The sender's choice applies, and a receive follows it. - Links. Opening a CrocApp link, a `crocapp.dev/r/` link or a `getcroc.com/?code=` link, or pasting one into the Receive field, opens Receive with the code filled in; nothing connects until you tap Receive. The sender's waiting screen has a Share Link button. The page at `crocapp.dev/r/` reads the code in your browser (it never reaches a server) and offers to open CrocApp, get the app, or receive in your browser through croc's own web app, which needs the default relays. +- Send as link. Turn it on in Send to upload files (not folders or text) encrypted to a transfer server and get a link and QR code instead of a code, so the receiver can download later without being online at the same time. You choose how long the link lasts (1 hour, 1 day or 7 days) and how many downloads it allows (1 to 100), up to 100 files and 1 GiB. Receive takes the link like a code and shows the file list before anything is saved. History shows when each link expires and can revoke it. The files are encrypted on your device and the key is the part of the link after the `#`, so the server cannot read them. The server is set in Settings and defaults to `https://getcroc.com`, which croc's author runs; CrocApp does not. ### Changed diff --git a/app/CrocApp/Support/AutoVerify.swift b/app/CrocApp/Support/AutoVerify.swift index 5948ca4..3ae8fa8 100644 --- a/app/CrocApp/Support/AutoVerify.swift +++ b/app/CrocApp/Support/AutoVerify.swift @@ -1,9 +1,14 @@ import CrocKit import Foundation +import SwiftData /// Launch-argument harness for scripted verification. /// --auto-receive CODE receive into Documents, auto-accept /// --auto-send PATH --code CODE send PATH with custom code CODE +/// --auto-store-send PATH "Send as link" PATH (to --store-server URL, allowing --store-downloads N, +/// default 1), write the link to verify-store-link.txt, then wait for +/// verify-store-revoke.go in Documents and revoke the upload +/// --auto-store-receive LINK receive a stored link into Documents, auto-accept /// --auto-share-send CODE send whatever's staged in the ShareInbox with custom code CODE /// --local force croc local-only mode (LAN/sandbox listener check) /// --no-compress disable croc compression (F15) @@ -39,7 +44,8 @@ enum AutoVerify { #if DEBUG let args = ProcessInfo.processInfo.arguments return args.contains("--auto-receive") || args.contains("--auto-send") - || args.contains("--auto-share-send") || args.contains("--screen") + || args.contains("--auto-share-send") || args.contains("--auto-store-send") + || args.contains("--auto-store-receive") || args.contains("--screen") #else return false #endif @@ -114,6 +120,28 @@ enum AutoVerify { controller.startSend( urls: [URL(fileURLWithPath: args[i + 1])], customCode: args[ci + 1]) await watch(controller, resultURL: resultURL, autoAccept: false) + } else if let i = args.firstIndex(of: "--auto-store-send"), i + 1 < args.count { + if let j = args.firstIndex(of: "--store-server"), j + 1 < args.count { + controller.settings.storedServer = args[j + 1] + } + var downloads = 1 + if let j = args.firstIndex(of: "--store-downloads"), j + 1 < args.count, + let n = Int(args[j + 1]) + { + downloads = n + } + controller.startStoreSend( + urls: [URL(fileURLWithPath: args[i + 1])], downloads: downloads, + lifetimeSeconds: 3600) + await watchStoreSend(controller, docs: docs, resultURL: resultURL) + } else if let i = args.firstIndex(of: "--auto-store-receive"), i + 1 < args.count { + guard let link = URL(string: args[i + 1]) else { + try? "error bad link".write(to: resultURL, atomically: true, encoding: .utf8) + return + } + controller.startStoreReceive(link: link, into: docs, folderIsScoped: false) + await watch( + controller, resultURL: resultURL, autoAccept: !hold, sheetPolicy: sheetPolicy) } else if let i = args.firstIndex(of: "--auto-share-send"), i + 1 < args.count { let inbox = ShareInbox() inbox.refresh() @@ -182,6 +210,52 @@ enum AutoVerify { } } + /// The upload leaves its link for the script, then waits for the script's + /// signal to revoke it, so the script can download in between. + @MainActor + private static func watchStoreSend( + _ controller: TransferController, docs: URL, resultURL: URL + ) async { + let linkURL = docs.appendingPathComponent("verify-store-link.txt") + let signalURL = docs.appendingPathComponent("verify-store-revoke.go") + func finish(_ result: String) { + try? result.write(to: resultURL, atomically: true, encoding: .utf8) + } + while true { + try? await Task.sleep(for: .milliseconds(200)) + switch controller.phase { + case .stored(let receipt): + if !FileManager.default.fileExists(atPath: linkURL.path) { + try? receipt.link.write(to: linkURL, atomically: true, encoding: .utf8) + } + guard FileManager.default.fileExists(atPath: signalURL.path) else { continue } + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.date, order: .reverse)]) + descriptor.fetchLimit = 1 + guard let history = controller.history, + let record = try? history.container.mainContext.fetch(descriptor).first + else { + finish("error no history record for the upload") + return + } + do { + try await controller.revoke(record) + finish( + record.revokedAt != nil && !controller.canRevoke(record) + ? "ok success=true revoked=true" : "error revoke left the record live") + } catch { + finish("error revoke: \(error)") + } + return + case .failed(let message): + finish("error \(message)") + return + default: + break + } + } + } + @MainActor private static func watch( _ controller: TransferController, resultURL: URL, autoAccept: Bool, diff --git a/app/CrocAppTests/HistoryUpgradeTests.swift b/app/CrocAppTests/HistoryUpgradeTests.swift new file mode 100644 index 0000000..336bfa4 --- /dev/null +++ b/app/CrocAppTests/HistoryUpgradeTests.swift @@ -0,0 +1,74 @@ +import Foundation +import SwiftData +import Testing + +@testable import CrocApp + +/// R19: a history written by 0.9.9 must open under the current schema with +/// every record intact. `V099` is 0.9.9's `TransferRecord` verbatim (git tag +/// `v0.9.9`, `TransferHistory.swift`); the three fields added since are +/// optional, so SwiftData migrates the store by itself. +@MainActor @Suite struct HistoryUpgradeTests { + enum V099 { + @Model + final class TransferRecord { + var date: Date + var isSend: Bool + var statusRaw: String + var isText: Bool + var fileCount: Int + var totalBytes: Int64 + var names: [String] + var codeHint: String + var bookmarks: [Data] + + init( + isSend: Bool, statusRaw: String, isText: Bool, fileCount: Int, + totalBytes: Int64, names: [String], codeHint: String, bookmarks: [Data] + ) { + self.date = Date() + self.isSend = isSend + self.statusRaw = statusRaw + self.isText = isText + self.fileCount = fileCount + self.totalBytes = totalBytes + self.names = names + self.codeHint = codeHint + self.bookmarks = bookmarks + } + } + } + + @Test func historyFromV099OpensWithEveryRecord() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent( + "CrocAppTests.\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("history.store") + + do { + let old = try ModelContainer( + for: V099.TransferRecord.self, configurations: ModelConfiguration(url: url)) + for i in 0..<5 { + old.mainContext.insert( + V099.TransferRecord( + isSend: i.isMultiple(of: 2), statusRaw: "completed", isText: false, + fileCount: i + 1, totalBytes: Int64(i) * 1_000, names: ["file\(i).txt"], + codeHint: "word-…", bookmarks: [Data([UInt8(i)])])) + } + try old.mainContext.save() + } + + // The app's own open path swallows a failure and falls back to memory, + // so open the store directly: a migration error must fail this test. + let current = try ModelContainer( + for: TransferRecord.self, configurations: ModelConfiguration(url: url)) + let records = try current.mainContext.fetch( + FetchDescriptor(sortBy: [SortDescriptor(\.fileCount)])) + #expect(records.count == 5) + #expect(records.map(\.names) == (0..<5).map { ["file\($0).txt"] }) + #expect( + records.allSatisfy { $0.storedID == nil && $0.expiresAt == nil && $0.revokedAt == nil }) + #expect(records.allSatisfy { $0.bookmarks.count == 1 }) + } +} diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index c8c1f2b..7f38a85 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -20,6 +20,7 @@ Terms a new contributor or a fresh AI session hits in this repo and cannot infer | relay | the rendezvous/transport server both sides connect to when a direct LAN path is not available; sees ciphertext only. With no custom relay the default is croc's public relay pool (`1.getcroc.com:9009` to `4.getcroc.com:9009`), picked by the code; the old single default `croc.schollz.com:9009` survives as `models.DEFAULT_RELAY`, and a relay field equal to it means "not custom". | | room | the rendezvous channel on the relay, named `SHA-256(selector + "croc")`, where the selector is the first word of a three-word code (the first 4 characters for a legacy code like `NNNN-word`); two codes sharing it on the same relay collide into the same room ("room full"). | | shared secret prefix collision | the failure mode above: because the room name only hashes the code's selector (first word, or first 4 characters of a legacy code), concurrent transfers or test scripts using codes with the same selector collide (`docs/knowledge/crocmobile-bridge.md`). | +| stored transfer | croc's upload-and-link mode (`croc send --store`, default server `https://getcroc.com`): files are encrypted client-side, uploaded to a server, and fetched later from a link `https:///s/#v1.` whose fragment is the key; no peer, relay or code phrase involved. CrocApp's UI calls it Send as link (F43, `docs/knowledge/app-ui-architecture.md`, "Stored transfers"). | | Tailcat | croc v11's peer-to-peer data path: two native clients carry TCP streams over an in-process userspace WireGuard network that starts on DERP relays and moves to direct UDP when NAT traversal works. Under transport `auto` the sender offers it only for a send of 128 MiB or more and races it against the relay; setup contacts `tailcat.dev` and the `*.ipn.dev` DERP relays. Whether it stays in the build is decided on a physical iPhone (`docs/knowledge/device-test-checklist.md`, `docs/knowledge/crocmobile-bridge.md`). | | transport / Connection | croc's sender-only `--transport` (`auto`, `relay`, `derp`), shown in the app as the Connection setting: Automatic (`auto`, default) or Relay only (`relay`); the app never offers `derp`. A receive and a local-only send are always `auto` (`crocmobile.Options.Transport`, `CrocKit.Transport`, `AppSettings.transport`, F41). | @@ -53,6 +54,8 @@ Terms a new contributor or a fresh AI session hits in this repo and cannot infer | `Phase` | the enum on `TransferController` driving every view: `idle` → `starting` → `waiting`/`connecting` → `confirmSend` or `incoming` → `transferring` → `done`, `stored` (a stored upload finished) or `failed` (`app/CrocApp/Models/TransferController.swift`). | | security-scoped resource | an Apple sandbox URL (from `fileImporter` or a bookmark) that needs `startAccessingSecurityScopedResource()` before use; both `TransferController` and `HistoryView` call it before touching a URL. | | share extension (appex) | `CrocShare`, the iOS-only app extension (`app/CrocShare/`) that stages shared files into the App Group for later send; ~120 MB memory cap, no long-running work (`docs/knowledge/app-ui-architecture.md`). | +| receipt | `StoredReceipt` (CrocKit), what a stored upload returns: id, origin, upload token, link, expiry, download count. Held only in `StoredTransferVault` (Keychain); history keeps just the id, host and expiry. | +| revoke | deleting a stored upload from its server with its upload token (`TransferController.revoke`, History's Revoke Link). A server that no longer has it counts as success. Only possible while the receipt is in the vault. | | `StoredTransferVault` | the Keychain store of stored-upload receipts (`app/CrocApp/Models/StoredTransferVault.swift`): upload token and link, which History needs to revoke and must never hold itself. | | `SwiftData` `TransferRecord` | the `@Model` type (`app/CrocApp/Models/TransferHistory.swift`) persisting one finished/failed transfer for local-only history (F12); never stores file contents or the full code phrase. | | `TransferLink` | the one parser (`app/CrocApp/Models/TransferLink.swift`) for every form a code arrives in: bare code, `crocapp://receive?code=`, `croc://`, `https://crocapp.dev/r/#`, `https://getcroc.com/?code=`. `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. Opening one prefills Receive and never connects. | diff --git a/docs/knowledge/app-ui-architecture.md b/docs/knowledge/app-ui-architecture.md index e31f7c2..b7f6411 100644 --- a/docs/knowledge/app-ui-architecture.md +++ b/docs/knowledge/app-ui-architecture.md @@ -63,11 +63,14 @@ Relay strings use `""` to mean "croc default" (the `TextField` prompt reads "Pub `Phase.confirmSend` is the sender side of croc `--ask`: entered at `.connected` when `bothSidesConfirm` is on, progress-guarded like `.incoming`, accept is `respond(true)`, decline is `cancel()`. -### Stored transfers (R18-R20) +### Stored transfers (R18-R21) `isStoredTransfer` marks a transfer that goes through a stored-transfer server instead of a croc peer; it reuses `run()`, `handle(_:)`, the `BGContinuedProcessingTask` wrap and the history record. - `startStoreSend(urls:downloads:lifetimeSeconds:)` uploads to `AppSettings.storedServer` (UserDefaults `settings.storedServer`, default `https://getcroc.com`). The engine sends `.progress`, then `.stored(receipt)` just before `.done`; the controller puts the receipt in the vault, keeps its id and expiry for the record, and turns `.done` into `Phase.stored(receipt)`. - `startStoreReceive(link:into:folderIsScoped:)` takes the same path as a live receive: `.incoming` with the conflict scan, then `respond(accept:policy:)`. Auto-accept never applies; the engine always waits. +- Views. `SendView`: a "Send as link" toggle (disabled with a reason line for text or a staged folder), "Link lasts" picker (1 hour / 1 day default / 7 days), a downloads stepper (1-100, default 1) and a server note; with it on, the custom code field is hidden and the button reads Upload. It checks `AppSettings.storedServerError` and blocks Upload on an invalid server. `TransferStatusView`: `Phase.stored(receipt)` renders `storedView` (Link ready, expiry and download count, QR, link text, Copy Link via `Clipboard.copy(_, thisDeviceOnly: true)`, Share Link, Done); the transferring heading reads Uploading / Downloading and shows a caption instead of `TrustBadge` when `isStoredTransfer`. `ReceiveView`: `router.pendingReceive` carrying `.stored(url)` sets `storedLink`, which replaces the code field with a "Download link from " row and a Clear button, and Receive calls `startStoreReceive`. `HistoryView`: stored rows add a "Link expires / expired / revoked" line, and Revoke Link (context menu; "Revoke" swipe on iOS) sits behind a confirmation dialog, with an alert on failure. `PowerSettingsSections`: a "Send as link" section with the Server field, inline `storedServerError` banner and a Reset button shown only when the value differs from the default. +- `AppSettings.storedServerError(_:)` accepts `https`, or `http` on loopback only, and nothing after the host. `TransferLink.parse` accepts a stored link only over `https`, or `http` on loopback, and the engine checks the id and key lengths. +- `Clipboard.copy(_, thisDeviceOnly:)` sets `UIPasteboard` with `.localOnly` on iOS so a link (its fragment is the key) stays off Universal Clipboard; the macOS pasteboard has no such option, so Copy Link there is an ordinary copy. The Camera app does not open CrocApp for a stored link: the Associated Domains entitlement and the AASA file cover only `crocapp.dev/r/*`. Receiving in the app is Paste or Scan QR Code. - A stored transfer cannot resume, so no failure copy offers it. A background expiry reads "Upload interrupted … Try again" (send) or "Download interrupted … Open the link again" (receive), concern 4. `friendlyMessage(…, stored: true)` names the transfer server for `relayUnreachable` and `connectionLost`, which is how a store call reports a network failure. - `revoke(_ record:)` (throws `EngineFailure`) calls `CrocEngine.revokeStored`, which needs no free engine slot, then drops the receipt and sets `revokedAt`. `canRevoke(_:)` is true for an unexpired, unrevoked record whose receipt this device still holds. @@ -97,7 +100,7 @@ Asynchronous. `.incoming` is shown immediately with `conflicts: []`; `Self.scanC ## Receive links (R22) -- `Models/TransferLink.swift` is the one parser. `TransferLink.parse(_:)` takes a bare code, `crocapp://receive?code=`, `croc://` (0.9.9 QR codes), `https://crocapp.dev/r/#` or `https://getcroc.com/?code=`, and yields `.code`. It also parses a stored-transfer link `https:///s/#v1.` (`http` only for localhost, 127.0.0.1 and ::1); nothing receives that yet. `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. +- `Models/TransferLink.swift` is the one parser. `TransferLink.parse(_:)` takes a bare code, `crocapp://receive?code=`, `croc://` (0.9.9 QR codes), `https://crocapp.dev/r/#` or `https://getcroc.com/?code=`, and yields `.code`. It also parses a stored-transfer link `https:///s/#v1.` (`http` only for localhost, 127.0.0.1 and ::1) into `.stored(URL)`; `ReceiveView` takes it (see "Stored transfers (R18-R21)"). `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. - `AppRouter.open(_:)` stores the link in `pendingReceive` and calls `openReceive()` unless `isBusy`; mid-transfer the link is kept and the user is not moved. `ReceiveView` consumes `pendingReceive` (`onChange(initial: true)` on the form) and fills `code`. Opening a link never starts a receive: the user taps Receive. - Entry points, all through `TransferLink.parse` then `router.open`: the QR scan sheet (iOS), the Paste button, typed or pasted text containing `://` in the code field, `.onOpenURL` in `CrocAppApp` (iOS universal links and `crocapp://`), and `AppDelegate.application(_:open:)` on macOS (file URLs still go to Send). - The `crocapp` scheme is in all three Info.plists (`CrocApp-Info.plist`, `CrocApp-macOS-Info.plist`, `CrocApp-Direct-Info.plist`). Universal links for `crocapp.dev/r/*` need the Associated Domains entitlement and `web/landing/.well-known/apple-app-site-association`. diff --git a/docs/knowledge/device-test-checklist.md b/docs/knowledge/device-test-checklist.md index aabc6d1..5e4a62c 100644 --- a/docs/knowledge/device-test-checklist.md +++ b/docs/knowledge/device-test-checklist.md @@ -1,6 +1,6 @@ # Physical-device test checklist -The simulator cannot verify any of these. Run them on a real iOS 26 device before store submission — this is the hardware gate. Run so far (owner, 2026-10-14, on a physical iPhone): "Links and web receive" 1-6 and "Transport and Tailcat gate" (a)-(d), all passed. Every other section is still unrun. +The simulator cannot verify any of these. Run them on a real iOS 26 device before store submission — this is the hardware gate. Run so far (owner, 2026-10-14, on a physical iPhone): "Links and web receive" 1-6 and "Transport and Tailcat gate" (a)-(d), all passed. Every other section, including "Stored transfers (R18-R21)", is still unrun. ## Background continuation (BGContinuedProcessingTask) - [ ] Start multi-GB receive, background the app → system Live Activity shows title + progress + cancel @@ -37,6 +37,14 @@ Owner-run on a physical iPhone, with a Mac running CrocApp as the sender (start - [x] 5. Copy `https://getcroc.com/?code=` (a link the croc CLI shows with `--qrcode`, or typed by hand), tap Paste on Receive. Pass: the code field shows only ``, not the link - [x] 6. On the page from step 2, tap "Receive in your browser" while the Mac sender uses default relays. Pass: Safari opens `https://getcroc.com/?code=` and the croc web app offers the transfer. Informational: this is croc's own web app, not CrocApp +## Stored transfers (R18-R21) +Owner-run on a physical iPhone, with the default server (`https://getcroc.com`) and the public internet. Items 3 and 4 need a second phone (3) and any device that can hash the received file (4). Use `shasum -a 256` on the Mac; copy a file received on the iPhone to the Mac to hash it. +- [ ] 1. Send as link a 1-2 MB file (Send > Files > Send as link > Upload). Background the app mid-upload, then return. Pass: the upload completes and shows "Link ready", or it shows "Upload interrupted: iOS stopped it in the background. Try again." and sending again completes +- [ ] 2. On the iPhone tap Copy Link on the result screen, then paste on a Mac signed in to the same iCloud account (Handoff and Universal Clipboard on). Pass: nothing pastes on the Mac, because the link is copied for this device only +- [ ] 3. On a second phone with CrocApp, open Receive > Scan QR Code and scan the result-screen QR of a stored link. Pass: Receive shows "Download link from " in place of the code field, and nothing downloads until you tap Receive. Then, on the same QR, point the Camera app of a phone with CrocApp at it and of a phone without CrocApp. Informational: the stored link points at the server, not at `crocapp.dev/r/`, so the Camera is expected to open the server's page in the browser on both phones, not CrocApp. Record what each phone does and what the page shows +- [ ] 4. In CrocApp, receive the link (tap Receive). Pass: the incoming preview lists the file, Accept saves it, and its SHA-256 equals the source file's +- [ ] 5. In History, open the upload's row, choose Revoke Link (swipe or touch and hold) and confirm. Pass: the row reads "Link revoked", and reopening the link in a browser reports the link as gone + ## Transport and Tailcat gate Owner-run on a physical iPhone, against the Mac CLI (`/opt/homebrew/bin/croc`, v11). Use a file of at least 130 MiB, because croc starts Tailcat only for sends of 128 MiB or more (`crocmobile-bridge.md`, "Transport"). Under Automatic croc races Tailcat against its relay, so the CLI `--debug` log shows which side offered it. Report each SHA-256 (`shasum -a 256` on the Mac; copy a file received on the iPhone to the Mac to hash it) so Claude can compare them with the source file's. Pass for the gate is (a) to (c) complete with equal SHA-256 and no crash or hang. (d) is informational. - [x] (a) Settings > Connection = Automatic. Send the file from the iPhone to `croc --debug --yes` on the Mac, once with the app in the foreground and once switching to another app mid-transfer and back. Pass: both transfers complete, the SHA-256 matches the source, and the CLI log contains `tailcat-offer` diff --git a/docs/knowledge/features.md b/docs/knowledge/features.md index 8f3432c..4766b1c 100644 --- a/docs/knowledge/features.md +++ b/docs/knowledge/features.md @@ -1,6 +1,6 @@ # Feature roadmap -Approved 2026-07-22 by Bakir. Tier = target release. Numbers are stable — reference them as F1..F42 in plans, issues, and code comments (several views already carry them). +Approved 2026-07-22 by Bakir. Tier = target release. Numbers are stable — reference them as F1..F43 in plans, issues, and code comments (several views already carry them). Status is verified against the code, not against intent. **Shipped** means the feature exists and works in the app; it does not mean it has been tested on physical hardware (`device-test-checklist.md`) or that it has no open defects (`../known-issues.md`). @@ -44,6 +44,7 @@ Status is verified against the code, not against intent. **Shipped** means the f | F32 | Receive links: `crocapp://receive?code=`, `https://crocapp.dev/r/#` universal links, `https://getcroc.com/?code=`, and `croc://` (0.9.9 QR codes); one parser, `TransferLink`. Opening a link prefills Receive and never connects. Universal links in the Developer ID build are unverified (`../known-issues.md`) | shipped | | F36 | Trust UI: E2E badge, "how it works", active-relay indicator | shipped | | F42 | Waiting-screen QR and Share Link encode `https://crocapp.dev/r/#`; `web/landing/r/` page reads the code from the fragment (no network request) and offers Open in CrocApp, Get CrocApp (the landing page download section, until the store listing is live), the DMG, and receive in a browser through croc's own web app at getcroc.com (needs the default relays) | shipped | +| F43 | Send as link (stored transfers, R18-R21): files only, up to 100 files / 1 GiB, lifetime 1 hour / 1 day / 7 days, 1-100 downloads, to the server in Settings (`https://getcroc.com` by default); link and QR result screen, receive from a pasted or scanned link through the normal incoming preview, History expiry and Revoke. Needs the device checks in `device-test-checklist.md` | shipped | The whole V1 set is implemented. What stands between it and release is in `../known-issues.md` under "Blocking release", not in this table. diff --git a/docs/known-issues.md b/docs/known-issues.md index 67dd55b..ca950f2 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -71,4 +71,5 @@ Engine and bridge invariants live in `knowledge/crocmobile-bridge.md`; UI state- - **The macOS local-network denial path is hard to exercise during development.** Accepted: a development-procedure note, not an app defect. Enforcement reportedly only engages for builds resident in `/Applications` (single developer-forum thread FB16077972, not Apple documentation — strong inference, not verified here), so a DerivedData-launched Debug build can sit at `.ready` and read as falsely granted. Copy the app to `/Applications` before trying to reproduce a denial. - **`AppRouter` is a singleton with no reset hook, so multiple macOS windows share one navigation path.** Accepted: per-window navigation needs `@FocusedValue` plumbing to reach `AppDelegate` and `Commands`, which sit outside the environment graph, and multi-window reachability has never actually been observed. Revisit if someone reports it. - **croc prints the code phrase, and a non-default relay password, to stderr on every send.** croc builds the "On the other computer run" line (`croc `, plus `--pass ` when the password is not the default) with `Quiet=false`, which crocmobile needs (`knowledge/crocmobile-bridge.md`), and its debug logger prints file names and paths. Nothing in the app captures stderr: a build launched from Finder, the Dock or the Home Screen has it connected to `/dev/null` (strong inference, not measured on a device), so it reaches no log store or crash report. A harness run or an Xcode debug session does capture it (`verify-app-mac.sh` writes it to its temp logs). Accepted for 1.0; revisit if croc gains a way to silence that line without `Quiet`. -- **Stored-transfer links parse but cannot be received.** `TransferLink` recognises `https:///s/#v1.` (R22), and `TransferController.startStoreReceive` can receive it, but `ReceiveView` prefills only from a code and ignores it until P10 step 41 wires the preview. Deferred on purpose, not a defect. +- **Deleting a stored upload's History row removes the only Revoke button.** The receipt stays in the Keychain until the link expires, but nothing lists receipts without a record. Deferred: revoke before deleting the row. Revisit if users lose links this way. +- **The Camera app cannot open CrocApp for a stored link.** The stored link points at the transfer server, and Associated Domains covers only `crocapp.dev/r/*`, so a camera scan opens the server's page in the browser. In-app Scan QR Code and Paste work. Accepted for 1.0. diff --git a/scripts/verify-app-mac.sh b/scripts/verify-app-mac.sh index cd9a82b..f40ed7d 100755 --- a/scripts/verify-app-mac.sh +++ b/scripts/verify-app-mac.sh @@ -21,7 +21,7 @@ READY="$DOCS/verify-code-ready.txt" DD="$TMPROOT/dd-mac" TMP=$(mktemp -d) -trap 'kill_jobs; rm -rf "$TMP"; rm -f "$DOCS"/bigsend.bin "$DOCS"/bigrecv.bin' EXIT +trap 'kill_jobs; rm -rf "$TMP"; rm -rf "$DOCS"/bigsend.bin "$DOCS"/bigrecv.bin "$DOCS"/store-src "$DOCS"/storeme.bin "$DOCS"/verify-store-*' EXIT ( cd app && xcodebuild -scheme CrocApp -destination 'platform=macOS' \ -derivedDataPath "$DD" -allowProvisioningUpdates build ) > "$TMPROOT/mac-build.log" 2>&1 @@ -245,3 +245,69 @@ transport_app_receive relay ! grep -q "tailcat-offer" "$TMPROOT/mac-cli-xrrelay.log" || fail "transport relay receive: Tailcat offered" rm -f "$TMP/bigrecv.bin" echo MAC-TRANSPORT-OK + +# --- 9: stored transfers through the app (R18-R20) --------------------------- +# A local croc store server (croctest store-server), so no relay and no +# internet. One app instance uploads and keeps its receipt in memory, so the +# revoke has to come from that same process: the script signals it with +# verify-store-revoke.go once the CLI and a second app instance have downloaded. +# The source sits in a subfolder because the second instance receives into +# Documents itself. +CT="$TMP/croctest" +( cd crocmobile && go build -o "$CT" ./cmd/croctest ) +mkdir -p "$TMP/store9" "$DOCS/store-src" +"$CT" store-server "$TMP/store9" > "$TMP/9srv.log" 2>&1 & +STORE="" +for _ in $(seq 1 50); do + STORE=$(sed -n 's/^STORE-URL //p' "$TMP/9srv.log") + [ -n "$STORE" ] && break + sleep 0.2 +done +[ -n "$STORE" ] || fail "store server did not start" +STORE_LINK="$DOCS/verify-store-link.txt" +rm -f "$RESULT" "$STORE_LINK" "$DOCS/verify-store-revoke.go" "$DOCS/storeme.bin" +# 5 MiB spans two upload chunks. +head -c 5242880 /dev/urandom > "$DOCS/store-src/storeme.bin" + +run_app --auto-store-send "$DOCS/store-src/storeme.bin" --store-server "$STORE" --store-downloads 3 \ + > "$TMPROOT/mac-app-store-send.log" 2>&1 +SENDER_PID=$APP_PID +await_file "$STORE_LINK" 60 || fail "stored upload: the app never wrote its link" +LINK=$(cat "$STORE_LINK") +[ -n "$LINK" ] || fail "stored upload: empty link" + +mkdir -p "$TMP/d9cli" +CROC_STORE_TOKEN="$LINK" timeout 60 "$CROC" --yes --out "$TMP/d9cli" > "$TMPROOT/mac-cli-store.log" 2>&1 \ + || fail "stored upload -> CLI download failed (see $TMPROOT/mac-cli-store.log)" +cmp "$DOCS/store-src/storeme.bin" "$TMP/d9cli/storeme.bin" || fail "stored upload -> CLI download: file differs" + +rm -f "$RESULT" +run_app --auto-store-receive "$LINK" > "$TMPROOT/mac-app-store-recv.log" 2>&1 +await_app 60 +check_result "stored receive" +cmp "$DOCS/store-src/storeme.bin" "$DOCS/storeme.bin" || fail "stored upload -> app download: file differs" + +rm -f "$RESULT" +touch "$DOCS/verify-store-revoke.go" +await_file "$RESULT" 30 || fail "stored revoke: the app wrote no result" +got=$(cat "$RESULT") +echo "stored revoke result: $got" +[ "$got" = "ok success=true revoked=true" ] || fail "stored revoke: got '$got'" +kill "$SENDER_PID" 2>/dev/null || true +wait "$SENDER_PID" 2>/dev/null || true + +mkdir -p "$TMP/d9gone" +CROC_STORE_TOKEN="$LINK" timeout 60 "$CROC" --yes --out "$TMP/d9gone" > "$TMPROOT/mac-cli-store-gone.log" 2>&1 \ + && fail "revoked upload still downloads (CLI)" +[ -z "$(ls -A "$TMP/d9gone")" ] || fail "revoked upload wrote files (CLI)" +rm -f "$RESULT" "$DOCS/storeme.bin" +run_app --auto-store-receive "$LINK" > "$TMPROOT/mac-app-store-gone.log" 2>&1 +await_app 60 +got=$(cat "$RESULT" 2>/dev/null || echo missing) +echo "revoked receive result: $got" +case "$got" in + "error This link has expired"*) ;; + *) fail "revoked upload: the app should report an expired link, got '$got'" ;; +esac +[ ! -e "$DOCS/storeme.bin" ] || fail "revoked upload wrote a file (app)" +echo MAC-STORE-OK diff --git a/web/docs/content/guide/history.md b/web/docs/content/guide/history.md index b0bd503..097ed8c 100644 --- a/web/docs/content/guide/history.md +++ b/web/docs/content/guide/history.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 title: History description: "CrocApp's local-only transfer history: what it records, what it deliberately never records, and how to clear it." --- @@ -27,6 +27,10 @@ For each finished or failed transfer: - The full code phrase — only that first-segment hint - Anything beyond the 20-name and 200-bookmark caps +## Stored uploads + +A file sent with [Send as link](stored-transfers.md) is recorded with the server's host name and the time the link expires, then **Link expired** or **Link revoked** later. **Revoke Link**, in the row's context menu (and as a swipe action on iOS), deletes the upload from the server after a confirmation. The link and the upload token are never stored in History; they stay in the Keychain until the link is revoked or expires. + ## Send Again For a past send, the history entry's context menu (or swipe action on iOS) offers **Send Again** if bookmarks were captured for it. This re-stages the same files into a new send. If the original files were moved or deleted since, CrocApp tells you they're no longer available rather than silently sending nothing. diff --git a/web/docs/content/guide/power-options.md b/web/docs/content/guide/power-options.md index 8d5525d..ec922fe 100644 --- a/web/docs/content/guide/power-options.md +++ b/web/docs/content/guide/power-options.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 title: Power Options description: The shipped power-user settings and the croc CLI flag each one maps to. --- diff --git a/web/docs/content/guide/receiving.md b/web/docs/content/guide/receiving.md index 928d699..f97e483 100644 --- a/web/docs/content/guide/receiving.md +++ b/web/docs/content/guide/receiving.md @@ -16,6 +16,8 @@ Type the **Code phrase** into the field, paste it with the **Paste** button, or You can also paste a link, or open one from outside the app (for example by scanning the QR code with the camera). CrocApp fills in the code and waits: nothing connects until you tap **Receive**. See [Code phrases](code-phrases.md#accepted-forms) for the link forms it accepts, and for receiving in a browser instead. +A link from [Send as link](stored-transfers.md) works the same way: paste it, or scan its QR code on iOS, and the code field is replaced by a **Download link from** row naming the server. Tapping **Receive** then shows the usual incoming preview below, with **Keep Both** and **Replace**; auto-accept does not apply to it. + ## Choosing where files land (F7) Below the code field, CrocApp shows the current destination folder. Tap **Change** to pick a different one, or **Reset** to return to the default. diff --git a/web/docs/content/guide/sending.md b/web/docs/content/guide/sending.md index 4f74e11..bc65bd1 100644 --- a/web/docs/content/guide/sending.md +++ b/web/docs/content/guide/sending.md @@ -29,6 +29,10 @@ The **Custom code** field is optional. Leave it blank for a generated code, or e Tap **Send**. CrocApp shows the generated (or custom) code phrase, a **Copy Code** button, a QR code (F6) the receiver can scan, and a **Share Link** button. The QR code and the link open the receiver's CrocApp, or a page at `crocapp.dev/r/` if they do not have it (see [Code phrases](code-phrases.md#receiving-a-link)). With a custom relay or Local network only, a note on the screen says that receiving in a browser needs the default relays. The screen also shows a trust badge describing which relay path is in use. +## Send as link + +Instead of a code, **Send as link** (files only) uploads your files, encrypted, to a transfer server and gives you a link the receiver can open later, with no need to be online at the same time. You choose how long the link lasts and how many downloads it allows. See [Send as link](stored-transfers.md). + ## One active transfer at a time CrocApp allows exactly one active transfer at a time, matching a limit in the underlying engine. You cannot start a second send or receive while one is in progress; finish or cancel the current transfer first. diff --git a/web/docs/content/guide/settings-and-trust.md b/web/docs/content/guide/settings-and-trust.md index 4fff104..a58ace9 100644 --- a/web/docs/content/guide/settings-and-trust.md +++ b/web/docs/content/guide/settings-and-trust.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 6 title: Settings and Trust description: Where CrocApp's settings live, why you might run your own relay, and what the trust badge tells you. --- @@ -23,6 +23,10 @@ By default, CrocApp uses croc's public relay. You can point it at a relay you ru Running `croc relay` on a machine you control is enough; then enter its address (and password, if you set one) in CrocApp's relay fields. +## Send as link server + +The **Send as link** section holds the **Server** field for [stored transfers](stored-transfers.md#the-server-setting). It defaults to `https://getcroc.com`, which is run by croc's author, not by CrocApp, and it accepts an `https` address (or `http` on localhost) with nothing after the host. A **Reset** button restores the default. The relay and Connection settings do not apply to a stored upload. + ## Connection The **Connection** picker under the [power options](power-options.md) chooses how a send carries large files. **Automatic** (the default) can also use a direct path between the devices for a send of 128 MB or more, set up through `tailcat.dev`. A send set to **Relay only** never contacts those servers; a receive follows the sender's choice. The data is end-to-end encrypted either way. @@ -39,4 +43,4 @@ This is captured at the moment the transfer starts, so changing settings mid-tra ## What is stored -Settings (relay address, relay password, connection, local-only, compression, zip, exclude patterns, gitignore, both-sides confirm, auto-accept) are stored locally on the device. No settings, transfer content, or code phrases are sent anywhere except as part of an actual transfer you initiate. +Settings (relay address, relay password, connection, send-as-link server, local-only, compression, zip, exclude patterns, gitignore, both-sides confirm, auto-accept) are stored locally on the device. No settings, transfer content, or code phrases are sent anywhere except as part of an actual transfer you initiate. diff --git a/web/docs/content/guide/stored-transfers.md b/web/docs/content/guide/stored-transfers.md new file mode 100644 index 0000000..26e4b85 --- /dev/null +++ b/web/docs/content/guide/stored-transfers.md @@ -0,0 +1,115 @@ +--- +sidebar_position: 4 +title: Send as Link +description: "Upload encrypted files to a transfer server and share a link, so the receiver can download later without being online at the same time." +--- + +# Send as link (stored transfers) + +**Send as link** uploads your files, encrypted on your device, to a transfer server and gives you a link. The receiver opens the link and downloads the files whenever they like, until the link expires or runs out of downloads. You do not need to stay online, and the two devices never connect to each other. + +This is croc's *stored transfer* feature. The croc command line has the same feature, so the two interoperate. + +## When to use it instead of a code + +| | Code phrase | Send as link | +|---|---|---| +| Both devices online at the same time | Required | Not required | +| Receivers | One transfer per code | Up to the download count you set | +| Folders and text | Yes | No, files only | +| Where the files rest | Nowhere, they travel through a relay or directly | On the transfer server until the link ends | +| End it early | Cancel while it runs | [Revoke](#history-and-revoking) at any time before it expires | + +Use a code phrase when both people are available now, or for folders and text. Use a link when the receiver is not around, or when several people need the same files. + +## Sending + +1. Open **Send** and add files. +2. Turn on **Send as link**. +3. Choose **Link lasts**: **1 hour**, **1 day** (the default) or **7 days**. +4. Set **Downloads allowed**, from 1 (the default) to 100. +5. Tap **Upload**. + +A line under the options names the server the files will go to. While it runs, the screen reads **Uploading** with progress and a note that the files are encrypted on this device. It shows no relay badge, because an upload does not use a relay. + +The **Send as link** switch is disabled, with a line saying why, when text is selected or a folder is staged. Switch to **Files**, remove the folders, or send them with a code instead. The custom code field is hidden while **Send as link** is on, since there is no code. + +The relay, **Connection** and **Local network only** settings do not apply to an upload. If the server address in [Settings](#the-server-setting) is invalid, the Send screen shows the problem and **Upload** stays disabled. + +## The result screen + +When the upload finishes, CrocApp shows **Link ready**, the time the link works until, and the number of downloads. Below that are: + +- a QR code of the link +- the link itself +- **Copy Link**, which puts the link on the clipboard +- **Share Link**, which opens the system share sheet + +CrocApp does not show the link again after you tap **Done**, and [History](history.md) never stores it. Copy or share it first. If you lose it, revoke the upload from History and send again. + +On iOS, **Copy Link** keeps the link off Universal Clipboard, so it does not paste on your other Apple devices. The link contains the decryption key, which is why. macOS offers no such option, so on a Mac CrocApp cannot keep the copied link off Universal Clipboard. + +The link is a bearer secret: anyone who has it can download the files until it ends. Send it over a channel you would trust with the files. + +## Receiving + +In **Receive**, paste the link with the **Paste** button, type or paste it into the field, or on iOS tap **Scan QR Code** and scan the QR from the result screen. The code field is replaced by a row reading **Download link from** and the server's host name, with a **Clear** button. Nothing downloads until you tap **Receive**. + +CrocApp then fetches the file list and shows the usual **Incoming transfer** preview. Choose **Accept**, or **Keep Both** or **Replace** if a file with the same name already exists, exactly as in [Receiving files](receiving.md#overwrite-and-conflicts-f8). Auto-accept never applies to a link: you always see the preview first. The progress screen reads **Downloading**. + +The phone's Camera app does not open CrocApp for these links. They point at the transfer server, and CrocApp's universal links cover only `crocapp.dev/r/`. A camera scan opens the server's own page in the browser. In CrocApp use **Scan QR Code** or paste the link. + +### Receiving in a browser + +Opening the link in any browser shows the transfer server's own page. For the default server that is a getcroc.com page run by croc's author, not by CrocApp. The key is in the part of the link after the `#`, which browsers do not send to the server, so the page decrypts in your browser. That means a browser download trusts the page the server serves, in the same way as [receiving a code in a browser](code-phrases.md#receiving-a-link). To avoid that trust, receive in CrocApp or the croc CLI. + +A link that has expired, been revoked or run out of downloads reports so in CrocApp: "This link has expired, was revoked, or has no downloads left." + +## History and revoking + +A stored upload appears in [History](history.md) with the server's host name and one of: + +- **Link expires** and the time, while it is live +- **Link expired** and the time +- **Link revoked** and the time + +Choose **Revoke Link** to end it early. On macOS use the row's context menu. On iOS use the context menu (touch and hold) or swipe the row left and tap **Revoke**. A confirmation asks "Revoke this link?" and says the files are deleted from the server and the link stops working for everyone. If the server no longer has the upload, CrocApp treats the revoke as done. + +**Revoke Link** appears only while the link is live and this device still holds the upload's receipt. A transfer you received, or a link that has already expired, has nothing to revoke. Deleting a History row, or clearing History, removes the place you revoke from, so revoke first if you want the link dead. + +## The server setting + +Under the **Send as link** section of Settings (the Settings scene on macOS, the settings screen on iOS), the **Server** field holds the transfer server. The default is `https://getcroc.com`, run by croc's author, not by CrocApp. A **Reset** button appears once you change it. + +The address is checked as you type: + +- It must use `https`. Plain `http` is allowed only for `localhost` (including `127.0.0.1` and `::1`). +- It must be the address only: nothing after the host name, no path, query or fragment. + +The server must speak croc's stored-transfer protocol. The server you upload to decides how long it keeps files and which limits it enforces. The server in a received link is the one in the link, whatever your setting says. + +## Limits + +CrocApp refuses, before uploading, a send of more than 100 files, more than 1 GiB in total, or more than 100 downloads. Those are croc's defaults. A server with lower limits, or one that rate-limits uploads, can still refuse an upload, and CrocApp then says the server refused it for size, file count, download limit, or too many recent uploads. + +Only regular files go in a link. Folders and text do not. + +## Privacy + +The files are encrypted on your device before upload. The key is the part of the link after the `#`, which CrocApp and browsers never send to the server. + +The server sees the encrypted bytes, how large they are, the lifetime and download count you chose, and the usual details of any web request, such as your IP address and the time. It does not see the file contents or the key. CrocApp has not verified whether the server can read file names, so do not rely on names being hidden. + +Anyone who has the whole link can decrypt the files, so the link itself is the secret. + +On your device: + +- [History](history.md) keeps only the server host and the upload id for a stored upload, never the link or the upload token. +- The receipt, which holds the upload token and the link, lives only in the iOS or macOS Keychain. CrocApp removes it when you revoke and at the first launch after the link expires. +- CrocApp makes no network request for a stored upload except to the server in Settings. See [Security and privacy](../security-and-privacy.md). + +## If iOS interrupts an upload + +An upload is wrapped in iOS background processing, so it can continue when you leave the app, but iOS may stop it. If it does, CrocApp shows "Upload interrupted: iOS stopped it in the background. Try again." A stored transfer cannot resume, so trying again means starting the upload over from the beginning, which produces a new link. A download that iOS stops reads "Download interrupted: iOS stopped it in the background. Open the link again." + +Keep the app in the foreground for a large upload if you can. diff --git a/web/docs/content/index.md b/web/docs/content/index.md index 5e6395a..4162fdf 100644 --- a/web/docs/content/index.md +++ b/web/docs/content/index.md @@ -33,7 +33,7 @@ CrocApp has not been released yet. Nothing is available for download on any chan - [Install](getting-started/install.md): release status and how to build from source today - [Your first transfer](getting-started/first-transfer.md): a walkthrough of one send and one receive -- [Sending files](guide/sending.md), [Receiving files](guide/receiving.md), [Code phrases](guide/code-phrases.md) +- [Sending files](guide/sending.md), [Receiving files](guide/receiving.md), [Code phrases](guide/code-phrases.md), [Send as link](guide/stored-transfers.md) - [Power options](guide/power-options.md), [Settings and trust](guide/settings-and-trust.md), [History](guide/history.md) - [croc CLI interoperability](reference/croc-cli-interop.md) and the [feature matrix](reference/feature-matrix.md) - [Security and privacy](security-and-privacy.md), [Troubleshooting](troubleshooting.md), [FAQ](faq.md) diff --git a/web/docs/content/reference/feature-matrix.md b/web/docs/content/reference/feature-matrix.md index 3d69f2a..18a64d0 100644 --- a/web/docs/content/reference/feature-matrix.md +++ b/web/docs/content/reference/feature-matrix.md @@ -46,6 +46,7 @@ Feature numbers (`F1`…) are stable identifiers used across the project. "Shipp | F32 | Receive links: `crocapp://` scheme, `crocapp.dev/r/` universal links, and `croc://` QR codes from 0.9.9 | shipped | | F36 | Trust UI: end-to-end badge, "how it works", active-relay indicator | shipped | | F42 | Share Link, a QR code that is a web link, and a `crocapp.dev/r/` page with an option to receive in a browser | shipped | +| F43 | Send as link: encrypted upload of files to a stored-transfer server, a link with a chosen lifetime and download count, receive from the link, and revoke from History | shipped | ## Planned, not started (V1.x) diff --git a/web/docs/content/security-and-privacy.md b/web/docs/content/security-and-privacy.md index 0278f84..e22a2cf 100644 --- a/web/docs/content/security-and-privacy.md +++ b/web/docs/content/security-and-privacy.md @@ -22,6 +22,7 @@ There has been **no formal third-party security audit** of croc, and CrocApp doe - **The relay is just a pipe.** When devices can't connect directly, an internet relay forwards the encrypted stream. The relay never has the key — it sees only ciphertext. You can also run your own relay and set it in settings. - **A direct path for large files.** With the default **Automatic** connection setting, a send of 128 MB or more also tries a peer-to-peer connection, set up through `tailcat.dev`, with its own relays (`*.ipn.dev`) when the devices cannot reach each other. Whichever path connects first carries the transfer, and it is end-to-end encrypted on either path. Those servers are contacted only for such a send. Choose **Relay only** under [power options](guide/power-options.md) and your sends never contact them. A receive follows the sender's choice, so receiving a large file from a sender on Automatic (or the croc CLI's default) can use them. - **Local network when possible.** Devices on the same network transfer directly; the data never leaves your network. The local path and the relay race, and the faster one wins. +- **Stored uploads talk to one server.** [Send as link](guide/stored-transfers.md) encrypts the files on your device and uploads them to the server named in Settings (by default `https://getcroc.com`, run by croc's author, not by CrocApp). The key is in the part of the link after the `#`, which is never sent to the server, so the server holds ciphertext it cannot read. A stored upload contacts that server and nothing else. Anyone with the whole link can decrypt, and you can revoke an upload from [History](guide/history.md). - **Links carry the code in the fragment.** A `https://crocapp.dev/r/#` link keeps the code after the `#`, which browsers never send to a server. The page at that address makes no network request and sets a `no-referrer` policy. Anyone who sees the link still has the code, so share it the way you would share the code. - **You stay in control.** Nothing is saved without your OK: incoming transfers show a file preview you accept or decline. Auto-accept is off unless you turn it on. From 7127f4f971c804de9cf509bf78a428b2e324d361 Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Thu, 15 Oct 2026 00:45:43 +0200 Subject: [PATCH 5/8] Show the Send as link toggle off while it is unavailable --- app/CrocApp/Views/SendView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/CrocApp/Views/SendView.swift b/app/CrocApp/Views/SendView.swift index 4be64e2..c882c31 100644 --- a/app/CrocApp/Views/SendView.swift +++ b/app/CrocApp/Views/SendView.swift @@ -127,7 +127,7 @@ struct SendView: View { @ViewBuilder private var linkOptions: some View { VStack(alignment: .leading, spacing: Spacing.space3) { - Toggle("Send as link", isOn: $sendAsLink) + Toggle("Send as link", isOn: Binding(get: { linkSelected }, set: { sendAsLink = $0 })) .disabled(linkUnavailableReason != nil) if let reason = linkUnavailableReason { Text(reason).font(.caption).foregroundStyle(.secondary) From 8bad98092dc15a3e1facd7337d84409859c45e65 Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Thu, 15 Oct 2026 01:11:16 +0200 Subject: [PATCH 6/8] Leave a download unused when the interop harness revokes, so a broken revoke fails scenario 14 Scenarios 14a and 14c uploaded with one download and used it before revoking. The store server then answered revoke with 410, which the wrapper and the CLI both count as revoked, so 14b and 14c passed with revoke broken. Two downloads leave one at revoke time. Checked with revoke disabled in RevokeStored (14b fails) and with the CLI revoke skipped (14c fails). --- scripts/verify-interop.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/verify-interop.sh b/scripts/verify-interop.sh index 55c82ce..cfb1a4e 100755 --- a/scripts/verify-interop.sh +++ b/scripts/verify-interop.sh @@ -312,8 +312,11 @@ field() { # field LOGFILE KEY: a strin } cli_store() { CROC_CONFIG_DIR="$TMP/croc-config14" "$CROC" --disable-clipboard "$@"; } -# 14a. wrapper uploads two files (the 5 MiB one spans two chunks), CLI downloads -"$CT" store-send -server "$STORE" -downloads 1 -lifetime 3600 "$TMP/src/a.txt" "$TMP/src/big.bin" > "$TMP/14a.log" 2>&1 || fail "store send (wrapper)" +# 14a. wrapper uploads two files (the 5 MiB one spans two chunks), CLI downloads. +# Two downloads allowed so one is left at revoke time: the last download makes +# the server answer revoke with 410, which counts as revoked, so a broken revoke +# would still pass 14b and 14c. +"$CT" store-send -server "$STORE" -downloads 2 -lifetime 3600 "$TMP/src/a.txt" "$TMP/src/big.bin" > "$TMP/14a.log" 2>&1 || fail "store send (wrapper)" LINK=$(field "$TMP/14a.log" link); ID=$(field "$TMP/14a.log" id); TOKEN=$(field "$TMP/14a.log" uploadToken) [ -n "$LINK" ] && [ -n "$ID" ] && [ -n "$TOKEN" ] || fail "store send (wrapper): no receipt" grep -q 'EVENT done {"files":2,"success":true' "$TMP/14a.log" || fail "store send (wrapper): no success summary" @@ -334,7 +337,7 @@ pass "store: revoke, then CLI download fails" # 14c. CLI uploads, wrapper downloads, then the CLI revokes and the wrapper's # download fails with storeExpired -cli_store --ignore-stdin store --url "$STORE" --downloads 1 --expiration 1h "$TMP/src/a.txt" "$TMP/src/big.bin" > "$TMP/14c.cli" 2>&1 || fail "store send (CLI)" +cli_store --ignore-stdin store --url "$STORE" --downloads 2 --expiration 1h "$TMP/src/a.txt" "$TMP/src/big.bin" > "$TMP/14c.cli" 2>&1 || fail "store send (CLI)" LINK=$(grep -o 'http://127.0.0.1:[0-9]*/s/[A-Za-z0-9_-]*#v1\.[A-Za-z0-9_-]*' "$TMP/14c.cli" | head -1) ID=${LINK#*/s/}; ID=${ID%%#*} [ -n "$LINK" ] && [ -n "$ID" ] || fail "store send (CLI): no link in its output" From 0cd77a45d312c89674d55b263f5f7cab1b85095e Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Thu, 15 Oct 2026 01:27:32 +0200 Subject: [PATCH 7/8] Install the croc CLI at the engine's version in the interop job, since Homebrew's bottle lags and has no croc store --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6358ed6..e80c6ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,14 @@ jobs: go-version-file: crocmobile/go.mod cache-dependency-path: crocmobile/go.sum - - run: brew install croc + # The CLI peer must match the croc the engine embeds: Homebrew's bottle + # lagged at 11.5.0, which has no `croc store`. + - name: Install the croc CLI at the engine version + shell: bash + run: | + set -euo pipefail + v=$(cd crocmobile && go list -m -f '{{.Version}}' github.com/schollz/croc/v11) + GOBIN="$RUNNER_TEMP/croc11" go install "github.com/schollz/croc/v11@$v" # The R8 scenario's croc v10 peer. - run: go install "github.com/schollz/croc/v10@$(cat scripts/croc-v10.version)" @@ -135,7 +142,7 @@ jobs: shell: bash run: | set -euo pipefail - croc=$(brew --prefix)/bin/croc + croc="$RUNNER_TEMP/croc11/croc" "$croc" relay --host 127.0.0.1 --ports 9309,9310,9311,9312,9313 \ --source-join-limit 1000 --room-join-limit 1000 > "$RUNNER_TEMP/relay.log" 2>&1 & for _ in $(seq 50); do nc -z 127.0.0.1 9309 && break; sleep 0.2; done From 0fb79321269cf9c3039638f47536eb95c21a24d5 Mon Sep 17 00:00:00 2001 From: Bakir Gracic Date: Thu, 15 Oct 2026 01:27:38 +0200 Subject: [PATCH 8/8] Document the interop job's croc CLI install in tooling.md --- docs/knowledge/tooling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/knowledge/tooling.md b/docs/knowledge/tooling.md index 6a0152e..dee51c8 100644 --- a/docs/knowledge/tooling.md +++ b/docs/knowledge/tooling.md @@ -36,7 +36,7 @@ Xcode baseline lives in `.xcode-version` (plain text, one line). CI reads it int CI gotchas: -- **`ci.yml` has no workflow-level `paths:`.** A filtered-out workflow never reports, so a required check would stay pending. Instead a `changes` job diffs the PR base (or the push's `before`) against the head with plain `git diff --name-only` and sets one output per area (`swift`, `go`, `build`, `design`, `interop`); each job gates on its output with `if:`. A skipped job counts as passed. Adding a file CI *does* consume (a new script, a new dotfile config) means adding it to the matching regex in the `changes` job. An unknown base (new branch, force push) runs everything. A skipped matrix job reports under its unexpanded name and stays pending, so branch protection requires only the `ci pass` aggregate job, never the individual jobs. Unit tests ride on existing jobs: `go test -race` in `reusable-go.yml` (with `GOOS`/`GOARCH` unset for that step, since the job-level `darwin` would build a test binary the Linux runner cannot run), and `swift test` plus `xcodebuild test` on the macOS leg of `build`. The `interop (local relay)` job (`macos-26`) runs `scripts/verify-interop.sh` against a `croc relay` on 127.0.0.1 with raised admission limits (the public relays rate-limit a shared runner address); it installs the v11 CLI with `brew install croc` and the v10 peer with `go install` at the version in `scripts/croc-v10.version`. The other harnesses and the public-pool scenario are not run by CI. +- **`ci.yml` has no workflow-level `paths:`.** A filtered-out workflow never reports, so a required check would stay pending. Instead a `changes` job diffs the PR base (or the push's `before`) against the head with plain `git diff --name-only` and sets one output per area (`swift`, `go`, `build`, `design`, `interop`); each job gates on its output with `if:`. A skipped job counts as passed. Adding a file CI *does* consume (a new script, a new dotfile config) means adding it to the matching regex in the `changes` job. An unknown base (new branch, force push) runs everything. A skipped matrix job reports under its unexpanded name and stays pending, so branch protection requires only the `ci pass` aggregate job, never the individual jobs. Unit tests ride on existing jobs: `go test -race` in `reusable-go.yml` (with `GOOS`/`GOARCH` unset for that step, since the job-level `darwin` would build a test binary the Linux runner cannot run), and `swift test` plus `xcodebuild test` on the macOS leg of `build`. The `interop (local relay)` job (`macos-26`) runs `scripts/verify-interop.sh` against a `croc relay` on 127.0.0.1 with raised admission limits (the public relays rate-limit a shared runner address); it installs the v11 CLI with `go install` at the version in `crocmobile/go.mod` (Homebrew's bottle lagged at 11.5.0, which has no `croc store`) and the v10 peer with `go install` at the version in `scripts/croc-v10.version`. The other harnesses and the public-pool scenario are not run by CI. - **govulncheck under the go job's cross-compile env.** The go job sets `GOOS=darwin GOARCH=arm64` job-wide so vet/build analyse the shipping target. `go install` honours that and drops a darwin binary into `$GOPATH/bin/darwin_arm64/` (unrunnable on the Linux host, and not where `$(go env GOPATH)/bin/…` looks) → exit 127. Install the tool with `env -u GOOS -u GOARCH` so the binary is host-native in `$GOPATH/bin`, then run it with the job env intact so the *analysis* still targets darwin. - **`conflicting nullability specifier on return types` was fixed on the Go side, not suppressed.** It used to fire on every macOS/iOS build: gomobile treats any `New` func as a constructor and builds the ObjC selector from whatever *follows* that prefix (`bind/gen.go`, `bind/genobjc.go`), so `NewOptions` on type `Options` left an empty suffix and emitted a bare `- (nullable instancetype)init` on `CrocmobileOptions`, clashing with `NSObject`'s `nonnull init`. Renaming the Go constructor to `NewOptionsDefault` keeps it recognised and yields `initDefault`. Do not rename it back, and do not add a `New` func whose suffix is empty. - **macOS app build has no signing identity.** The runner has no `Mac Development` cert, so the `build (macOS)` job passes `CODE_SIGNING_ALLOWED=NO` to `xcodebuild`. The iOS Simulator destination never signs, so it needs nothing.