Skip to content
Merged
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 42 additions & 12 deletions CrocKit/Sources/CrocKit/CrocEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions CrocKit/Sources/CrocKit/DelegateBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransferEvent>.Continuation) {
self.continuation = continuation
Expand Down Expand Up @@ -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))
Expand Down
58 changes: 58 additions & 0 deletions CrocKit/Sources/CrocKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions CrocKit/Sources/crockit-verify/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func drive(_ stream: AsyncStream<TransferEvent>, 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
Expand Down
49 changes: 49 additions & 0 deletions CrocKit/Tests/CrocKitTests/DelegateBridgeTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing

@testable import CrocKit
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion CrocKit/Tests/CrocKitTests/EngineFailureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

Expand Down
61 changes: 61 additions & 0 deletions CrocKit/Tests/CrocKitTests/StoreEngineTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
5 changes: 4 additions & 1 deletion app/CrocApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion app/CrocApp/CrocAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading