Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.

- Advanced settings (Settings > Advanced on iOS, the Advanced tab of the macOS Settings window): SOCKS5 and HTTP proxies, a built-in DNS resolver option, the encryption curve, the file hash, an upload speed limit, the base port and number of parallel connections, a multiplexing switch, a sender IP to receive from directly, and, on macOS, a multicast address. Each shows its default, checks what you type, and says which device it affects and whether the other device must match. Reset to Defaults puts them all back. The proxies also apply to Send as link and are kept in the Keychain, never in the settings file. A receive with a sender IP connects straight to the sender and skips relays. iOS has no multicast setting, because the multicast entitlement is not granted.
- Menu bar quick send on macOS. A CrocApp icon in the menu bar opens a small window: drop files or folders on it, choose them with Choose Files…, or send the clipboard text. It then shows the code, a QR code, Copy Code, progress and Cancel. It shares the one active transfer with the main window, so it also shows a transfer started there or from Shortcuts. Quick send always uses a generated code. Settings > General has Show in menu bar (on by default) and Open at login (off by default; it follows System Settings > General > Login Items and offers a shortcut there when macOS needs your approval).
- Shortcuts and Siri actions: Send Files with CrocApp, Send Text with CrocApp and Receive with CrocApp. The send actions return the code as soon as croc has one, so keep CrocApp open until the receiver finishes. Receive takes a code or any link CrocApp opens and returns the received files; it does not answer the incoming-files prompt, so turn on Auto-accept or answer it in the app. The actions also appear in Spotlight and Siri with the phrases "Send files with CrocApp", "Send text with CrocApp" and "Receive with CrocApp".
- The iPad menu bar has Show Receive Folder in Files (⇧⌘R) in the File menu next to Send… and Receive…. On a Mac the same shortcut shows the folder in Finder.

### Changed

- The QR code on the sender's waiting screen now encodes a `https://crocapp.dev/r/` link instead of `croc://<code>`, so a phone camera opens CrocApp, or a page that explains what to do when the app is missing. QR codes from 0.9.9 still scan in the app.
Expand Down
37 changes: 33 additions & 4 deletions CrocKit/Sources/CrocKit/CrocEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public actor CrocEngine {
opts.server = options.server
opts.downloads = options.downloads
opts.lifetimeSeconds = options.lifetimeSeconds
opts.socks5Proxy = options.proxy.socks5
opts.httpProxy = options.proxy.http
let joined = paths.joined(separator: "\n")
return try start { bridge, error in
CrocmobileStartStoreSend(joined, opts, bridge, error)
Expand All @@ -61,17 +63,26 @@ public actor CrocEngine {

/// 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) }
public func startStoreReceive(
link: String, outDir: String, proxy: ProxySettings
) throws -> EngineTransfer {
let opts = Self.storeOptions(proxy: proxy)
return try start { bridge, error in
CrocmobileStartStoreReceive(link, outDir, opts, 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 {
public nonisolated func revokeStored(
_ receipt: StoredReceipt, proxy: ProxySettings
) async throws {
var error: NSError?
guard CrocmobileRevokeStored(receipt.origin, receipt.id, receipt.uploadToken, &error)
let opts = Self.storeOptions(proxy: proxy)
guard
CrocmobileRevokeStored(receipt.origin, receipt.id, receipt.uploadToken, opts, &error)
else {
throw EngineFailure(payload: error?.localizedDescription)
}
Expand Down Expand Up @@ -134,6 +145,13 @@ public actor CrocEngine {
bridge = nil
}

private static func storeOptions(proxy: ProxySettings) -> CrocmobileStoreOptions {
let opts = CrocmobileStoreOptions()
opts.socks5Proxy = proxy.socks5
opts.httpProxy = proxy.http
return opts
}

static func crocOptions(from o: EngineOptions) throws -> CrocmobileOptions {
guard let opts = CrocmobileNewOptionsDefault() else {
throw CrocEngineError.startFailed(
Expand All @@ -156,6 +174,17 @@ public actor CrocEngine {
opts.gitIgnore = o.gitIgnore
opts.exclude = o.exclude.joined(separator: "\n")
opts.ask = o.ask
opts.curve = o.curve.rawValue
opts.hashAlgorithm = o.hashAlgorithm.rawValue
opts.throttleUpload = o.throttleUpload
opts.noMultiplexing = o.noMultiplexing
opts.basePort = o.basePort
opts.transfers = o.transfers
opts.multicastAddress = o.multicastAddress
opts.peerIP = o.peerIP
opts.socks5Proxy = o.socks5Proxy
opts.httpProxy = o.httpProxy
opts.internalDNS = o.internalDNS
return opts
}
}
74 changes: 74 additions & 0 deletions CrocKit/Sources/CrocKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@ public enum Transport: String, Sendable, CaseIterable {
case relay
}

/// The PAKE curve the key exchange runs on. Both sides must pick the same one.
/// Raw values are croc's `--curve` names; `p256` is croc's default.
public enum Curve: String, Sendable, CaseIterable {
case p256, p384, p521, siec, ed25519
}

/// How the sender hashes files to verify them. The receiver uses the sender's
/// choice. Raw values are croc's `--hash` names; `xxhash` is croc's default.
public enum HashAlgorithm: String, Sendable, CaseIterable {
case xxhash, imohash, md5, highway
}

/// The proxies a connection goes through. Either may carry credentials in a
/// URL (`socks5://user:secret@host:1080`), so the description never shows them.
/// Blank means none; SOCKS5 wins when both are set.
public struct ProxySettings: Sendable, Equatable {
/// `host:port` or a `socks5://` / `socks5h://` URL.
public var socks5 = ""
/// `host:port` or an `http://` / `https://` URL.
public var http = ""
public init(socks5: String = "", http: String = "") {
self.socks5 = socks5
self.http = http
}
}

extension ProxySettings: CustomStringConvertible, CustomDebugStringConvertible {
public var description: String {
"ProxySettings(socks5: \(socks5.isEmpty ? "none" : "set"), http: \(http.isEmpty ? "none" : "set"))"
}
public var debugDescription: String { description }
}

public struct EngineOptions: Sendable {
/// Blank relay fields mean croc's defaults (see `RelayDefaults`).
public var relayAddress = ""
Expand All @@ -55,9 +88,47 @@ public struct EngineOptions: Sendable {
/// Both-sides confirm (croc --ask). Sender prompt is bridged via the
/// stdin pipe; respond(accept:policy:) answers it.
public var ask = false

// Advanced options. The defaults are croc's own (`EngineOptionsDefaultsTests`
// checks them against the engine).

/// Receiving: the sender adopts the receiver's curve, so peers need not match.
public var curve = Curve.p256
/// Send only; the receiver uses the sender's choice.
public var hashAlgorithm = HashAlgorithm.xxhash
/// Send only: bytes per second, digits with an optional `k`, `m` or `g`
/// (`"500k"`). Blank means no limit.
public var throttleUpload = ""
/// Send only: one data connection instead of several.
public var noMultiplexing = false
/// Send only: the first port of this side's local relay.
public var basePort = Int(CrocmobileDefaultBasePort)
/// Send only: data ports after `basePort`. All ports must stay at or below 65535.
public var transfers = Int(CrocmobileDefaultTransfers)
/// IPv4 multicast group for finding peers on the local network. Both sides
/// must use the same one.
public var multicastAddress = CrocmobileDefaultMulticastAddress
/// Receive only: the sender's `ip:port` (IPv6 in brackets). Connects
/// straight to it and turns the public relay pool off.
public var peerIP = ""
/// May carry credentials; see `ProxySettings`.
public var socks5Proxy = ""
/// May carry credentials; see `ProxySettings`.
public var httpProxy = ""
/// Resolve relay names with croc's built-in resolver instead of the system's.
public var internalDNS = false
public init() {}
}

extension EngineOptions: CustomStringConvertible, CustomDebugStringConvertible {
/// Names the options without their values: the relay password and the proxies are secrets.
public var description: String {
"EngineOptions(relay: \(relayAddress.isEmpty ? "default" : "custom"), "
+ "proxy: \(socks5Proxy.isEmpty && httpProxy.isEmpty ? "none" : "set"))"
}
public var debugDescription: String { description }
}

public enum TransferEvent: Sendable {
case codeReady(String)
case connected
Expand Down Expand Up @@ -119,6 +190,7 @@ public struct StoredSendOptions: Sendable {
/// 1 to 100.
public var downloads = 1
public var lifetimeSeconds = 86_400
public var proxy = ProxySettings()
public init() {}
}

Expand All @@ -135,6 +207,8 @@ public struct EngineFailure: Error, Equatable, Sendable {
case storeLimit
/// Stored transfers: the server no longer has it (expired, revoked, used up).
case storeExpired
/// An Advanced option (proxy, ports, multicast address, sender IP) failed the engine's check.
case invalidSetting
}

public let code: Code
Expand Down
92 changes: 92 additions & 0 deletions CrocKit/Tests/CrocKitTests/CrocOptionsMappingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,66 @@ import Testing
#expect(o.ask)
}

/// One non-default value per Advanced option, and one assert per field.
@Test func advancedFieldsMap() throws {
var e = EngineOptions()
e.curve = .ed25519
e.hashAlgorithm = .highway
e.throttleUpload = "500k"
e.noMultiplexing = true
e.basePort = 9100
e.transfers = 2
e.multicastAddress = "224.0.0.251"
e.peerIP = "10.0.0.1:9100"
e.socks5Proxy = "socks5://user:pw@proxy.example:1080"
e.httpProxy = "http://user:pw@proxy.example:3128"
e.internalDNS = true

let o = try CrocEngine.crocOptions(from: e)
#expect(o.curve == "ed25519")
#expect(o.hashAlgorithm == "highway")
#expect(o.throttleUpload == "500k")
#expect(o.noMultiplexing)
#expect(o.basePort == 9100)
#expect(o.transfers == 2)
#expect(o.multicastAddress == "224.0.0.251")
#expect(o.peerIP == "10.0.0.1:9100")
#expect(o.socks5Proxy == "socks5://user:pw@proxy.example:1080")
#expect(o.httpProxy == "http://user:pw@proxy.example:3128")
#expect(o.internalDNS)
}

@Test(arguments: Curve.allCases)
func everyCurveMapsByName(_ curve: Curve) throws {
var e = EngineOptions()
e.curve = curve
#expect(try CrocEngine.crocOptions(from: e).curve == curve.rawValue)
}

@Test(arguments: HashAlgorithm.allCases)
func everyHashMapsByName(_ hash: HashAlgorithm) throws {
var e = EngineOptions()
e.hashAlgorithm = hash
#expect(try CrocEngine.crocOptions(from: e).hashAlgorithm == hash.rawValue)
}

@Test func descriptionsHideSecrets() {
var e = EngineOptions()
e.relayPassword = "relay-secret"
e.socks5Proxy = "socks5://user:proxy-secret@proxy.example:1080"
e.httpProxy = "http://user:http-secret@proxy.example:3128"
var stored = StoredSendOptions()
stored.proxy = ProxySettings(socks5: e.socks5Proxy, http: e.httpProxy)
let texts = [
String(describing: e), String(reflecting: e), "\(e)",
String(describing: stored.proxy), String(reflecting: stored.proxy),
]
for text in texts {
#expect(!text.contains("secret"))
#expect(!text.contains("proxy.example"))
}
}

private struct Flag {
let name: String
let source: WritableKeyPath<EngineOptions, Bool>
Expand All @@ -79,6 +139,8 @@ import Testing
Flag(name: "zipFolder", source: \.zipFolder, target: \.zipFolder),
Flag(name: "gitIgnore", source: \.gitIgnore, target: \.gitIgnore),
Flag(name: "ask", source: \.ask, target: \.ask),
Flag(name: "noMultiplexing", source: \.noMultiplexing, target: \.noMultiplexing),
Flag(name: "internalDNS", source: \.internalDNS, target: \.internalDNS),
]
for flag in flags {
var e = EngineOptions()
Expand All @@ -92,3 +154,33 @@ import Testing
}
}
}

/// The Swift defaults for the shared fields are the engine's, so a setting left
/// alone behaves like the croc CLI.
@Suite struct EngineOptionsDefaultsTests {
@Test func sharedFieldsEqualTheEnginesDefaults() throws {
let engine = try #require(CrocmobileNewOptionsDefault())
let mapped = try CrocEngine.crocOptions(from: EngineOptions())
#expect(mapped.curve == engine.curve)
#expect(mapped.hashAlgorithm == engine.hashAlgorithm)
#expect(mapped.throttleUpload == engine.throttleUpload)
#expect(mapped.noMultiplexing == engine.noMultiplexing)
#expect(mapped.basePort == engine.basePort)
#expect(mapped.transfers == engine.transfers)
#expect(mapped.multicastAddress == engine.multicastAddress)
#expect(mapped.peerIP == engine.peerIP)
#expect(mapped.socks5Proxy == engine.socks5Proxy)
#expect(mapped.httpProxy == engine.httpProxy)
#expect(mapped.internalDNS == engine.internalDNS)
#expect(mapped.transport == engine.transport || engine.transport.isEmpty)
}

@Test func defaultsAreCrocs() {
let e = EngineOptions()
#expect(e.curve == .p256)
#expect(e.hashAlgorithm == .xxhash)
#expect(e.basePort == 9009)
#expect(e.transfers == 4)
#expect(e.multicastAddress == "239.255.255.250")
}
}
45 changes: 43 additions & 2 deletions CrocKit/Tests/CrocKitTests/StoreEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ import Testing
@Test func badLinkIsRefused() async {
let engine = CrocEngine()
let failure = await startFailure {
try await engine.startStoreReceive(link: "not a link", outDir: NSTemporaryDirectory())
try await engine.startStoreReceive(
link: "not a link", outDir: NSTemporaryDirectory(), proxy: ProxySettings())
}
#expect(failure?.code == .other)
}
Expand All @@ -55,7 +56,47 @@ import Testing
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)
try await CrocEngine().revokeStored(receipt, proxy: ProxySettings())
}
}

/// A bad proxy is refused before any network use, with the engine's code
/// and without the credentials in the error text.
@Test func badProxyIsInvalidSetting() async {
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.proxy = ProxySettings(socks5: "http://user:secret@proxy.example:1080")
let engine = CrocEngine()
let failure = await startFailure {
try await engine.startStoreSend(paths: [file.path], options: options)
}
#expect(failure?.code == .invalidSetting)
#expect(failure?.message.contains("secret") == false)
}

@Test func badProxyFailsReceiveAndRevoke() async {
let bad = ProxySettings(http: "socks5://user:secret@proxy.example:3128")
let engine = CrocEngine()
let failure = await startFailure {
try await engine.startStoreReceive(
link:
"https://getcroc.com/s/AAAAAAAAAAAAAAAAAAAAAA#v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
outDir: NSTemporaryDirectory(), proxy: bad)
}
#expect(failure?.code == .invalidSetting)
let receipt = StoredReceipt(
id: "AAAAAAAAAAAAAAAAAAAAAA", origin: "http://127.0.0.1:1", uploadToken: "t",
link: "", expiresAt: .now, downloads: 1)
do {
try await engine.revokeStored(receipt, proxy: bad)
Issue.record("revoke with a bad proxy succeeded")
} catch let failure as EngineFailure {
#expect(failure.code == .invalidSetting)
#expect(!failure.message.contains("secret"))
} catch {
Issue.record("unexpected error \(error)")
}
}
}
Loading
Loading