diff --git a/CHANGELOG.md b/CHANGELOG.md index 48060568..6ed77a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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://`, 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. diff --git a/CrocKit/Sources/CrocKit/CrocEngine.swift b/CrocKit/Sources/CrocKit/CrocEngine.swift index e9de9e43..9eaa1f0d 100644 --- a/CrocKit/Sources/CrocKit/CrocEngine.swift +++ b/CrocKit/Sources/CrocKit/CrocEngine.swift @@ -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) @@ -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) } @@ -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( @@ -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 } } diff --git a/CrocKit/Sources/CrocKit/Models.swift b/CrocKit/Sources/CrocKit/Models.swift index e2d01ae5..8972d1d4 100644 --- a/CrocKit/Sources/CrocKit/Models.swift +++ b/CrocKit/Sources/CrocKit/Models.swift @@ -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 = "" @@ -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 @@ -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() {} } @@ -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 diff --git a/CrocKit/Tests/CrocKitTests/CrocOptionsMappingTests.swift b/CrocKit/Tests/CrocKitTests/CrocOptionsMappingTests.swift index 8988ffe3..dd47aba8 100644 --- a/CrocKit/Tests/CrocKitTests/CrocOptionsMappingTests.swift +++ b/CrocKit/Tests/CrocKitTests/CrocOptionsMappingTests.swift @@ -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 @@ -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() @@ -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") + } +} diff --git a/CrocKit/Tests/CrocKitTests/StoreEngineTests.swift b/CrocKit/Tests/CrocKitTests/StoreEngineTests.swift index fa5a3a2c..04f770d6 100644 --- a/CrocKit/Tests/CrocKitTests/StoreEngineTests.swift +++ b/CrocKit/Tests/CrocKitTests/StoreEngineTests.swift @@ -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) } @@ -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)") } } } diff --git a/app/CrocApp/CrocAppApp.swift b/app/CrocApp/CrocAppApp.swift index 89e1afa0..2513013b 100644 --- a/app/CrocApp/CrocAppApp.swift +++ b/app/CrocApp/CrocAppApp.swift @@ -5,6 +5,7 @@ // Created by admin on 21.07.2026. // +import AppIntents import SwiftData import SwiftUI @@ -13,11 +14,14 @@ struct CrocAppApp: App { @State private var settings: AppSettings @State private var controller: TransferController @State private var history: HistoryStore - @State private var outputFolder = OutputFolderStore() + @State private var outputFolder: OutputFolderStore @State private var localNetwork = LocalNetworkChecker() @State private var router = AppRouter.shared + static let mainWindowID = "main" + #if os(macOS) + @State private var quickSend: MenuBarQuickSend @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate #endif @@ -33,13 +37,21 @@ struct CrocAppApp: App { let controller = TransferController(settings: settings, vault: vault) controller.history = history _controller = State(initialValue: controller) + let outputFolder = OutputFolderStore() + _outputFolder = State(initialValue: outputFolder) + // Shortcuts actions can run right after launch, before any window. + AppDependencyManager.shared.add(dependency: controller) + AppDependencyManager.shared.add(dependency: outputFolder) + #if os(macOS) + _quickSend = State(initialValue: MenuBarQuickSend(controller: controller)) + #endif // AppRouter.shared is constructed before this init runs; attach the // controller now so AppRouter.isBusy can read isActive live. AppRouter.shared.attach(controller: controller) } var body: some Scene { - WindowGroup { + WindowGroup(id: Self.mainWindowID) { ContentView() .environment(controller) .environment(settings) @@ -69,6 +81,16 @@ struct CrocAppApp: App { } #if os(macOS) + MenuBarExtra( + "CrocApp", systemImage: "arrow.up.arrow.down.circle", + isInserted: Binding( + get: { settings.showInMenuBar }, set: { settings.showInMenuBar = $0 }) + ) { + MenuBarQuickSendView(quickSend: quickSend) + .environment(controller) + } + .menuBarExtraStyle(.window) + Settings { SettingsView() .environment(outputFolder) diff --git a/app/CrocApp/Intents/CrocAppShortcuts.swift b/app/CrocApp/Intents/CrocAppShortcuts.swift new file mode 100644 index 00000000..aea881a7 --- /dev/null +++ b/app/CrocApp/Intents/CrocAppShortcuts.swift @@ -0,0 +1,23 @@ +import AppIntents + +/// The actions as ready-made shortcuts, so they appear in the Shortcuts app +/// and in Spotlight without setup. Every phrase must name the app. +struct CrocAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: SendFilesIntent(), + phrases: ["Send files with \(.applicationName)"], + shortTitle: "Send Files", + systemImageName: "arrow.up.circle") + AppShortcut( + intent: SendTextIntent(), + phrases: ["Send text with \(.applicationName)"], + shortTitle: "Send Text", + systemImageName: "text.bubble") + AppShortcut( + intent: ReceiveIntent(), + phrases: ["Receive with \(.applicationName)"], + shortTitle: "Receive", + systemImageName: "arrow.down.circle") + } +} diff --git a/app/CrocApp/Intents/ReceiveIntent.swift b/app/CrocApp/Intents/ReceiveIntent.swift new file mode 100644 index 00000000..a5158c18 --- /dev/null +++ b/app/CrocApp/Intents/ReceiveIntent.swift @@ -0,0 +1,52 @@ +import AppIntents +import Foundation + +/// Receives with a code or any link CrocApp opens (`TransferLink`), then +/// returns the files. The incoming-files prompt still appears in the app +/// unless auto-accept is on in Settings: a shortcut does not answer it. +struct ReceiveIntent: AppIntent { + static let title: LocalizedStringResource = "Receive with CrocApp" + static let description = IntentDescription( + "Receives files with a croc code or a download link and returns them.") + static let supportedModes: IntentModes = .foreground + + @Parameter(title: "Code or Link") + var codeOrLink: String + + @Dependency var controller: TransferController + @Dependency var outputFolder: OutputFolderStore + + static var parameterSummary: some ParameterSummary { + Summary("Receive \(\.$codeOrLink) with CrocApp") + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue<[IntentFile]> & ProvidesDialog { + guard let link = TransferLink.parse(codeOrLink) else { + throw TransferIntentError.notACodeOrLink + } + try controller.claimForIntent() + let started = Date() + let folder = outputFolder.url + AppRouter.shared.openReceive() + switch link { + case .code(let code): + controller.startReceive( + code: code, into: folder, folderIsScoped: outputFolder.isUserSelected) + case .stored(let url): + controller.startStoreReceive( + link: url, into: folder, folderIsScoped: outputFolder.isUserSelected) + } + let text = try await controller.awaitReceive() + // The controller let go of a chosen folder when the transfer ended. + let scoped = outputFolder.isUserSelected && folder.startAccessingSecurityScopedResource() + defer { if scoped { folder.stopAccessingSecurityScopedResource() } } + var urls = ReceivedFileLocator.locate( + names: controller.offeredFileNames, in: folder, since: started) + if let text { urls.append(try IntentStaging.textFile(text)) } + let files = urls.map { IntentFile(fileURL: $0) } + return .result( + value: files, + dialog: files.count == 1 ? "Received 1 item." : "Received \(files.count) items.") + } +} diff --git a/app/CrocApp/Intents/SendIntents.swift b/app/CrocApp/Intents/SendIntents.swift new file mode 100644 index 00000000..6bdd314b --- /dev/null +++ b/app/CrocApp/Intents/SendIntents.swift @@ -0,0 +1,66 @@ +import AppIntents +import UniformTypeIdentifiers + +/// Both send actions start a transfer on the app's one `TransferController` +/// and return its code as soon as croc has one. A transfer needs the app +/// running to its end, which on iOS lasts past the 30 s a background action +/// gets, so the actions run in the foreground. +struct SendFilesIntent: AppIntent { + static let title: LocalizedStringResource = "Send Files with CrocApp" + static let description = IntentDescription( + "Sends files or folders with a croc code and returns the code. Keep CrocApp open until the transfer ends." + ) + static let supportedModes: IntentModes = .foreground + + @Parameter(title: "Files", supportedContentTypes: [.item]) + var files: [IntentFile] + + @Dependency var controller: TransferController + + static var parameterSummary: some ParameterSummary { + Summary("Send \(\.$files) with CrocApp") + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + guard !files.isEmpty else { throw TransferIntentError.nothingToSend } + try controller.claimForIntent() + IntentStaging.sweep() + let urls = try IntentStaging.urls(for: files) + AppRouter.shared.path = [.send] + controller.startSend(urls: urls, customCode: "") + let code = try await controller.awaitCode() + return .result( + value: code, + dialog: "The code is \(code). Keep CrocApp open until the transfer ends.") + } +} + +struct SendTextIntent: AppIntent { + static let title: LocalizedStringResource = "Send Text with CrocApp" + static let description = IntentDescription( + "Sends text with a croc code and returns the code. Keep CrocApp open until the transfer ends." + ) + static let supportedModes: IntentModes = .foreground + + @Parameter(title: "Text") + var text: String + + @Dependency var controller: TransferController + + static var parameterSummary: some ParameterSummary { + Summary("Send \(\.$text) with CrocApp") + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + guard !text.isEmpty else { throw TransferIntentError.nothingToSend } + try controller.claimForIntent() + AppRouter.shared.path = [.send] + controller.startSendText(text, customCode: "") + let code = try await controller.awaitCode() + return .result( + value: code, + dialog: "The code is \(code). Keep CrocApp open until the transfer ends.") + } +} diff --git a/app/CrocApp/Intents/TransferIntentSupport.swift b/app/CrocApp/Intents/TransferIntentSupport.swift new file mode 100644 index 00000000..789efa5a --- /dev/null +++ b/app/CrocApp/Intents/TransferIntentSupport.swift @@ -0,0 +1,154 @@ +import AppIntents +import Foundation + +/// Why a Shortcuts action could not finish; the text is what Shortcuts shows. +enum TransferIntentError: Error, CustomLocalizedStringResourceConvertible { + case busy + case nothingToSend + case notACodeOrLink + case failed(String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .busy: "CrocApp is already sending or receiving. Wait for it to finish." + case .nothingToSend: "There is nothing to send." + case .notACodeOrLink: "That is not a croc code or a CrocApp link." + case .failed(let message): "\(message)" + } + } +} + +extension TransferController { + /// Frees the controller for a Shortcuts action. A finished transfer keeps + /// its result on screen until someone taps Done, which nobody does on a + /// phone running a shortcut, so it is cleared here; one still in flight + /// refuses the action. + func claimForIntent() throws { + switch phase { + case .idle: return + case .done, .failed, .stored: reset() + default: throw TransferIntentError.busy + } + } + + /// Looks at the controller every 100 ms until `check` yields a value, or + /// returns nil once the action is cancelled. Polling, not observation: it + /// ends on cancellation at once, and what it reads outlives the moment + /// it changed. + func wait(for check: () -> T?) async -> T? { + while !Task.isCancelled { + if let value = check() { return value } + try? await Task.sleep(for: .milliseconds(100)) + } + return nil + } + + /// The code of a send just started, once croc has one. + func awaitCode() async throws -> String { + let outcome = await wait { () -> Result? in + if let code = currentCode { return .success(code) } + if case .failed(let message) = phase { return .failure(.failed(message)) } + return nil + } + return try settle(outcome) + } + + /// A cancelled action also stops the transfer it started. + private func settle(_ outcome: Result?) throws -> T { + guard let outcome else { + cancel() + throw TransferIntentError.failed("Cancelled.") + } + return try outcome.get() + } + + /// The end of a receive just started: its received text, or a failure. + func awaitReceive() async throws -> String? { + let outcome = await wait { () -> Result? in + switch phase { + case .done(_, let text): .success(text) + case .failed(let message): .failure(.failed(message)) + default: nil + } + } + return try settle(outcome) + } +} + +/// Files a Shortcuts action hands over that have no usable file URL are +/// written here, and swept at the start of the next send. A send outlives its +/// action, so they cannot be deleted when the action returns. +enum IntentStaging { + private static var directory: URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "ShortcutsFiles", isDirectory: true) + } + + static func sweep() { + try? FileManager.default.removeItem(at: directory) + } + + static func urls(for files: [IntentFile]) throws -> [URL] { + try files.map { file in + if let url = file.fileURL { return url } + let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let url = folder.appendingPathComponent(file.filename) + try file.data.write(to: url) + return url + } + } + + static func textFile(_ text: String) throws -> URL { + let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let url = folder.appendingPathComponent("Received text.txt") + try text.write(to: url, atomically: true, encoding: .utf8) + return url + } +} + +/// Finds what a receive wrote. croc keeps the sender's modification dates, so +/// the files that arrived are told apart by creation date: a Keep Both copy +/// ("name (1).ext") is new, while a Replace overwrites the file at its name. +enum ReceivedFileLocator { + static func locate(names: [String], in folder: URL, since: Date) -> [URL] { + let offered = Set(names) + return names.compactMap { name in + let wanted = folder.appendingPathComponent(name) + let parent = wanted.deletingLastPathComponent() + let stem = wanted.deletingPathExtension().lastPathComponent + let suffix = wanted.pathExtension.isEmpty ? "" : "." + wanted.pathExtension + let subfolder = (name as NSString).deletingLastPathComponent + func offeredName(_ file: String) -> String { + subfolder.isEmpty ? file : subfolder + "/" + file + } + let entries = + (try? FileManager.default.contentsOfDirectory( + at: parent, includingPropertiesForKeys: [.creationDateKey])) ?? [] + // A file the sender offered under its own name is not a copy of + // another one ("a.txt" and "a (1).txt" can both be offered). + let created = entries.filter { + let file = $0.lastPathComponent + return isCopy(file, stem: stem, suffix: suffix) + && (offeredName(file) == name || !offered.contains(offeredName(file))) + && creation($0) >= since + } + if let newest = created.max(by: { creation($0) < creation($1) }) { return newest } + return FileManager.default.fileExists(atPath: wanted.path) ? wanted : nil + } + } + + /// `name.ext` itself or its numbered Keep Both copy `name (n).ext`. + static func isCopy(_ file: String, stem: String, suffix: String) -> Bool { + guard file.hasPrefix(stem), file.hasSuffix(suffix), + file.count >= stem.count + suffix.count + else { return false } + let middle = String(file.dropFirst(stem.count).dropLast(suffix.count)) + return middle.isEmpty || middle.wholeMatch(of: /\ \(\d+\)/) != nil + } + + private static func creation(_ url: URL) -> Date { + (try? url.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast + } +} diff --git a/app/CrocApp/Models/AdvancedSettingsValidation.swift b/app/CrocApp/Models/AdvancedSettingsValidation.swift new file mode 100644 index 00000000..1a48115e --- /dev/null +++ b/app/CrocApp/Models/AdvancedSettingsValidation.swift @@ -0,0 +1,136 @@ +import Foundation +import Network + +/// Checks for the Advanced fields (R25). Each mirrors what the engine accepts +/// (`crocmobile/network.go`, and croc's own parsing for the upload limit) and +/// returns plain-words text, or nil when the value is fine. Blank is always +/// fine: it means croc's default. Proxy texts never repeat the value, which can +/// carry credentials. +extension AppSettings { + nonisolated static func socks5ProxyError(_ text: String) -> String? { + proxyError( + text, defaultScheme: "socks5", schemes: ["socks5", "socks5h"], + message: "Enter host:port, or an address starting with socks5:// or socks5h://.") + } + + nonisolated static func httpProxyError(_ text: String) -> String? { + proxyError( + text, defaultScheme: "http", schemes: ["http", "https"], + message: "Enter host:port, or an address starting with http:// or https://.") + } + + nonisolated static func multicastAddressError(_ text: String) -> String? { + let address = text.trimmingCharacters(in: .whitespacesAndNewlines) + if address.isEmpty { return nil } + guard let octets = ipv4Octets(address), (224...239).contains(octets[0]) else { + return "Enter an IPv4 multicast address from 224.0.0.0 to 239.255.255.255." + } + return nil + } + + /// `ip:port`, IPv4 or IPv6 in brackets. + nonisolated static func senderIPError(_ text: String) -> String? { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return nil } + let message = "Enter the sender's address and port, such as 192.168.1.20:9009." + let host: Substring + let port: Substring + if value.hasPrefix("[") { + guard let close = value.firstIndex(of: "]") else { return message } + host = value[value.index(after: value.startIndex).. String? { + if basePort < 1 || basePort > 65535 { return "The base port must be from 1 to 65535." } + if transfers < 1 { return "Use at least 1 parallel connection." } + if basePort + transfers > 65535 { + return + "The ports run from the base port up by the number of connections. Keep the last one at 65535 or lower." + } + return nil + } + + /// croc's format: whole bytes per second, optionally with k, m or g + /// (kilo-, mega-, gigabytes). A lone digit is refused: croc quietly ignores + /// a one-character value, which would look like a limit that is not there. + nonisolated static func throttleError(_ text: String) -> String? { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return nil } + let digits = + value.last.map { "kKmMgG".contains($0) } == true ? value.dropLast() : value[...] + let message = "Enter a speed such as 500k, 2m or 1g, or leave it blank for no limit." + guard value.count > 1, !digits.isEmpty, digits.allSatisfy({ $0.isASCII && $0.isNumber }), + let amount = Int64(digits), amount > 0 + else { return message } + return nil + } + + // MARK: - Helpers + + private nonisolated static func isPort(_ text: Substring) -> Bool { + guard !text.isEmpty, text.allSatisfy({ $0.isASCII && $0.isNumber }), + let number = Int(text) + else { return false } + return (1...65535).contains(number) + } + + /// Four decimal octets without leading zeros, as Go's `net.ParseIP` reads them. + private nonisolated static func ipv4Octets(_ text: String) -> [Int]? { + let parts = text.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + var octets: [Int] = [] + for part in parts { + guard !part.isEmpty, part.count <= 3, part.allSatisfy({ $0.isASCII && $0.isNumber }), + part == "0" || !part.hasPrefix("0"), let n = Int(part), n <= 255 + else { return nil } + octets.append(n) + } + return octets + } + + /// host:port or `scheme://[user:password@]host:port[/...]` with one of + /// `schemes`; a bare host:port takes `defaultScheme`, as in croc. + private nonisolated static func proxyError( + _ text: String, defaultScheme: String, schemes: [String], message: String + ) -> String? { + var value = text.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return nil } + guard !value.contains(where: { $0.isWhitespace || $0.isNewline }) else { return message } + var scheme = defaultScheme + if let range = value.range(of: "://") { + scheme = value[.. Advanced: every Advanced + /// control back to croc's default. Persists like any edit, and clearing the + /// proxies deletes their Keychain items. + func resetAdvancedToDefaults() { + let croc = Self.advancedDefaults + curve = croc.curve + hashAlgorithm = croc.hashAlgorithm + throttleUpload = croc.throttleUpload + noMultiplexing = croc.noMultiplexing + basePort = croc.basePort + transfers = croc.transfers + multicastAddress = croc.multicastAddress + peerIP = croc.peerIP + internalDNS = croc.internalDNS + socks5Proxy = "" + httpProxy = "" + } + + /// True when every Advanced control already holds its default. + var advancedIsDefault: Bool { + let croc = Self.advancedDefaults + return curve == croc.curve && hashAlgorithm == croc.hashAlgorithm + && throttleUpload.isEmpty && noMultiplexing == croc.noMultiplexing + && basePort == croc.basePort && transfers == croc.transfers + && multicastAddress == croc.multicastAddress && peerIP.isEmpty + && internalDNS == croc.internalDNS && socks5Proxy.isEmpty && httpProxy.isEmpty } /// One-time move of the relay password off UserDefaults (plaintext @@ -160,4 +254,16 @@ final class AppSettings { : secrets.set(relayPassword, account: Self.relayPasswordAccount) if stored { defaults.removeObject(forKey: Self.legacyRelayPasswordKey) } } + + /// Same `persist` gate as the other writes, but Keychain-only: a proxy URL + /// can hold a user name and password. Deletes rather than stores a blank. + private func persistProxy(_ value: String, account: String) { + guard persist else { return } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + secrets.delete(account: account) + } else { + secrets.set(trimmed, account: account) + } + } } diff --git a/app/CrocApp/Models/LoginItem.swift b/app/CrocApp/Models/LoginItem.swift new file mode 100644 index 00000000..25270819 --- /dev/null +++ b/app/CrocApp/Models/LoginItem.swift @@ -0,0 +1,49 @@ +#if os(macOS) +import Observation +import ServiceManagement + +/// The slice of `SMAppService` the Open at login toggle uses, so tests can +/// stand in for the system. +protocol LoginItemService { + var status: SMAppService.Status { get } + func register() throws + func unregister() throws +} + +extension SMAppService: LoginItemService {} + +/// Open at login (R26): the toggle shows what the system reports, never a +/// stored copy of what we asked for, because the user can change it in System +/// Settings > General > Login Items at any time. +@MainActor +@Observable +final class LoginItem { + private let service: any LoginItemService + private(set) var status: SMAppService.Status + private(set) var errorMessage: String? + + init(service: any LoginItemService = SMAppService.mainApp) { + self.service = service + status = service.status + } + + /// Registered, whether or not the user has approved it yet. + var isRegistered: Bool { status == .enabled || status == .requiresApproval } + + var needsApproval: Bool { status == .requiresApproval } + + func refresh() { + status = service.status + } + + func setRegistered(_ on: Bool) { + errorMessage = nil + do { + if on { try service.register() } else { try service.unregister() } + } catch { + errorMessage = "Couldn't change Open at login: \(error.localizedDescription)" + } + refresh() + } +} +#endif diff --git a/app/CrocApp/Models/MenuBarQuickSend.swift b/app/CrocApp/Models/MenuBarQuickSend.swift new file mode 100644 index 00000000..5d0a0007 --- /dev/null +++ b/app/CrocApp/Models/MenuBarQuickSend.swift @@ -0,0 +1,57 @@ +#if os(macOS) +import AppKit +import Observation + +/// What the menu-bar window (R26) does on the user's behalf. A thin layer over +/// the one `TransferController`: it never holds transfer state of its own, so +/// the window shows a transfer started anywhere in the app. +@MainActor +@Observable +final class MenuBarQuickSend { + private let controller: TransferController + + /// Why the last tap did nothing, for the window to show. + private(set) var notice: String? + + init(controller: TransferController) { + self.controller = controller + } + + /// Sends files and folders with a generated code. Returns false when + /// nothing was started (a transfer is running, or `urls` has no file). + @discardableResult + func send(urls: [URL]) -> Bool { + let files = urls.filter(\.isFileURL) + guard !controller.isActive, !files.isEmpty else { return false } + notice = nil + controller.startSend(urls: files, customCode: "") + return true + } + + @discardableResult + func sendClipboardText() -> Bool { + guard !controller.isActive else { return false } + let text = NSPasteboard.general.string(forType: .string) ?? "" + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + notice = "The clipboard has no text to send." + return false + } + notice = nil + controller.startSendText(text, customCode: "") + return true + } + + /// A modal panel: the window-style menu-bar extra closes when focus moves + /// to a SwiftUI file importer's sheet, taking the importer with it. + func chooseFiles() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.prompt = "Send" + NSApp.activate() + guard panel.runModal() == .OK else { return } + send(urls: panel.urls) + } +} +#endif diff --git a/app/CrocApp/Models/TransferController.swift b/app/CrocApp/Models/TransferController.swift index b31fe36c..3cd7eabb 100644 --- a/app/CrocApp/Models/TransferController.swift +++ b/app/CrocApp/Models/TransferController.swift @@ -48,6 +48,15 @@ final class TransferController { var lastOutputFolder: URL? { outDir } + /// Names (paths relative to the receive folder) the sender offered in the + /// current or last receive. Shortcuts uses them to hand the files back. + private(set) var offeredFileNames: [String] = [] + + /// The code of the send in flight, once croc has made one. Unlike `phase` + /// it survives the move to `.connecting`, so a caller that looks late + /// cannot miss it. + private(set) var currentCode: String? + private let engine = CrocEngine() private let background = BackgroundCoordinator() private var backgroundExpired = false @@ -110,9 +119,32 @@ final class TransferController { o.noCompress = settings.noCompress o.transport = settings.transport o.ask = settings.bothSidesConfirm + o.curve = settings.curve + o.hashAlgorithm = settings.hashAlgorithm + o.throttleUpload = settings.throttleUpload.trimmingCharacters(in: .whitespacesAndNewlines) + o.noMultiplexing = settings.noMultiplexing + o.basePort = settings.basePort + o.transfers = settings.transfers + o.internalDNS = settings.internalDNS + o.socks5Proxy = proxies.socks5 + o.httpProxy = proxies.http + #if os(macOS) + // iOS has no multicast entitlement (concern 8): the field is hidden + // there and the engine keeps croc's default group. + let multicast = settings.multicastAddress.trimmingCharacters(in: .whitespacesAndNewlines) + if !multicast.isEmpty { o.multicastAddress = multicast } + #endif return o } + /// The Advanced proxies, trimmed. Credentials may be inside: pass them on, + /// never log or describe them. + private var proxies: ProxySettings { + ProxySettings( + socks5: settings.socks5Proxy.trimmingCharacters(in: .whitespacesAndNewlines), + http: settings.httpProxy.trimmingCharacters(in: .whitespacesAndNewlines)) + } + func startSend(urls: [URL], customCode: String) { guard !isActive else { return } direction = .send @@ -149,6 +181,7 @@ final class TransferController { options.server = server options.downloads = downloads options.lifetimeSeconds = lifetimeSeconds + options.proxy = proxies run { try await self.engine.startStoreSend(paths: paths, options: options) } } @@ -200,9 +233,12 @@ final class TransferController { guard !isActive else { return } direction = .receive isStoredTransfer = false - beginReceive(into: folder, folderIsScoped: folderIsScoped, codeHint: Self.codeHint(code)) + beginReceive( + into: folder, folderIsScoped: folderIsScoped, codeHint: Self.codeHint(code), + relay: settings.receiveRelayKind) var options = baseOptions() options.outDir = folder.path + options.peerIP = settings.peerIP.trimmingCharacters(in: .whitespacesAndNewlines) // Auto-accept uses the setting; otherwise the incoming sheet's answer // replaces it. options.whenFileExists = settings.whenFileExists @@ -222,15 +258,19 @@ final class TransferController { direction = .receive isStoredTransfer = true beginReceive( - into: folder, folderIsScoped: folderIsScoped, codeHint: Self.codeHint(storedLink: link)) + into: folder, folderIsScoped: folderIsScoped, codeHint: Self.codeHint(storedLink: link), + relay: settings.relayKind) autoAcceptActive = false let path = folder.path let link = link.absoluteString - run { try await self.engine.startStoreReceive(link: link, outDir: path) } + let proxy = proxies + run { try await self.engine.startStoreReceive(link: link, outDir: path, proxy: proxy) } } - private func beginReceive(into folder: URL, folderIsScoped: Bool, codeHint: String) { - activeRelay = settings.relayKind + private func beginReceive( + into folder: URL, folderIsScoped: Bool, codeHint: String, relay: AppSettings.RelayKind + ) { + activeRelay = relay sendConfirmArmed = false outDir = folder if folderIsScoped, folder.startAccessingSecurityScopedResource() { @@ -258,7 +298,7 @@ final class TransferController { 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) + try await engine.revokeStored(receipt, proxy: proxies) vault.remove(id: id) history?.markRevoked(record) } @@ -322,6 +362,8 @@ final class TransferController { sawTransferBytes = false receivedText = nil storedReceipt = nil + offeredFileNames = [] + currentCode = nil speedBytesPerSec = 0 lastProgressDate = nil lastProgressBytes = 0 @@ -370,6 +412,7 @@ final class TransferController { private func handle(_ event: TransferEvent) { switch event { case .codeReady(let code): + currentCode = code if pendingRecord?.codeHint.isEmpty == true { pendingRecord?.codeHint = Self.codeHint(code) } @@ -386,6 +429,7 @@ final class TransferController { case .fileList(let list): let names = list.files.map(\.name) if pendingRecord?.isSend == false { + offeredFileNames = names pendingRecord?.names = Array(names.prefix(TransferRecord.maxNames)) pendingRecord?.fileCount = list.files.count pendingRecord?.totalBytes = list.totalSize @@ -627,6 +671,9 @@ final class TransferController { "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 .invalidSetting: + return + "An Advanced setting can't be used. Check the proxies, addresses, ports and upload speed limit in Settings > Advanced." case .other: return "Transfer failed. Try again, or check your connection." } diff --git a/app/CrocApp/Support/AutoVerify.swift b/app/CrocApp/Support/AutoVerify.swift index 3ae8fa89..712fd623 100644 --- a/app/CrocApp/Support/AutoVerify.swift +++ b/app/CrocApp/Support/AutoVerify.swift @@ -1,7 +1,12 @@ +import AppIntents import CrocKit import Foundation import SwiftData +#if os(macOS) +import ServiceManagement +#endif + /// 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 @@ -9,6 +14,15 @@ import SwiftData /// 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-intent send-files|send-text|receive --intent-arg ARG +/// run that Shortcuts action's perform() with ARG (a path, the text, or +/// a code); the sends write their code to verify-code.txt, receive +/// auto-accepts and lists the returned file names in verify-result.txt +/// --auto-menubar-send PATH send PATH through the menu-bar view model (macOS); the generated +/// code goes to verify-code.txt +/// --auto-menubar-clipboard send the pasteboard text through the menu-bar view model (macOS) +/// --auto-login-item register Open at login, record the system status, unregister, +/// record it again (macOS) /// --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) @@ -17,7 +31,7 @@ import SwiftData /// --hold park at the incoming prompt instead of auto-accepting /// --when-exists keepBoth|replace answer the incoming prompt with this file policy /// (the setting stays Keep Both, so the late answer must reach croc) -/// --screen ROUTE navigate to home|send|receive|settings|howItWorks|history| +/// --screen ROUTE navigate to home|send|receive|settings|advanced|howItWorks|history| /// onboarding on launch; combines with the --auto-* modes /// --stage PATH preload PATH into the Send screen's staged list (repeatable) /// --seed-history fill the (in-memory) history store with sample records @@ -46,6 +60,8 @@ enum AutoVerify { return args.contains("--auto-receive") || args.contains("--auto-send") || args.contains("--auto-share-send") || args.contains("--auto-store-send") || args.contains("--auto-store-receive") || args.contains("--screen") + || args.contains("--auto-menubar-send") || args.contains("--auto-menubar-clipboard") + || args.contains("--auto-login-item") || args.contains("--auto-intent") #else return false #endif @@ -109,6 +125,11 @@ enum AutoVerify { if let route = requestedRoute() { applyRoute(route, args: args) } if args.contains("--seed-history") { seedHistory(controller.history) } let hold = args.contains("--hold") + #if os(macOS) + if await runMenuBarModes(args: args, controller: controller, resultURL: resultURL) { + return + } + #endif if let i = args.firstIndex(of: "--auto-receive"), i + 1 < args.count { controller.startReceive(code: args[i + 1], into: docs, folderIsScoped: false) @@ -142,6 +163,11 @@ enum AutoVerify { 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-intent"), i + 1 < args.count, + let ai = args.firstIndex(of: "--intent-arg"), ai + 1 < args.count + { + await runIntent( + args[i + 1], argument: args[ai + 1], controller: controller, resultURL: resultURL) } else if let i = args.firstIndex(of: "--auto-share-send"), i + 1 < args.count { let inbox = ShareInbox() inbox.refresh() @@ -158,6 +184,110 @@ enum AutoVerify { #endif } + #if DEBUG + /// Calls an App Intent's `perform()` directly: Shortcuts cannot be driven + /// from a script, so this is the nearest proof that the actions work + /// against the CLI. The send actions return at the code, so the watch + /// that follows carries each to its end. The dependencies are set by hand: + /// outside Shortcuts' own flow, reading an unset one is a fatal error. + @MainActor + private static func runIntent( + _ name: String, argument: String, controller: TransferController, resultURL: URL + ) async { + func finish(_ result: String) { + try? result.write(to: resultURL, atomically: true, encoding: .utf8) + } + let codeURL = resultURL.deletingLastPathComponent().appendingPathComponent( + "verify-code.txt") + do { + switch name { + case "send-files": + let intent = SendFilesIntent() + intent.controller = controller + intent.files = [IntentFile(fileURL: URL(fileURLWithPath: argument))] + let code = try await intent.perform().value + try code?.write(to: codeURL, atomically: true, encoding: .utf8) + await watch(controller, resultURL: resultURL, autoAccept: false) + case "send-text": + let intent = SendTextIntent() + intent.controller = controller + intent.text = argument + let code = try await intent.perform().value + try code?.write(to: codeURL, atomically: true, encoding: .utf8) + await watch(controller, resultURL: resultURL, autoAccept: false) + case "receive": + controller.settings.autoAccept = true + let intent = ReceiveIntent() + intent.controller = controller + intent.outputFolder = OutputFolderStore() + intent.codeOrLink = argument + let files = try await intent.perform().value ?? [] + let names = files.compactMap { $0.fileURL?.lastPathComponent }.joined( + separator: ",") + finish("ok success=true files=\(names)") + default: + finish("error unknown intent \(name)") + } + } catch { + finish("error \(error)") + } + } + #endif + + #if DEBUG && os(macOS) + /// The macOS-only modes. True when one of them ran. + @MainActor + private static func runMenuBarModes( + args: [String], controller: TransferController, resultURL: URL + ) async -> Bool { + func fail(_ message: String) { + try? "error \(message)".write(to: resultURL, atomically: true, encoding: .utf8) + } + let quickSend = MenuBarQuickSend(controller: controller) + if let i = args.firstIndex(of: "--auto-menubar-send"), i + 1 < args.count { + guard quickSend.send(urls: [URL(fileURLWithPath: args[i + 1])]) else { + fail("menu-bar send did not start") + return true + } + await watch(controller, resultURL: resultURL, autoAccept: false) + } else if args.contains("--auto-menubar-clipboard") { + guard quickSend.sendClipboardText() else { + fail("menu-bar clipboard send did not start") + return true + } + await watch(controller, resultURL: resultURL, autoAccept: false) + } else if args.contains("--auto-login-item") { + runLoginItemCheck(resultURL: resultURL) + } else { + return false + } + return true + } + + /// Leaves the login item as it found it. The status reads come from the + /// system, so this proves the toggle's model matches `SMAppService`. + @MainActor + private static func runLoginItemCheck(resultURL: URL) { + let item = LoginItem() + let before = item.status + func finish(_ result: String) { + try? result.write(to: resultURL, atomically: true, encoding: .utf8) + } + item.setRegistered(true) + let registered = item.status + let toggleOn = item.isRegistered + item.setRegistered(false) + let after = item.status + if let message = item.errorMessage { + finish("error \(message)") + } else { + finish( + "ok before=\(before.rawValue) registered=\(registered.rawValue) toggleOn=\(toggleOn)" + + " after=\(after.rawValue) toggleOff=\(!item.isRegistered)") + } + } + #endif + #if DEBUG @MainActor private static func applyRoute(_ route: String, args: [String]) { @@ -170,6 +300,7 @@ enum AutoVerify { case "send": AppRouter.shared.openSend(with: staged) case "receive": AppRouter.shared.path = [.receive] case "settings": AppRouter.shared.path = [.settings] + case "advanced": AppRouter.shared.path = [.settings, .advancedSettings] case "howItWorks": AppRouter.shared.path = [.howItWorks] case "history": AppRouter.shared.path = [.history] default: break // home and onboarding both start from an empty path @@ -272,8 +403,12 @@ enum AutoVerify { while true { try? await Task.sleep(for: .milliseconds(200)) switch controller.phase { - case .waiting where !codeReady: + case .waiting(let code) where !codeReady: codeReady = true + try? code.write( + to: resultURL.deletingLastPathComponent().appendingPathComponent( + "verify-code.txt"), + atomically: true, encoding: .utf8) try? "ready".write( to: resultURL.deletingLastPathComponent().appendingPathComponent( "verify-code-ready.txt"), diff --git a/app/CrocApp/Views/AdvancedSettingsView.swift b/app/CrocApp/Views/AdvancedSettingsView.swift new file mode 100644 index 00000000..802c42f7 --- /dev/null +++ b/app/CrocApp/Views/AdvancedSettingsView.swift @@ -0,0 +1,297 @@ +import CrocKit +import SwiftUI + +/// Settings > Advanced (R25): croc's security, network and performance options. +/// The defaults are croc's own and right for most people. Every control shows +/// its default, says which side it affects and whether the other device has to +/// match, and checks its value inline with the same rules as the engine +/// (`AdvancedSettingsValidation.swift`). A page pushed from the iOS settings +/// screen, a tab in the macOS Settings window. +struct AdvancedSettingsView: View { + @Environment(AppSettings.self) private var settings + @State private var confirmReset = false + + var body: some View { + @Bindable var settings = settings + + Form { + Section("Security") { + AdvancedRow( + footnote: + "Applies when receiving: the sender switches to the receiver's curve, so the other device need not match. Default: P-256." + ) { + Picker("Curve", selection: $settings.curve) { + ForEach(Curve.allCases, id: \.self) { curve in + Text(curve.title).tag(curve) + } + } + } + AdvancedRow( + footnote: + "Applies when sending. The receiving device uses the sender's choice, so the other device does not need to match. Default: xxHash." + ) { + Picker("File hash", selection: $settings.hashAlgorithm) { + ForEach(HashAlgorithm.allCases, id: \.self) { hash in + Text(hash.title).tag(hash) + } + } + } + } + + Section("Network") { + AdvancedRow( + footnote: + "Applies to this device only. Takes priority over the HTTP proxy if both are set. Connections on your local network skip the proxy. Also used for Send as link. Saved in the Keychain.", + error: AppSettings.socks5ProxyError(settings.socks5Proxy) + ) { + LabeledField("SOCKS5 proxy") { + TextField( + "SOCKS5 proxy", text: $settings.socks5Proxy, + prompt: Text("None, or host:port") + ) + .urlEntry() + } + } + AdvancedRow( + footnote: + "Applies to this device only. Ignored when a SOCKS5 proxy is set. Connections on your local network skip the proxy. Also used for Send as link. Saved in the Keychain.", + error: AppSettings.httpProxyError(settings.httpProxy) + ) { + LabeledField("HTTP proxy") { + TextField( + "HTTP proxy", text: $settings.httpProxy, + prompt: Text("None, or host:port") + ) + .urlEntry() + } + } + AdvancedRow( + footnote: + "Looks up relay names with croc's own resolver instead of the system's. Off by default. Applies to this device only." + ) { + Toggle("Built-in DNS resolver", isOn: $settings.internalDNS) + } + #if os(macOS) + AdvancedRow( + footnote: + "Applies to both devices. They find each other on the local network only if both use the same address. Blank uses \(AppSettings.advancedDefaults.multicastAddress).", + error: AppSettings.multicastAddressError(settings.multicastAddress) + ) { + LabeledField("Multicast address") { + TextField( + "Multicast address", text: $settings.multicastAddress, + prompt: Text(AppSettings.advancedDefaults.multicastAddress) + ) + .addressEntry() + } + } + #endif + AdvancedRow( + footnote: + "Applies when receiving. Connects straight to the sender at this address and port, skipping relays. Use the sender's base port, 9009 unless they changed it.", + error: AppSettings.senderIPError(settings.peerIP) + ) { + LabeledField("Sender IP") { + TextField( + "Sender IP", text: $settings.peerIP, + prompt: Text("Find the sender automatically") + ) + .addressEntry() + } + } + } + + Section("Performance") { + AdvancedRow( + footnote: + "Applies when sending. The most this device sends per second, such as 500k, 2m or 1g. The other device is not affected.", + error: AppSettings.throttleError(settings.throttleUpload) + ) { + LabeledField("Upload speed limit") { + TextField( + "Upload speed limit", text: $settings.throttleUpload, + prompt: Text("No limit") + ) + .addressEntry() + } + } + AdvancedRow( + footnote: + "Applies when sending. The first port of this device's local relay. A device that receives by sender IP must use this port. Default \(AppSettings.advancedDefaults.basePort).", + error: basePortError + ) { + LabeledField("Base port") { + TextField( + "Base port", value: $settings.basePort, + format: .number.grouping(.never) + ) + .numberEntry() + } + } + AdvancedRow( + footnote: + "Applies when sending. How many extra ports follow the base port, one per connection. Default \(AppSettings.advancedDefaults.transfers).", + error: transfersError + ) { + LabeledField("Parallel connections") { + TextField( + "Parallel connections", value: $settings.transfers, + format: .number.grouping(.never) + ) + .numberEntry() + } + } + AdvancedRow( + footnote: + "On by default. Off sends over one data connection. Applies when sending; the receiving device follows." + ) { + Toggle("Multiplexing", isOn: multiplexing) + } + } + + Section { + Button("Reset to Defaults") { confirmReset = true } + .disabled(settings.advancedIsDefault) + } footer: { + Text( + "Puts every setting on this page back to croc's default and removes the saved proxies." + ) + } + } + .formStyle(.grouped) + .navigationTitle("Advanced") + #if os(iOS) + .scrollDismissesKeyboard(.interactively) + #endif + .confirmationDialog( + "Reset all Advanced settings?", isPresented: $confirmReset, titleVisibility: .visible + ) { + Button("Reset to Defaults", role: .destructive) { settings.resetAdvancedToDefaults() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This also removes the saved proxies.") + } + } + + /// The setting is stored negated, so the unset default is croc's. + private var multiplexing: Binding { + Binding( + get: { !settings.noMultiplexing }, + set: { settings.noMultiplexing = !$0 }) + } + + private var basePortError: String? { + guard settings.basePort < 1 || settings.basePort > 65535 else { return nil } + return AppSettings.portsError(basePort: settings.basePort, transfers: settings.transfers) + } + + /// The port range as a whole; the base port reports its own mistakes. + private var transfersError: String? { + guard basePortError == nil else { return nil } + return AppSettings.portsError(basePort: settings.basePort, transfers: settings.transfers) + } +} + +/// One setting: the control, its inline error (words and an icon, never colour +/// alone), then a footnote on which side it affects. +private struct AdvancedRow: View { + let footnote: String + var error: String? + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.space3) { + content + if let error { + StatusBanner(kind: .error, title: error) + .frame(maxWidth: .infinity, alignment: .leading) + } + Text(footnote) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, Spacing.space2) + } +} + +/// A visible label above a text field, stacked so it reads the same at every +/// Dynamic Type size and on both platforms. The field keeps its own title as +/// its accessibility label. +private struct LabeledField: View { + let title: LocalizedStringKey + @ViewBuilder var field: Field + + init(_ title: LocalizedStringKey, @ViewBuilder field: () -> Field) { + self.title = title + self.field = field() + } + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.space2) { + Text(title).accessibilityHidden(true) + field + .labelsHidden() + .autocorrectionDisabled() + .frame( + maxWidth: .infinity, minHeight: ControlHeight.hitTargetMin, alignment: .leading) + } + } +} + +extension View { + fileprivate func urlEntry() -> some View { + #if os(iOS) + textInputAutocapitalization(.never).keyboardType(.URL) + #else + self + #endif + } + + fileprivate func addressEntry() -> some View { + #if os(iOS) + textInputAutocapitalization(.never).keyboardType(.numbersAndPunctuation) + #else + self + #endif + } + + fileprivate func numberEntry() -> some View { + #if os(iOS) + keyboardType(.numberPad).monospacedDigit() + #else + monospacedDigit() + #endif + } +} + +extension Curve { + fileprivate var title: String { + switch self { + case .p256: "P-256" + case .p384: "P-384" + case .p521: "P-521" + case .siec: "SIEC" + case .ed25519: "Ed25519" + } + } +} + +extension HashAlgorithm { + fileprivate var title: String { + switch self { + case .xxhash: "xxHash" + case .imohash: "imohash" + case .md5: "MD5" + case .highway: "HighwayHash" + } + } +} + +#Preview { + NavigationStack { AdvancedSettingsView() } + .environment( + AppSettings( + defaults: UserDefaults(suiteName: "AdvancedSettingsPreview") ?? .standard, + secrets: MemorySecretStore())) +} diff --git a/app/CrocApp/Views/AppCommands.swift b/app/CrocApp/Views/AppCommands.swift index 259eb090..bffbb9fa 100644 --- a/app/CrocApp/Views/AppCommands.swift +++ b/app/CrocApp/Views/AppCommands.swift @@ -2,10 +2,12 @@ import SwiftUI #if os(macOS) import AppKit +#else +import UIKit #endif -/// File-menu commands. Cross-platform (iPad gets them via the menu bar too); -/// Finder reveal is macOS-only. +/// File-menu commands, on the Mac menu bar and the iPadOS 26 menu bar (R28). +/// The receive folder opens in Finder on the Mac and in Files on iPad. struct AppCommands: Commands { let router: AppRouter let outputFolder: OutputFolderStore @@ -19,12 +21,23 @@ struct AppCommands: Commands { Button("Receive…") { router.openReceive() } .keyboardShortcut("2") .disabled(controller.isActive) - #if os(macOS) Divider() + #if os(macOS) Button("Show Receive Folder in Finder") { NSWorkspace.shared.activateFileViewerSelecting([outputFolder.url]) } .keyboardShortcut("r", modifiers: [.command, .shift]) + #else + Button("Show Receive Folder in Files") { + // Files opens at a folder through its shareddocuments scheme. + if let path = outputFolder.url.path.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "shareddocuments://" + path) + { + UIApplication.shared.open(url) + } + } + .keyboardShortcut("r", modifiers: [.command, .shift]) #endif } } diff --git a/app/CrocApp/Views/HomeView.swift b/app/CrocApp/Views/HomeView.swift index 04fd9d79..25f309e3 100644 --- a/app/CrocApp/Views/HomeView.swift +++ b/app/CrocApp/Views/HomeView.swift @@ -82,6 +82,7 @@ struct HomeView: View { case .send: SendView() case .receive: ReceiveView() case .settings: SettingsScreen() + case .advancedSettings: AdvancedSettingsView() case .howItWorks: HowItWorksView() case .history: HistoryView() } @@ -95,6 +96,11 @@ struct SettingsScreen: View { var body: some View { Form { PowerSettingsSections() + Section { + NavigationLink("Advanced", value: AppRouter.Route.advancedSettings) + } footer: { + Text("Proxies, ports, encryption curve and more. The defaults suit most people.") + } } .navigationTitle("Settings") } diff --git a/app/CrocApp/Views/MenuBarQuickSendView.swift b/app/CrocApp/Views/MenuBarQuickSendView.swift new file mode 100644 index 00000000..6feefad0 --- /dev/null +++ b/app/CrocApp/Views/MenuBarQuickSendView.swift @@ -0,0 +1,166 @@ +#if os(macOS) +import AppKit +import CrocKit +import SwiftUI + +/// The menu-bar window (R26): drop files or tap a button to send, then the +/// code, QR and progress of whatever transfer the shared controller runs. +struct MenuBarQuickSendView: View { + @Environment(TransferController.self) private var controller + @Environment(\.openWindow) private var openWindow + let quickSend: MenuBarQuickSend + @State private var isDropTargeted = false + + var body: some View { + VStack(spacing: Spacing.space4) { + content + Divider() + Button("Open CrocApp") { openMainWindow() } + .buttonStyle(.link) + } + .padding(Spacing.space5) + .frame(width: 300) + } + + @ViewBuilder private var content: some View { + switch controller.phase { + case .idle: + idle + case .starting, .connecting: + VStack(spacing: Spacing.space3) { + ProgressView() + Text("Connecting…").foregroundStyle(.secondary) + cancelButton + } + case .waiting(let code): + waiting(code: code) + case .confirmSend: + VStack(spacing: Spacing.space3) { + Text("The receiver is connected. Send now?").multilineTextAlignment(.center) + HStack { + Button("Cancel", role: .destructive) { controller.cancel() } + Button("Send") { controller.respond(accept: true) } + .buttonStyle(.borderedProminent) + } + } + case .incoming: + VStack(spacing: Spacing.space3) { + Label("Files are waiting for your answer", systemImage: "arrow.down.circle") + Text("Open CrocApp to review and accept them.") + .font(.callout).foregroundStyle(.secondary) + cancelButton + } + case .transferring(let p): + transferring(p) + case .done(let summary, let receivedText): + VStack(spacing: Spacing.space3) { + Label( + summary.success ? "Transfer complete" : "Transfer finished with errors", + systemImage: summary.success + ? "checkmark.circle.fill" : "exclamationmark.circle") + if let receivedText { + Button("Copy Received Text") { Clipboard.copy(receivedText) } + } + doneButton + } + case .stored(let receipt): + VStack(spacing: Spacing.space3) { + Label("Upload ready", systemImage: "checkmark.circle.fill") + Button("Copy Link") { Clipboard.copy(receipt.link, thisDeviceOnly: true) } + doneButton + } + case .failed(let message): + VStack(spacing: Spacing.space3) { + StatusBanner(kind: .error, title: message) + doneButton + } + } + } + + private var idle: some View { + VStack(spacing: Spacing.space3) { + VStack(spacing: Spacing.space3) { + Image(systemName: "square.and.arrow.up") + .font(.title) + .accessibilityHidden(true) + Text("Drop files to send") + } + .frame(maxWidth: .infinity, minHeight: 96) + .background( + RoundedRectangle(cornerRadius: Radius.md, style: .continuous) + .strokeBorder( + isDropTargeted ? Color.accentColor : Color.separatorToken, + style: StrokeStyle( + lineWidth: isDropTargeted ? 2 : BorderWidth.hairline, dash: [6, 4])) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("Drop files here to send") + .dropDestination(for: URL.self) { urls, _ in + quickSend.send(urls: urls) + } isTargeted: { + isDropTargeted = $0 + } + + HStack { + Button("Choose Files…") { quickSend.chooseFiles() } + Button("Send Clipboard Text") { quickSend.sendClipboardText() } + } + if let notice = quickSend.notice { + Text(notice).font(.caption).foregroundStyle(.secondary) + } + } + } + + private func waiting(code: String) -> some View { + VStack(spacing: Spacing.space3) { + Text(direction == .send ? "Ready to send" : "Waiting for the sender…") + .font(.headline) + if direction == .send { + Text(code) + .font(.title3.monospaced().weight(.semibold)) + .textSelection(.enabled) + .multilineTextAlignment(.center) + .accessibilityLabel("Transfer code") + .accessibilityValue(code.replacingOccurrences(of: "-", with: ", ")) + Button { + Clipboard.copy(code) + } label: { + Label("Copy Code", systemImage: "doc.on.doc") + } + QRCodeView(content: TransferLink.webURL(forCode: code)?.absoluteString ?? code) + } + cancelButton + } + } + + private func transferring(_ p: TransferProgress) -> some View { + let done = p.bytesFinished + p.fileSent + return VStack(spacing: Spacing.space3) { + Text(p.fileName).lineLimit(1).truncationMode(.middle) + ProgressView(value: Double(done), total: Double(max(p.totalSize, 1))) + .accessibilityLabel("Transfer progress") + cancelButton + } + } + + private var direction: TransferController.Direction { controller.direction } + + private var cancelButton: some View { + Button("Cancel", role: .destructive) { controller.cancel() } + } + + private var doneButton: some View { + Button("Done") { controller.reset() } + .buttonStyle(.borderedProminent) + } + + private func openMainWindow() { + NSApp.activate() + if let window = NSApp.windows.first(where: { $0.canBecomeMain }) { + window.makeKeyAndOrderFront(nil) + } else { + openWindow(id: CrocAppApp.mainWindowID) + } + } +} +#endif diff --git a/app/CrocApp/Views/SettingsView.swift b/app/CrocApp/Views/SettingsView.swift index 9f32f1c0..a42910bc 100644 --- a/app/CrocApp/Views/SettingsView.swift +++ b/app/CrocApp/Views/SettingsView.swift @@ -1,12 +1,18 @@ #if os(macOS) import SwiftUI import AppKit +import ServiceManagement import UniformTypeIdentifiers -/// macOS Settings scene (⌘,): receive folder plus the power options (F13-F19). +/// macOS Settings scene (⌘,): a General tab (receive folder plus the power +/// options, F13-F19) and an Advanced tab (R25). The Advanced controls with +/// their footnotes would roughly double a form that already scrolls, so they +/// get their own tab, the macOS convention for a long settings window. struct SettingsView: View { @Environment(OutputFolderStore.self) private var outputFolder + @Environment(AppSettings.self) private var settings @State private var showFolderPicker = false + @State private var loginItem = LoginItem() /// F.3: the Address field in PowerSettingsSections is the first /// focusable control in the Form, so macOS puts a blinking caret in it @@ -20,6 +26,13 @@ struct SettingsView: View { @FocusState private var focusedControl: FocusTarget? var body: some View { + TabView { + Tab("General", systemImage: "gearshape") { general } + Tab("Advanced", systemImage: "slider.horizontal.3") { AdvancedSettingsView() } + } + } + + private var general: some View { Form { Section("Receive") { LabeledContent("Save received files to") { @@ -42,9 +55,17 @@ struct SettingsView: View { .focused($focusedControl, equals: .showInFinder) } + menuBarSection + PowerSettingsSections() } .defaultFocus($focusedControl, .showInFinder) + .onAppear { loginItem.refresh() } + .onReceive( + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) + ) { _ in + loginItem.refresh() + } .formStyle(.grouped) // minWidth, not a fixed width: the window is resizable now, and a // fixed-width form inside a wider window leaves dead margins on both @@ -61,5 +82,31 @@ struct SettingsView: View { } } +extension SettingsView { + @ViewBuilder fileprivate var menuBarSection: some View { + Section { + Toggle("Show in menu bar", isOn: Bindable(settings).showInMenuBar) + Toggle( + "Open at login", + isOn: Binding( + get: { loginItem.isRegistered }, set: { loginItem.setRegistered($0) })) + if loginItem.needsApproval { + HStack { + Text("Allow CrocApp in System Settings > General > Login Items.") + .font(.caption).foregroundStyle(.secondary) + Button("Open Settings") { SMAppService.openSystemSettingsLoginItems() } + } + } + if let message = loginItem.errorMessage { + StatusBanner(kind: .error, title: message) + } + } header: { + Text("Menu bar") + } footer: { + Text("Send files and text from the menu bar without opening the window.") + } + } +} + #Preview { SettingsView().environment(OutputFolderStore()).environment(AppSettings()) } #endif diff --git a/app/CrocApp/Views/TransferStatusView.swift b/app/CrocApp/Views/TransferStatusView.swift index c84bfc93..ecbaa56a 100644 --- a/app/CrocApp/Views/TransferStatusView.swift +++ b/app/CrocApp/Views/TransferStatusView.swift @@ -196,7 +196,7 @@ struct TransferStatusView: View { /// R23: croc's browser client only knows the default relays. private var browserReceiveNote: String? { switch controller.activeRelay { - case .publicDefault: nil + case .publicDefault, .directToSender: nil case .custom: "Receiving in a browser needs the default relays. This transfer uses a custom relay." case .localOnly: diff --git a/app/CrocApp/Views/TrustBadge.swift b/app/CrocApp/Views/TrustBadge.swift index a1f9fa46..cd0cc1d0 100644 --- a/app/CrocApp/Views/TrustBadge.swift +++ b/app/CrocApp/Views/TrustBadge.swift @@ -100,6 +100,8 @@ struct TrustBadge: View { switch relay { case .localOnly: return "Local network only — nothing leaves your network" + case .directToSender: + return "Straight to the sender, no relay in between" case .custom(let address): return "Via custom relay \(address) — it sees only encrypted data" case .publicDefault: diff --git a/app/CrocAppTests/AdvancedSettingsValidationTests.swift b/app/CrocAppTests/AdvancedSettingsValidationTests.swift new file mode 100644 index 00000000..8d31b23e --- /dev/null +++ b/app/CrocAppTests/AdvancedSettingsValidationTests.swift @@ -0,0 +1,118 @@ +import CrocKit +import Testing + +@testable import CrocApp + +/// The accept and reject tables mirror `crocmobile/network_test.go` +/// (`TestValidateNetworkOptions`, `TestRelayPorts`), so the form and the engine +/// agree on what a value means. +@Suite struct AdvancedSettingsValidationTests { + @Test(arguments: [ + "", " ", "127.0.0.1:1080", "proxy.example:1080", + "socks5://user:secret@proxy.example:1080", + "socks5h://proxy.example:1080", "SOCKS5://proxy.example:1080", + " socks5://proxy.example:1080 ", + "socks5://[::1]:1080", + ]) + func socks5Accepts(_ text: String) { + #expect(AppSettings.socks5ProxyError(text) == nil) + } + + @Test(arguments: [ + "proxy.example", "http://user:secret@proxy.example:1080", "socks5://:1080", + "user:secret@proxy.example", "socks5://proxy.example", "proxy.example:port", + "proxy.example:0", "proxy.example:70000", "socks5://proxy.example:1080 extra", + ]) + func socks5Rejects(_ text: String) { + #expect(AppSettings.socks5ProxyError(text) != nil) + } + + @Test(arguments: [ + "", "proxy.example:3128", "http://user:secret@proxy.example:3128", + " https://proxy.example:443 ", + "https://proxy.example:443/", "http://[::1]:3128", + ]) + func httpAccepts(_ text: String) { + #expect(AppSettings.httpProxyError(text) == nil) + } + + @Test(arguments: [ + "socks5://user:secret@proxy.example:1080", "user:secret@proxy.example", "proxy.example", + "ftp://proxy.example:21", "http://:3128", + ]) + func httpRejects(_ text: String) { + #expect(AppSettings.httpProxyError(text) != nil) + } + + /// A proxy URL can carry a password, so the message must not repeat it. + @Test func proxyErrorsNeverQuoteTheValue() { + let value = "socks5://user:hunter2@nohost" + let messages = [ + AppSettings.socks5ProxyError(value), AppSettings.httpProxyError(value), + ] + for message in messages { + #expect(message != nil) + #expect(message?.contains("hunter2") == false) + #expect(message?.contains("nohost") == false) + } + } + + @Test(arguments: ["", "239.255.255.250", "224.0.0.251", "239.255.255.255", " 224.0.0.1 "]) + func multicastAccepts(_ text: String) { + #expect(AppSettings.multicastAddressError(text) == nil) + } + + @Test(arguments: [ + "10.0.0.1", "ff02::1", "not an address", "223.255.255.255", "240.0.0.0", "239.255.255", + "239.255.255.256", "239.255.255.250.1", "239.255.255.0250", + ]) + func multicastRejects(_ text: String) { + #expect(AppSettings.multicastAddressError(text) != nil) + } + + @Test(arguments: ["", "10.0.0.1:9009", "[::1]:9009", " 192.168.1.20:65535 ", "[fe80::1]:1"]) + func senderIPAccepts(_ text: String) { + #expect(AppSettings.senderIPError(text) == nil) + } + + @Test(arguments: [ + "10.0.0.1", "sender.local:9009", "10.0.0.1:0", "10.0.0.1:http", "10.0.0.1:65536", + "::1:9009", "[::1]", "[::1]9009", "[not-ip]:9009", "[fe80::1%en0]:9009", ":9009", + ]) + func senderIPRejects(_ text: String) { + #expect(AppSettings.senderIPError(text) != nil) + } + + @Test(arguments: [(9009, 4), (9100, 2), (50000, 1), (65533, 2), (1, 1), (65534, 1)]) + func portsAccept(base: Int, transfers: Int) { + #expect(AppSettings.portsError(basePort: base, transfers: transfers) == nil) + } + + @Test(arguments: [(0, 4), (-1, 4), (9009, 0), (9009, -1), (65535, 4), (65534, 2), (70000, 1)]) + func portsReject(base: Int, transfers: Int) { + #expect(AppSettings.portsError(basePort: base, transfers: transfers) != nil) + } + + @Test(arguments: ["", " ", "500k", "500K", "2m", "2M", "1g", "1G", "1000", "10", "99k"]) + func throttleAccepts(_ text: String) { + #expect(AppSettings.throttleError(text) == nil) + } + + @Test(arguments: [ + "k", "xk", "5", "0k", "0", "00", "-5k", "+5k", "1.5m", "5kb", "5 k", "fast", + "99999999999999999999k", "٣k", + ]) + func throttleRejects(_ text: String) { + #expect(AppSettings.throttleError(text) != nil) + } + + @Test func defaultsAreValid() { + let defaults = AppSettings.advancedDefaults + #expect(AppSettings.multicastAddressError(defaults.multicastAddress) == nil) + #expect( + AppSettings.portsError(basePort: defaults.basePort, transfers: defaults.transfers) + == nil) + #expect(AppSettings.throttleError(defaults.throttleUpload) == nil) + #expect(AppSettings.senderIPError(defaults.peerIP) == nil) + } +} diff --git a/app/CrocAppTests/AppSettingsAdvancedTests.swift b/app/CrocAppTests/AppSettingsAdvancedTests.swift new file mode 100644 index 00000000..75b7785b --- /dev/null +++ b/app/CrocAppTests/AppSettingsAdvancedTests.swift @@ -0,0 +1,197 @@ +import CrocKit +import Foundation +import Testing + +@testable import CrocApp + +/// Advanced settings (R25): persistence, the proxies' Keychain-only storage and reset. +@Suite struct AppSettingsAdvancedTests { + private let suiteName = "CrocAppTests.\(UUID().uuidString)" + private let defaults: UserDefaults + private let secrets = FakeSecretStore() + + init() throws { + defaults = try #require(UserDefaults(suiteName: suiteName)) + } + + private func cleanUp() { + defaults.removePersistentDomain(forName: suiteName) + } + + private func makeSettings() -> AppSettings { + AppSettings(defaults: defaults, secrets: secrets) + } + + private func setNonDefaults(_ s: AppSettings) { + s.curve = .ed25519 + s.hashAlgorithm = .md5 + s.throttleUpload = "500k" + s.noMultiplexing = true + s.basePort = 9100 + s.transfers = 2 + s.multicastAddress = "224.0.0.251" + s.peerIP = "10.0.0.1:9100" + s.internalDNS = true + s.socks5Proxy = "socks5://user:secret@proxy.example:1080" + s.httpProxy = "http://user:secret@proxy.example:3128" + } + + @Test func freshDefaultsAreCrocs() { + defer { cleanUp() } + let s = makeSettings() + #expect(s.curve == .p256) + #expect(s.hashAlgorithm == .xxhash) + #expect(s.throttleUpload == "") + #expect(!s.noMultiplexing) + #expect(s.basePort == 9009) + #expect(s.transfers == 4) + #expect(s.multicastAddress == "239.255.255.250") + #expect(s.peerIP == "") + #expect(!s.internalDNS) + #expect(s.socks5Proxy == "") + #expect(s.httpProxy == "") + #expect(s.advancedIsDefault) + } + + @Test func changesSurviveARelaunch() { + defer { cleanUp() } + setNonDefaults(makeSettings()) + + let r = makeSettings() + #expect(r.curve == .ed25519) + #expect(r.hashAlgorithm == .md5) + #expect(r.throttleUpload == "500k") + #expect(r.noMultiplexing) + #expect(r.basePort == 9100) + #expect(r.transfers == 2) + #expect(r.multicastAddress == "224.0.0.251") + #expect(r.peerIP == "10.0.0.1:9100") + #expect(r.internalDNS) + #expect(r.socks5Proxy == "socks5://user:secret@proxy.example:1080") + #expect(r.httpProxy == "http://user:secret@proxy.example:3128") + #expect(!r.advancedIsDefault) + } + + /// Two settings saved under each other's key would still pass the test above. + @Test func eachSettingPersistsAlone() { + defer { cleanUp() } + let edits: [(String, (AppSettings) -> Void)] = [ + ("curve", { $0.curve = .p521 }), + ("hash", { $0.hashAlgorithm = .imohash }), + ("throttle", { $0.throttleUpload = "2m" }), + ("multiplexing", { $0.noMultiplexing = true }), + ("basePort", { $0.basePort = 9100 }), + ("transfers", { $0.transfers = 2 }), + ("multicast", { $0.multicastAddress = "224.0.0.251" }), + ("peerIP", { $0.peerIP = "10.0.0.1:9009" }), + ("dns", { $0.internalDNS = true }), + ("socks5", { $0.socks5Proxy = "proxy.example:1080" }), + ("http", { $0.httpProxy = "proxy.example:3128" }), + ] + for (name, edit) in edits { + defaults.removePersistentDomain(forName: suiteName) + secrets.items.removeAll() + edit(makeSettings()) + let r = makeSettings() + let changed = [ + "curve": r.curve != .p256, "hash": r.hashAlgorithm != .xxhash, + "throttle": r.throttleUpload != "", "multiplexing": r.noMultiplexing, + "basePort": r.basePort != 9009, "transfers": r.transfers != 4, + "multicast": r.multicastAddress != "239.255.255.250", "peerIP": r.peerIP != "", + "dns": r.internalDNS, "socks5": r.socks5Proxy != "", "http": r.httpProxy != "", + ] + for (other, isChanged) in changed { + #expect(isChanged == (other == name), "set \(name), read \(other)") + } + } + } + + @Test func unknownCurveAndHashReadAsDefaults() { + defer { cleanUp() } + defaults.set("curve25519", forKey: "settings.advanced.curve") + defaults.set("sha1", forKey: "settings.advanced.hashAlgorithm") + let s = makeSettings() + #expect(s.curve == .p256) + #expect(s.hashAlgorithm == .xxhash) + } + + /// A proxy URL can hold a password: it belongs in the Keychain, never in the plist. + @Test func proxiesLiveOnlyInTheSecretStore() { + defer { cleanUp() } + setNonDefaults(makeSettings()) + + #expect(secrets.items["advanced.socks5Proxy"] == "socks5://user:secret@proxy.example:1080") + #expect(secrets.items["advanced.httpProxy"] == "http://user:secret@proxy.example:3128") + let stored = defaults.persistentDomain(forName: suiteName) ?? [:] + #expect(!stored.keys.contains { $0.lowercased().contains("proxy") }) + #expect(!String(describing: stored).contains("secret")) + #expect(!String(describing: stored).contains("proxy.example")) + } + + @Test func blankProxyDeletesTheKeychainItem() { + defer { cleanUp() } + let s = makeSettings() + s.socks5Proxy = "proxy.example:1080" + s.httpProxy = "proxy.example:3128" + #expect(secrets.items.count == 2) + + s.socks5Proxy = "" + s.httpProxy = " " + #expect(secrets.items.isEmpty) + #expect(makeSettings().socks5Proxy == "") + } + + @Test func persistOffWritesNoProxy() { + defer { cleanUp() } + let s = makeSettings() + s.persist = false + s.socks5Proxy = "proxy.example:1080" + s.basePort = 9100 + s.resetToDefaults() + s.httpProxy = "proxy.example:3128" + #expect(secrets.items.isEmpty) + #expect(defaults.persistentDomain(forName: suiteName)?.isEmpty ?? true) + } + + /// A harness run's reset must not blank proxies a real user saved. + @Test func harnessResetKeepsSavedProxies() { + defer { cleanUp() } + secrets.items["advanced.socks5Proxy"] = "proxy.example:1080" + let s = makeSettings() + s.persist = false + s.resetToDefaults() + #expect(s.socks5Proxy == "") + #expect(secrets.items["advanced.socks5Proxy"] == "proxy.example:1080") + } + + @Test func resetAdvancedRestoresEveryDefaultAndPersists() { + defer { cleanUp() } + let s = makeSettings() + setNonDefaults(s) + s.relayAddress = "relay.example:9009" + + s.resetAdvancedToDefaults() + + #expect(s.advancedIsDefault) + let r = makeSettings() + #expect(r.advancedIsDefault) + #expect(r.curve == .p256) + #expect(r.basePort == 9009) + #expect(r.socks5Proxy == "") + #expect(secrets.items.isEmpty, "reset deletes the saved proxies") + #expect(r.relayAddress == "relay.example:9009", "reset leaves the other settings alone") + } + + @Test func receiveRelayKindFollowsTheSenderIP() { + defer { cleanUp() } + let s = makeSettings() + #expect(s.receiveRelayKind == .publicDefault) + s.peerIP = "10.0.0.1:9009" + #expect(s.receiveRelayKind == .directToSender) + #expect(s.relayKind == .publicDefault, "a send is not affected by the sender IP") + s.peerIP = " " + #expect(s.receiveRelayKind == .publicDefault) + s.onlyLocal = true + #expect(s.receiveRelayKind == .localOnly) + } +} diff --git a/app/CrocAppTests/FriendlyMessageTests.swift b/app/CrocAppTests/FriendlyMessageTests.swift index 7cae2ad5..a0adda5d 100644 --- a/app/CrocAppTests/FriendlyMessageTests.swift +++ b/app/CrocAppTests/FriendlyMessageTests.swift @@ -41,6 +41,10 @@ import Testing "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."), + ( + .invalidSetting, + "An Advanced setting can't be used. Check the proxies, addresses, ports and upload speed limit in Settings > Advanced." + ), (.other, "Transfer failed. Try again, or check your connection."), ]) func mapsEveryCode(code: EngineFailure.Code, expected: String) { diff --git a/app/CrocAppTests/MenuBarAndShortcutsTests.swift b/app/CrocAppTests/MenuBarAndShortcutsTests.swift new file mode 100644 index 00000000..1161bbca --- /dev/null +++ b/app/CrocAppTests/MenuBarAndShortcutsTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing + +@testable import CrocApp + +#if os(macOS) +import ServiceManagement +#endif + +@Suite struct ShowInMenuBarSettingTests { + @Test func onByDefaultAndPersistsOff() throws { + let name = "CrocAppTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let secrets = FakeSecretStore() + #expect(AppSettings(defaults: defaults, secrets: secrets).showInMenuBar) + AppSettings(defaults: defaults, secrets: secrets).showInMenuBar = false + #expect(!AppSettings(defaults: defaults, secrets: secrets).showInMenuBar) + } +} + +@Suite struct ReceivedFileLocatorTests { + @Test func copyNamesFollowKeepBoth() { + #expect(ReceivedFileLocator.isCopy("a.txt", stem: "a", suffix: ".txt")) + #expect(ReceivedFileLocator.isCopy("a (1).txt", stem: "a", suffix: ".txt")) + #expect(ReceivedFileLocator.isCopy("a (12).txt", stem: "a", suffix: ".txt")) + #expect(!ReceivedFileLocator.isCopy("a (x).txt", stem: "a", suffix: ".txt")) + #expect(!ReceivedFileLocator.isCopy("ab.txt", stem: "a", suffix: ".txt")) + #expect(!ReceivedFileLocator.isCopy("a.txt.bak", stem: "a", suffix: ".txt")) + #expect(ReceivedFileLocator.isCopy("README", stem: "README", suffix: "")) + #expect(ReceivedFileLocator.isCopy("README (2)", stem: "README", suffix: "")) + } + + @Test func prefersTheNewKeepBothCopyOverTheOldFile() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let old = dir.appendingPathComponent("a.txt") + try Data("old".utf8).write(to: old) + Thread.sleep(forTimeInterval: 0.05) + let started = Date() + let copy = dir.appendingPathComponent("a (1).txt") + try Data("new".utf8).write(to: copy) + let found = ReceivedFileLocator.locate(names: ["a.txt"], in: dir, since: started) + #expect(found.map(\.lastPathComponent) == ["a (1).txt"]) + } + + @Test func aSecondOfferedNameIsNotTakenAsACopyOfTheFirst() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let started = Date() + try Data("1".utf8).write(to: dir.appendingPathComponent("a.txt")) + Thread.sleep(forTimeInterval: 0.05) + try Data("2".utf8).write(to: dir.appendingPathComponent("a (1).txt")) + let found = ReceivedFileLocator.locate( + names: ["a.txt", "a (1).txt"], in: dir, since: started) + #expect(found.map(\.lastPathComponent) == ["a.txt", "a (1).txt"]) + } + + @Test func fallsBackToTheNamedFileAfterAReplace() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory( + at: dir.appendingPathComponent("sub"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try Data("x".utf8).write(to: dir.appendingPathComponent("sub/b.txt")) + let found = ReceivedFileLocator.locate( + names: ["sub/b.txt", "missing.txt"], in: dir, since: Date().addingTimeInterval(60)) + #expect(found.map(\.lastPathComponent) == ["b.txt"]) + } +} + +#if os(macOS) +@MainActor +@Suite struct LoginItemTests { + final class FakeService: LoginItemService { + var status: SMAppService.Status = .notRegistered + var failure: Error? + func register() throws { + if let failure { throw failure } + status = .enabled + } + func unregister() throws { + if let failure { throw failure } + status = .notRegistered + } + } + + struct Boom: Error {} + + @Test func toggleFollowsTheSystemStatus() { + let service = FakeService() + let item = LoginItem(service: service) + #expect(!item.isRegistered) + item.setRegistered(true) + #expect(item.isRegistered && !item.needsApproval) + item.setRegistered(false) + #expect(!item.isRegistered) + } + + @Test func approvalPendingCountsAsRegisteredAndNeedsApproval() { + let service = FakeService() + service.status = .requiresApproval + let item = LoginItem(service: service) + #expect(item.isRegistered && item.needsApproval) + } + + @Test func refreshPicksUpAChangeMadeInSystemSettings() { + let service = FakeService() + let item = LoginItem(service: service) + service.status = .enabled + #expect(!item.isRegistered) + item.refresh() + #expect(item.isRegistered) + } + + @Test func aFailedRegisterShowsAnErrorAndKeepsTheRealStatus() { + let service = FakeService() + service.failure = Boom() + let item = LoginItem(service: service) + item.setRegistered(true) + #expect(item.errorMessage != nil) + #expect(!item.isRegistered) + } +} + +@MainActor +@Suite struct MenuBarQuickSendTests { + private func makeController() -> TransferController { + TransferController( + settings: AppSettings( + defaults: UserDefaults(suiteName: "CrocAppTests.\(UUID().uuidString)")!, + secrets: FakeSecretStore()), + vault: StoredTransferVault(secrets: MemorySecretStore())) + } + + @Test func sendRefusesAnythingButFiles() { + let controller = makeController() + let quickSend = MenuBarQuickSend(controller: controller) + #expect(!quickSend.send(urls: [])) + #expect(!quickSend.send(urls: [URL(string: "https://example.com/a")!])) + #expect(!controller.isActive) + } +} +#endif diff --git a/crocmobile/cmd/croctest/main.go b/crocmobile/cmd/croctest/main.go index a9f44dd7..c5bb531d 100644 --- a/crocmobile/cmd/croctest/main.go +++ b/crocmobile/cmd/croctest/main.go @@ -1,11 +1,14 @@ // croctest exercises crocmobile from the command line for interop verification. // -// 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 send [-relay ADDR] [-no-local] [-only-local] [-text MSG] [-code CODE] [-relay-index N] [-throttle RATE] [-no-multi] [-transport auto|relay|derp] [ADVANCED] [PATH...] +// croctest receive [-relay ADDR] [-no-local] [-only-local] [-out DIR] [-answer y|n] [-yes] [-policy keepBoth|replace] [-cancel-after MS] [ADVANCED] CODE +// croctest store-send [-server URL] [-downloads N] [-lifetime SECONDS] [-socks5 ADDR] [-connect ADDR] PATH... +// croctest store-receive [-out DIR] [-answer y|n] [-policy keepBoth|replace] [-cancel-after MS] [-socks5 ADDR] [-connect ADDR] LINK +// croctest store-revoke [-socks5 ADDR] [-connect ADDR] ORIGIN ID TOKEN // croctest store-server DIR +// croctest socks5-proxy +// croctest http-proxy +// croctest multicast-listen GROUP SECONDS // croctest relay CODE // croctest relays // @@ -19,6 +22,22 @@ // -transport is the sender's data transport; croc's "derp" forces Tailcat with // no relay fallback. // +// ADVANCED are the Options of the app's Advanced settings, named like croc's +// own flags: -curve, -hash, -port, -transfers, -multicast, -ip, -socks5, +// -connect, -internal-dns. +// +// socks5-proxy and http-proxy run a CONNECT-only proxy without authentication +// on a loopback port for the harness, and print "PROXY-ADDR " once +// it listens and "PROXY-TUNNEL " for each connection it tunnels. croc's +// dialer bypasses proxies for local addresses, so a tunnel appears only for a +// non-local relay such as the public pool. +// +// multicast-listen joins the multicast GROUP on discovery's port for SECONDS +// seconds and prints "MULTICAST-LISTEN ", then one +// "MULTICAST-PAYLOAD " per datagram. A croc sender announces "croc" +// and a receiver "ok". croc drops discovery packets from the machine's own +// addresses, so on one host this is the only way to see the group in use. +// // -relay-index makes a generated code map to that public pool relay, as if the // latency probe had picked it. relay prints the pool relay a code maps to; // relays prints the pool in index order. @@ -39,6 +58,7 @@ import ( "net" "net/http" "os" + "strconv" "strings" "time" @@ -115,7 +135,7 @@ func printRelay(code string) { func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: croctest send|receive|store-send|store-receive|store-revoke|store-server|relay|relays ...") + fmt.Fprintln(os.Stderr, "usage: croctest send|receive|store-send|store-receive|store-revoke|store-server|socks5-proxy|http-proxy|multicast-listen|relay|relays ...") os.Exit(2) } mode := os.Args[1] @@ -134,6 +154,20 @@ func main() { } serveStore(os.Args[2]) return + case "socks5-proxy": + serveSocks5() + return + case "http-proxy": + serveHTTPProxy() + return + case "multicast-listen": + secs, err := strconv.Atoi(os.Args[len(os.Args)-1]) + if len(os.Args) != 4 || err != nil { + fmt.Fprintln(os.Stderr, "multicast-listen needs GROUP SECONDS") + os.Exit(2) + } + listenMulticast(os.Args[2], time.Duration(secs)*time.Second) + return case "relays": for _, r := range publicrelay.Relays() { fmt.Println(r) @@ -158,6 +192,15 @@ func main() { relayIndex := fs.Int("relay-index", -1, "public pool relay a generated code maps to (send)") noMulti := fs.Bool("no-multi", false, "one data connection instead of one per relay port (send)") transport := fs.String("transport", crocmobile.TransportAuto, "data transport: auto|relay|derp (send)") + curve := fs.String("curve", "p256", "PAKE curve") + hash := fs.String("hash", "xxhash", "file hash algorithm (send)") + port := fs.Int("port", crocmobile.DefaultBasePort, "base port of the local relay (send)") + transfers := fs.Int("transfers", crocmobile.DefaultTransfers, "data ports after the base port (send)") + multicast := fs.String("multicast", crocmobile.DefaultMulticastAddress, "multicast address for LAN discovery") + peerIP := fs.String("ip", "", "sender ip:port, skipping relays (receive)") + socks5 := fs.String("socks5", "", "SOCKS5 proxy") + connect := fs.String("connect", "", "HTTP proxy") + internalDNS := fs.Bool("internal-dns", false, "croc's built-in DNS resolver") _ = fs.Parse(os.Args[2:]) if *policy != crocmobile.PolicyKeepBoth && *policy != crocmobile.PolicyReplace { @@ -189,26 +232,36 @@ func main() { opts.ThrottleUpload = *throttle opts.NoMultiplexing = *noMulti opts.Transport = *transport + opts.Curve = *curve + opts.HashAlgorithm = *hash + opts.BasePort = *port + opts.Transfers = *transfers + opts.MulticastAddress = *multicast + opts.PeerIP = *peerIP + opts.Socks5Proxy = *socks5 + opts.HTTPProxy = *connect + opts.InternalDNS = *internalDNS + proxies := &crocmobile.StoreOptions{Socks5Proxy: *socks5, HTTPProxy: *connect} var tr *crocmobile.Transfer - d := &printDelegate{answer: *answer, policy: *policy, pool: *relay == "" && !*onlyLocal, transfer: &tr, ready: make(chan struct{})} + d := &printDelegate{answer: *answer, policy: *policy, pool: *relay == "" && !*onlyLocal && *peerIP == "", 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) + &crocmobile.StoreOptions{Server: *server, Downloads: *downloads, LifetimeSeconds: *lifetime, Socks5Proxy: *socks5, HTTPProxy: *connect}, 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) + tr, err = crocmobile.StartStoreReceive(fs.Arg(0), *out, proxies, 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 { + if err := crocmobile.RevokeStored(fs.Arg(0), fs.Arg(1), fs.Arg(2), proxies); err != nil { fmt.Fprintf(os.Stderr, "EVENT error %s\n", err) os.Exit(1) } diff --git a/crocmobile/cmd/croctest/netmodes.go b/crocmobile/cmd/croctest/netmodes.go new file mode 100644 index 00000000..eb79eb67 --- /dev/null +++ b/crocmobile/cmd/croctest/netmodes.go @@ -0,0 +1,203 @@ +package main + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// peerdiscovery's default port, which croc leaves alone. +const discoveryPort = 9999 + +// The proxy modes let the harness prove that crocmobile's -socks5 and -connect +// reach croc's dialer. Both do CONNECT only, with no authentication, listen on +// loopback, print "PROXY-ADDR " once they listen and one +// "PROXY-TUNNEL " per connection they tunnel, to stderr. +// +// multicast-listen lets the harness see which group crocmobile's -multicast +// reaches. croc's peer discovery drops packets from the machine's own +// addresses, so two croc peers on one host never find each other and a +// transfer cannot prove the option. + +func listenProxy() net.Listener { + 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, "PROXY-ADDR %s\n", l.Addr()) + return l +} + +// tunnel dials target, runs ready on the client once that worked, then copies +// both ways until either side closes. +func tunnel(client net.Conn, target string, ready func() error) { + defer client.Close() + remote, err := net.DialTimeout("tcp", target, 15*time.Second) + if err != nil { + return + } + defer remote.Close() + if ready() != nil { + return + } + fmt.Fprintf(os.Stderr, "PROXY-TUNNEL %s\n", target) + var wg sync.WaitGroup + wg.Add(2) + pipe := func(dst, src net.Conn) { + defer wg.Done() + _, _ = io.Copy(dst, src) + // Pass the half-close on, so the opposite copy ends once its peer + // has sent everything. + if cw, ok := dst.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } else { + _ = dst.Close() + } + } + go pipe(remote, client) + go pipe(client, remote) + wg.Wait() +} + +func serveSocks5() { + l := listenProxy() + for { + c, err := l.Accept() + if err != nil { + fmt.Fprintf(os.Stderr, "EVENT error %s\n", err) + os.Exit(1) + } + go handleSocks5(c) + } +} + +func handleSocks5(c net.Conn) { + target, err := socks5Handshake(c) + if err != nil { + c.Close() + return + } + tunnel(c, target, func() error { + _, err := c.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}) + return err + }) +} + +// socks5Handshake reads a no-auth greeting and a CONNECT request and returns +// the target as host:port. +func socks5Handshake(c net.Conn) (string, error) { + _ = c.SetDeadline(time.Now().Add(15 * time.Second)) + defer func() { _ = c.SetDeadline(time.Time{}) }() + head := make([]byte, 2) + if _, err := io.ReadFull(c, head); err != nil || head[0] != 5 { + return "", errors.New("not socks5") + } + if _, err := io.ReadFull(c, make([]byte, head[1])); err != nil { + return "", err + } + if _, err := c.Write([]byte{5, 0}); err != nil { + return "", err + } + req := make([]byte, 4) + if _, err := io.ReadFull(c, req); err != nil || req[0] != 5 || req[1] != 1 { + return "", errors.New("not a CONNECT request") + } + var host string + switch req[3] { + case 1, 4: + ip := make([]byte, map[byte]int{1: 4, 4: 16}[req[3]]) + if _, err := io.ReadFull(c, ip); err != nil { + return "", err + } + host = net.IP(ip).String() + case 3: + n := make([]byte, 1) + if _, err := io.ReadFull(c, n); err != nil { + return "", err + } + name := make([]byte, n[0]) + if _, err := io.ReadFull(c, name); err != nil { + return "", err + } + host = string(name) + default: + return "", errors.New("unknown address type") + } + port := make([]byte, 2) + if _, err := io.ReadFull(c, port); err != nil { + return "", err + } + return net.JoinHostPort(host, strconv.Itoa(int(binary.BigEndian.Uint16(port)))), nil +} + +func serveHTTPProxy() { + l := listenProxy() + fmt.Fprintf(os.Stderr, "EVENT error %s\n", http.Serve(l, http.HandlerFunc(handleConnect))) + os.Exit(1) +} + +func handleConnect(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if r.Method != http.MethodConnect || !ok { + http.Error(w, "CONNECT only", http.StatusMethodNotAllowed) + return + } + c, _, err := hj.Hijack() + if err != nil { + return + } + tunnel(c, r.Host, func() error { + _, err := c.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + return err + }) +} + +// listenMulticast joins group on every multicast interface for the given time +// and prints "MULTICAST-PAYLOAD " for each datagram, "MULTICAST-LISTEN +// " first with the number of interfaces it joined. It exits 1 when it joined +// none. +func listenMulticast(group string, d time.Duration) { + ip := net.ParseIP(group) + ifaces, _ := net.Interfaces() + var wg sync.WaitGroup + joined := 0 + deadline := time.Now().Add(d) + for i := range ifaces { + if ifaces[i].Flags&net.FlagUp == 0 || ifaces[i].Flags&net.FlagMulticast == 0 { + continue + } + c, err := net.ListenMulticastUDP("udp4", &ifaces[i], &net.UDPAddr{IP: ip, Port: discoveryPort}) + if err != nil { + continue + } + joined++ + _ = c.SetReadDeadline(deadline) + wg.Add(1) + go func() { + defer wg.Done() + defer c.Close() + buf := make([]byte, 1500) + for { + n, _, err := c.ReadFromUDP(buf) + if err != nil { + return + } + fmt.Fprintf(os.Stderr, "MULTICAST-PAYLOAD %s\n", strings.TrimSpace(string(buf[:n]))) + } + }() + } + fmt.Fprintf(os.Stderr, "MULTICAST-LISTEN %d\n", joined) + if joined == 0 { + os.Exit(1) + } + wg.Wait() +} diff --git a/crocmobile/crocmobile.go b/crocmobile/crocmobile.go index ccd540aa..5967d564 100644 --- a/crocmobile/crocmobile.go +++ b/crocmobile/crocmobile.go @@ -17,7 +17,6 @@ type Options struct { RelayAddress string RelayAddress6 string RelayPassword string - RelayPorts string // comma-joined Curve string HashAlgorithm string ThrottleUpload string @@ -36,6 +35,15 @@ type Options struct { Ask bool NoMultiplexing bool Transport string // send: TransportAuto (also when empty) or TransportRelay; a receive follows the sender + + // Advanced options. Zero values mean croc's defaults. + BasePort int // send: first port of this side's local relay (croc --port) + Transfers int // send: data ports after BasePort (croc --transfers) + MulticastAddress string // IPv4 group for LAN discovery (croc --multicast) + PeerIP string // receive: the sender's ip:port (croc --ip); skips relays, discovery and the public pool + Socks5Proxy string // host:port or socks5:// URL (croc --socks5); wins over HTTPProxy; may carry credentials + HTTPProxy string // host:port or http(s):// URL (croc --connect); may carry credentials + InternalDNS bool // resolve relay names with croc's built-in resolver (croc --internal-dns) } // NewOptionsDefault returns croc CLI defaults. @@ -47,12 +55,14 @@ type Options struct { // every Swift build. The suffix makes the selector `initDefault` instead. func NewOptionsDefault() *Options { return &Options{ - RelayAddress: models.DEFAULT_RELAY, - RelayAddress6: models.DEFAULT_RELAY6, - RelayPassword: models.DEFAULT_PASSPHRASE, - RelayPorts: "9009,9010,9011,9012,9013", - Curve: "p256", - HashAlgorithm: "xxhash", + RelayAddress: models.DEFAULT_RELAY, + RelayAddress6: models.DEFAULT_RELAY6, + RelayPassword: models.DEFAULT_PASSPHRASE, + Curve: "p256", + HashAlgorithm: "xxhash", + BasePort: DefaultBasePort, + Transfers: DefaultTransfers, + MulticastAddress: DefaultMulticastAddress, } } diff --git a/crocmobile/errors.go b/crocmobile/errors.go index 27628bfd..23efbe49 100644 --- a/crocmobile/errors.go +++ b/crocmobile/errors.go @@ -31,6 +31,7 @@ const ( 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) + codeInvalidSetting = "invalidSetting" // an Advanced option (proxy, ports, multicast, sender IP) failed validation codeOther = "other" ) diff --git a/crocmobile/errors_test.go b/crocmobile/errors_test.go index 68d15295..054ff97c 100644 --- a/crocmobile/errors_test.go +++ b/crocmobile/errors_test.go @@ -217,7 +217,13 @@ func TestRunReportsFailurePayload(t *testing.T) { close(pollDone) s := &session{delegate: d, done: make(chan struct{}), pollDone: pollDone} - s.run(func() error { return errors.New("peer error: refusing files") }, t.TempDir(), func() {}) + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + // run restores the working directory; a throwaway dir here would leave the + // whole test binary in a deleted cwd. + s.run(func() error { return errors.New("peer error: refusing files") }, wd, func() {}) if len(d.errors) != 1 { t.Fatalf("OnError calls = %v, want one", d.errors) diff --git a/crocmobile/network.go b/crocmobile/network.go new file mode 100644 index 00000000..575d7bd8 --- /dev/null +++ b/crocmobile/network.go @@ -0,0 +1,170 @@ +package crocmobile + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/schollz/croc/v11/src/comm" + "github.com/schollz/croc/v11/src/models" + "github.com/schollz/croc/v11/src/storeclient" +) + +// croc CLI defaults for the Advanced options (cli.go: --port, --transfers, +// --multicast). +const ( + DefaultBasePort = 9009 + DefaultTransfers = 4 + DefaultMulticastAddress = "239.255.255.250" +) + +// relayPorts is the send command's port list (cli.go send): the base port, +// then one more per transfer. The sender's local relay listens on them. +func relayPorts(o *Options) ([]string, error) { + base, n := o.BasePort, o.Transfers + if base == 0 { + base = DefaultBasePort + } + if n == 0 { + n = DefaultTransfers + } + if base < 1 || n < 1 || base+n > 65535 { + return nil, invalidSetting("ports %d to %d are out of range", base, base+n) + } + ports := make([]string, n+1) + for i := range ports { + ports[i] = strconv.Itoa(base + i) + } + return ports, nil +} + +func invalidSetting(format string, args ...any) error { + return &codedError{codeInvalidSetting, fmt.Errorf(format, args...)} +} + +// validateNetworkOptions checks the Advanced options croc would otherwise take +// as given and fail on late, or silently ignore. Proxy errors never quote the +// value: it can carry credentials. +func validateNetworkOptions(o *Options) error { + if err := validateProxy(o.Socks5Proxy, "socks5", "socks5", "socks5h"); err != nil { + return err + } + if err := validateProxy(o.HTTPProxy, "http", "http", "https"); err != nil { + return err + } + if err := validateThrottle(o.ThrottleUpload); err != nil { + return err + } + if m := strings.TrimSpace(o.MulticastAddress); m != "" { + ip := net.ParseIP(m) + if ip == nil || ip.To4() == nil || !ip.IsMulticast() { + return invalidSetting("multicast address %q is not an IPv4 multicast address", m) + } + } + if p := strings.TrimSpace(o.PeerIP); p != "" { + host, port, err := net.SplitHostPort(p) + if err != nil || net.ParseIP(host) == nil { + return invalidSetting("sender IP %q is not an IP address with a port", p) + } + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return invalidSetting("sender IP %q has no valid port", p) + } + } + return nil +} + +// validateProxy accepts what croc's dialer accepts: host:port, or a URL with +// one of the given schemes (croc adds defaultScheme to a bare host:port). +func validateProxy(value, defaultScheme string, schemes ...string) error { + _, err := proxyURL(value, defaultScheme, schemes...) + return err +} + +func proxyURL(value, defaultScheme string, schemes ...string) (*url.URL, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + if !strings.Contains(value, "://") { + value = defaultScheme + "://" + value + } + u, err := url.Parse(value) + if err != nil || u.Hostname() == "" { + return nil, invalidSetting("%s proxy is not host:port or a proxy URL", defaultScheme) + } + if port, err := strconv.Atoi(u.Port()); err != nil || port < 1 || port > 65535 { + return nil, invalidSetting("%s proxy has no valid port", defaultScheme) + } + for _, s := range schemes { + if u.Scheme == s { + return u, nil + } + } + return nil, invalidSetting("%s proxy scheme must be one of %s", defaultScheme, strings.Join(schemes, ", ")) +} + +// validateThrottle accepts croc's upload limit format: bytes per second, +// optionally followed by k, m or g. croc panics on anything else and on zero, +// and silently ignores a one-character value, so those are refused here. +func validateThrottle(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + digits := value + if strings.ContainsRune("kKmMgG", rune(value[len(value)-1])) { + digits = value[:len(value)-1] + } + n, err := strconv.ParseInt(digits, 10, 64) + if len(value) < 2 || err != nil || n < 1 || strings.ContainsAny(digits, "+-") { + return invalidSetting("upload limit %q is not a speed such as 500k", value) + } + return nil +} + +// setNetworkGlobals points croc's process-wide proxy and DNS settings at o and +// returns the function that puts the previous values back. croc reads these +// at dial time, so they are only written while activeMu is held, and the +// restore runs before the lock is released. +func setNetworkGlobals(o *Options) (restore func()) { + socks5, httpProxy, internalDNS := comm.Socks5Proxy, comm.HttpProxy, models.INTERNAL_DNS + comm.Socks5Proxy = strings.TrimSpace(o.Socks5Proxy) + comm.HttpProxy = strings.TrimSpace(o.HTTPProxy) + models.INTERNAL_DNS = o.InternalDNS + return func() { + comm.Socks5Proxy, comm.HttpProxy, models.INTERNAL_DNS = socks5, httpProxy, internalDNS + } +} + +// storeClient returns a storeclient.Client that never reads croc's proxy +// globals: RevokeStored runs outside activeMu, where reading them would race a +// transfer that sets them. The HTTP client mirrors storeclient's own default +// (SOCKS5 before HTTP, no redirects), with the proxies from o. +func storeClient(o *StoreOptions) (*storeclient.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + if o != nil { + socks5, err := proxyURL(o.Socks5Proxy, "socks5", "socks5", "socks5h") + if err != nil { + return nil, err + } + httpProxy, err := proxyURL(o.HTTPProxy, "http", "http", "https") + if err != nil { + return nil, err + } + if socks5 != nil { + transport.Proxy = http.ProxyURL(socks5) + } else if httpProxy != nil { + transport.Proxy = http.ProxyURL(httpProxy) + } + } + return &storeclient.Client{HTTP: &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return errors.New("stored-transfer redirects are not allowed") + }, + }}, nil +} diff --git a/crocmobile/network_test.go b/crocmobile/network_test.go new file mode 100644 index 00000000..f5d80a2a --- /dev/null +++ b/crocmobile/network_test.go @@ -0,0 +1,285 @@ +package crocmobile + +import ( + "encoding/json" + "io" + "net" + "net/http" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/schollz/croc/v11/src/comm" + "github.com/schollz/croc/v11/src/models" + "github.com/schollz/croc/v11/src/store" +) + +func TestRelayPorts(t *testing.T) { + tests := []struct { + name string + base, transfers int + want []string + }{ + {"croc defaults", 0, 0, []string{"9009", "9010", "9011", "9012", "9013"}}, + {"custom", 9100, 2, []string{"9100", "9101", "9102"}}, + {"one transfer", 50000, 1, []string{"50000", "50001"}}, + {"top of range", 65533, 2, []string{"65533", "65534", "65535"}}, + } + for _, tt := range tests { + got, err := relayPorts(&Options{BasePort: tt.base, Transfers: tt.transfers}) + if err != nil || !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: relayPorts = %v, %v; want %v", tt.name, got, err, tt.want) + } + } + for _, o := range []Options{{BasePort: -1}, {Transfers: -1}, {BasePort: 65535}, {BasePort: 65534, Transfers: 2}} { + if _, err := relayPorts(&o); codeOf(err) != codeInvalidSetting { + t.Errorf("relayPorts(%+v) err = %v, want %s", o, err, codeInvalidSetting) + } + } +} + +func TestValidateNetworkOptions(t *testing.T) { + valid := []Options{ + {}, + {Socks5Proxy: "127.0.0.1:1080", HTTPProxy: "proxy.example:3128"}, + {Socks5Proxy: "socks5://user:secret@proxy.example:1080"}, + {Socks5Proxy: "socks5h://proxy.example:1080"}, + {HTTPProxy: "http://user:secret@proxy.example:3128"}, + {HTTPProxy: " https://proxy.example:443 "}, + {MulticastAddress: "239.255.255.250"}, + {MulticastAddress: "224.0.0.251"}, + {PeerIP: "10.0.0.1:9009"}, + {PeerIP: "[::1]:9009"}, + {ThrottleUpload: "500k"}, + {ThrottleUpload: " 2M "}, + {ThrottleUpload: "1g"}, + {ThrottleUpload: "12"}, + } + for _, o := range valid { + if err := validateNetworkOptions(&o); err != nil { + t.Errorf("validateNetworkOptions(%+v) = %v, want nil", o, err) + } + } + invalid := []Options{ + {Socks5Proxy: "proxy.example"}, + {Socks5Proxy: "http://user:secret@proxy.example:1080"}, + {Socks5Proxy: "socks5://:1080"}, + {HTTPProxy: "socks5://user:secret@proxy.example:1080"}, + {HTTPProxy: "user:secret@proxy.example"}, + {MulticastAddress: "10.0.0.1"}, + {MulticastAddress: "ff02::1"}, + {MulticastAddress: "not an address"}, + {PeerIP: "10.0.0.1"}, + {PeerIP: "sender.local:9009"}, + {PeerIP: "10.0.0.1:0"}, + {PeerIP: "10.0.0.1:http"}, + {Socks5Proxy: "proxy.example:0"}, + {HTTPProxy: "proxy.example:70000"}, + {ThrottleUpload: "5"}, + {ThrottleUpload: "0k"}, + {ThrottleUpload: "k"}, + {ThrottleUpload: "kk"}, + {ThrottleUpload: "-5k"}, + {ThrottleUpload: "+5k"}, + {ThrottleUpload: "5kb"}, + {ThrottleUpload: "1.5m"}, + } + for _, o := range invalid { + err := validateNetworkOptions(&o) + if codeOf(err) != codeInvalidSetting { + t.Errorf("validateNetworkOptions(%+v) = %v, want %s", o, err, codeInvalidSetting) + continue + } + if strings.Contains(err.Error(), "secret") { + t.Errorf("error %q quotes the proxy credentials", err) + } + } +} + +func TestInvalidSettingFailsBeforeTheLock(t *testing.T) { + _, err := StartSend("", "hello", &Options{Socks5Proxy: "socks5://user:secret@nohost"}, newStoreRecorder()) + if err == nil || !strings.Contains(err.Error(), codeInvalidSetting) || strings.Contains(err.Error(), "secret") { + t.Fatalf("StartSend err = %v, want a coded %s without the credentials", err, codeInvalidSetting) + } + if comm.Socks5Proxy != "" { + t.Fatalf("comm.Socks5Proxy = %q after a refused start", comm.Socks5Proxy) + } + if !activeMu.TryLock() { + t.Fatal("lock held after a refused start") + } + activeMu.Unlock() +} + +type networkGlobals struct { + socks5, httpProxy string + internalDNS bool +} + +func readNetworkGlobals() networkGlobals { + return networkGlobals{comm.Socks5Proxy, comm.HttpProxy, models.INTERNAL_DNS} +} + +var proxiedOptions = Options{Socks5Proxy: "203.0.113.1:1080", HTTPProxy: "203.0.113.2:3128", InternalDNS: true} + +func assertGlobalsRestored(t *testing.T, before networkGlobals) { + t.Helper() + if got := readNetworkGlobals(); got != before { + t.Fatalf("network globals = %+v, want them restored to %+v", got, before) + } + if !activeMu.TryLock() { + t.Fatal("lock still held") + } + activeMu.Unlock() +} + +func TestNetworkGlobalsRestoredAfterStartError(t *testing.T) { + before := readNetworkGlobals() + o := proxiedOptions + o.OnlyLocal = true + if _, err := StartSend(filepath.Join(t.TempDir(), "missing"), "", &o, newStoreRecorder()); err == nil { + t.Fatal("StartSend of a missing file succeeded") + } + assertGlobalsRestored(t, before) +} + +type panickingCodeDelegate struct{ *storeRecorder } + +func (panickingCodeDelegate) OnCodeReady(string) { panic("delegate panic") } + +func TestNetworkGlobalsRestoredAfterPanic(t *testing.T) { + before := readNetworkGlobals() + o := proxiedOptions + o.OnlyLocal = true + o.WorkDir = t.TempDir() + _, err := StartSend("", "hello", &o, panickingCodeDelegate{newStoreRecorder()}) + if err == nil || !strings.Contains(err.Error(), "delegate panic") { + t.Fatalf("StartSend err = %v, want the delegate panic", err) + } + assertGlobalsRestored(t, before) +} + +// A transfer holds its proxy and DNS settings for its whole run, and puts the +// previous ones back when it ends (here, cancelled while it waits for a peer). +func TestNetworkGlobalsHeldForTheTransfer(t *testing.T) { + before := readNetworkGlobals() + o := proxiedOptions + o.OnlyLocal = true + o.BasePort = 47100 + o.Transfers = 2 + o.WorkDir = t.TempDir() + rec := newStoreRecorder() + tr, err := StartSend("", "hello", &o, rec) + if err != nil { + t.Fatalf("StartSend: %v", err) + } + want := networkGlobals{proxiedOptions.Socks5Proxy, proxiedOptions.HTTPProxy, true} + if got := readNetworkGlobals(); got != want { + t.Errorf("during the transfer: network globals = %+v, want %+v", got, want) + } + tr.Cancel() + rec.wait(t) + assertGlobalsRestored(t, before) +} + +// connectProxy is a minimal HTTP proxy for plain-http requests (the loopback +// store server), counting what it forwards. +func connectProxy(t *testing.T, forwarded *atomic.Int32) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + forwarded.Add(1) + r.RequestURI = "" + resp, err := http.DefaultTransport.RoundTrip(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + for k, v := range resp.Header { + w.Header()[k] = v + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + })} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + return ln.Addr().String() +} + +func TestStoreCallsUseTheProxy(t *testing.T) { + server := startTestStore(t, store.Config{}) + var forwarded atomic.Int32 + proxy := connectProxy(t, &forwarded) + o := StoreOptions{HTTPProxy: proxy} + + rec, receipt := storeSend(t, server, o, writeFiles(t, t.TempDir(), map[string]string{"a.txt": "via proxy"})) + if rec.errMsg != "" { + t.Fatalf("upload through the proxy failed: %s", rec.errMsg) + } + uploads := forwarded.Load() + if uploads == 0 { + t.Fatal("the upload bypassed the proxy") + } + + out := t.TempDir() + recv := newStoreRecorder() + tr, err := StartStoreReceive(receipt.Link, out, &o, recv) + if err != nil { + t.Fatal(err) + } + <-recv.fileReady + tr.Respond(true, PolicyKeepBoth) + recv.wait(t) + if recv.errMsg != "" || readFile(t, filepath.Join(out, "a.txt")) != "via proxy" { + t.Fatalf("download through the proxy: err %q", recv.errMsg) + } + downloads := forwarded.Load() + if downloads == uploads { + t.Fatal("the download bypassed the proxy") + } + + if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken, &o); err != nil { + t.Fatalf("RevokeStored: %v", err) + } + if forwarded.Load() == downloads { + t.Fatal("the revoke bypassed the proxy") + } +} + +func TestStoreClientPrefersSOCKS5AndRefusesRedirects(t *testing.T) { + c, err := storeClient(&StoreOptions{Socks5Proxy: "127.0.0.1:1080", HTTPProxy: "127.0.0.1:3128"}) + if err != nil { + t.Fatal(err) + } + req, _ := http.NewRequest(http.MethodGet, "https://getcroc.com/", nil) + u, err := c.HTTP.Transport.(*http.Transport).Proxy(req) + if err != nil || u == nil || u.String() != "socks5://127.0.0.1:1080" { + t.Fatalf("proxy = %v, %v; want socks5://127.0.0.1:1080", u, err) + } + if c.HTTP.CheckRedirect(req, nil) == nil { + t.Fatal("redirects are followed") + } + if _, err := storeClient(&StoreOptions{HTTPProxy: "user:secret@nohost"}); codeOf(err) != codeInvalidSetting { + t.Fatalf("bad proxy: err = %v, want %s", err, codeInvalidSetting) + } +} + +func TestStartErrorPayloadForInvalidSetting(t *testing.T) { + _, err := StartReceive("abcdef-code", &Options{OutDir: t.TempDir(), PeerIP: "nohost"}, newStoreRecorder()) + var f failure + if err == nil || json.Unmarshal([]byte(err.Error()), &f) != nil || f.Code != codeInvalidSetting { + t.Fatalf("StartReceive err = %v, want a %s payload", err, codeInvalidSetting) + } +} + +func codeOf(err error) string { + if err == nil { + return "" + } + return classify(err) +} diff --git a/crocmobile/relay.go b/crocmobile/relay.go index 5b9ecab5..341f7427 100644 --- a/crocmobile/relay.go +++ b/crocmobile/relay.go @@ -55,7 +55,7 @@ func resolveRelay(code string, o *Options) (relayRoute, error) { func usesPublicRelay(o *Options) bool { v4, v6 := customRelays(o) - return v4 == "" && v6 == "" && !o.OnlyLocal + return v4 == "" && v6 == "" && !o.OnlyLocal && strings.TrimSpace(o.PeerIP) == "" } // customRelays returns each relay address set to something other than croc's diff --git a/crocmobile/relay_test.go b/crocmobile/relay_test.go index 8a1e843d..aabbbd03 100644 --- a/crocmobile/relay_test.go +++ b/crocmobile/relay_test.go @@ -86,6 +86,7 @@ func TestBuildCrocOptionsPoolOff(t *testing.T) { {"custom v6, default v4", Options{RelayAddress: DefaultRelayAddress(), RelayAddress6: "[::1]:9009"}, "", "[::1]:9009"}, {"custom pair", Options{RelayAddress: "relay.example:9009", RelayAddress6: "[::1]:9009"}, "relay.example:9009", "[::1]:9009"}, {"custom pair, local only", Options{RelayAddress: "relay.example:9009", RelayAddress6: "[::1]:9009", OnlyLocal: true}, "relay.example:9009", "[::1]:9009"}, + {"sender IP", Options{PeerIP: "10.0.0.1:9009"}, "croc.schollz.com:9009", "croc6.schollz.com:9009"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/crocmobile/session.go b/crocmobile/session.go index 8304cfb9..704740f0 100644 --- a/crocmobile/session.go +++ b/crocmobile/session.go @@ -62,6 +62,10 @@ func buildCrocOptions(sender bool, secret string, o *Options) (croc.Options, err if err != nil { return croc.Options{}, err } + ports, err := relayPorts(o) + if err != nil { + return croc.Options{}, err + } password := o.RelayPassword if password == "" { password = models.DEFAULT_PASSPHRASE @@ -73,10 +77,10 @@ func buildCrocOptions(sender bool, secret string, o *Options) (croc.Options, err RelayAddress6: route.v6, PublicRelay: route.public, RelayPassword: password, - RelayPorts: splitNonEmpty(o.RelayPorts, ","), + RelayPorts: ports, Curve: o.Curve, HashAlgorithm: o.HashAlgorithm, - ThrottleUpload: o.ThrottleUpload, + ThrottleUpload: strings.TrimSpace(o.ThrottleUpload), DisableLocal: o.DisableLocal, OnlyLocal: o.OnlyLocal, NoCompress: o.NoCompress, @@ -90,6 +94,8 @@ func buildCrocOptions(sender bool, secret string, o *Options) (croc.Options, err DisableClipboard: true, Quiet: false, // Quiet=true nukes process stderr globally Transport: transport, + IP: strings.TrimSpace(o.PeerIP), + MulticastAddress: strings.TrimSpace(o.MulticastAddress), } optionsPolicy(o).apply(&co) return co, nil @@ -171,11 +177,14 @@ func splitNonEmpty(s, sep string) []string { // from croc.GetFilesInfoWithExactExclusions/croc.NewCtx, and (because // `committed` is only set true right before the `go s.run(...)` call, after // d.OnCodeReady returns) any panic raised by the delegate's OnCodeReady -// itself. This is what guarantees the lock, cwd, and temp file are never -// leaked, even across a panic. The panic itself is left to propagate to +// itself. This is what guarantees the lock, cwd, temp file and croc's network +// globals (setNetworkGlobals) are never leaked, even across a panic. The panic itself is left to propagate to // StartSend/StartReceive's own recover, which converts it into a returned // error. func startSession(sender bool, code string, paths []string, text string, o *Options, d Delegate) (*session, error) { + if err := validateNetworkOptions(o); err != nil { + return nil, err + } if !activeMu.TryLock() { return nil, errors.New("another transfer is active") } @@ -184,12 +193,17 @@ func startSession(sender bool, code string, paths []string, text string, o *Opti activeMu.Unlock() return nil, fmt.Errorf("working directory: %w", err) } + restoreNetwork := setNetworkGlobals(o) + release := func() { + restoreNetwork() + activeMu.Unlock() + } committed := false defer func() { if !committed { removeTempFiles() restoreWD(origWD) - activeMu.Unlock() + release() } }() @@ -289,22 +303,22 @@ func startSession(sender bool, code string, paths []string, text string, o *Opti forgetPublicRelay() } return err - }, origWD, func() { activeMu.Unlock() }) + }, origWD, release) go s.poll() return s, nil } - return startReceiveSession(code, o, d, origWD, &committed) + return startReceiveSession(code, o, d, origWD, release, &committed) } // startReceiveSession is startSession's receive-side counterpart, called as a // tail call from within startSession's own frame: the `committed` guard defer // declared there is still armed for every error path below, so on failure we // only need to unwind whatever this function itself set up (pipes, os.Stdin/ -// os.Stdout swaps, the croc ctx) — cwd restore and the activeMu unlock are -// handled by that defer once we return. Only right before the async run -// actually starts do we flip *committed to true, handing ownership of cwd -// and the lock to s.run/release. -func startReceiveSession(code string, o *Options, d Delegate, origWD string, committed *bool) (*session, error) { +// os.Stdout swaps, the croc ctx) — cwd restore and release (network globals, +// then the activeMu unlock) are handled by that defer once we return. Only +// right before the async run actually starts do we flip *committed to true, +// handing ownership of cwd and the lock to s.run/release. +func startReceiveSession(code string, o *Options, d Delegate, origWD string, release func(), committed *bool) (*session, error) { outDir := o.OutDir if outDir == "" { return nil, errors.New("OutDir required for receive") @@ -407,7 +421,7 @@ func startReceiveSession(code string, o *Options, d Delegate, origWD string, com } *committed = true - go s.run(func() error { return c.Receive() }, origWD, func() { activeMu.Unlock() }) + go s.run(func() error { return c.Receive() }, origWD, release) go s.poll() return s, nil } @@ -662,6 +676,14 @@ func (s *session) poll() { for { select { case <-s.done: + // A transfer that ends inside one tick (a small file over a fast + // link, auto-accepted) would otherwise never report its list. + if !s.sender && !listSent && len(s.client.FilesToTransfer) > 0 { + func() { + defer func() { recover() }() //nolint:errcheck // a delegate panic must not skip close(pollDone) + s.delegate.OnFileList(s.fileListJSON()) + }() + } return case <-t.C: } diff --git a/crocmobile/session_test.go b/crocmobile/session_test.go index c6abff7e..4c9f0e82 100644 --- a/crocmobile/session_test.go +++ b/crocmobile/session_test.go @@ -60,22 +60,25 @@ func mustBuildCrocOptions(t *testing.T, sender bool, secret string, o *Options) func TestBuildCrocOptionsFieldMapping(t *testing.T) { o := &Options{ - RelayAddress: "relay4.example:9009", - RelayAddress6: "relay6.example:9009", - RelayPassword: "relaypass", - RelayPorts: "9009, 9010,,9011", - Curve: "siec", - HashAlgorithm: "imohash", - ThrottleUpload: "500k", - Exclude: "node_modules\n .git \n\nbuild", - DisableLocal: true, - OnlyLocal: true, - NoCompress: true, - ZipFolder: true, - GitIgnore: true, - Overwrite: true, - Ask: true, - NoMultiplexing: true, + RelayAddress: "relay4.example:9009", + RelayAddress6: "relay6.example:9009", + RelayPassword: "relaypass", + BasePort: 9100, + Transfers: 2, + PeerIP: "10.0.0.1:9100", + MulticastAddress: " 239.1.2.3 ", + Curve: "siec", + HashAlgorithm: "imohash", + ThrottleUpload: "500k", + Exclude: "node_modules\n .git \n\nbuild", + DisableLocal: true, + OnlyLocal: true, + NoCompress: true, + ZipFolder: true, + GitIgnore: true, + Overwrite: true, + Ask: true, + NoMultiplexing: true, } got := mustBuildCrocOptions(t, false, "secret-code", o) @@ -85,7 +88,9 @@ func TestBuildCrocOptionsFieldMapping(t *testing.T) { RelayAddress: "relay4.example:9009", RelayAddress6: "relay6.example:9009", RelayPassword: "relaypass", - RelayPorts: []string{"9009", "9010", "9011"}, + RelayPorts: []string{"9100", "9101", "9102"}, + IP: "10.0.0.1:9100", + MulticastAddress: "239.1.2.3", Curve: "siec", HashAlgorithm: "imohash", ThrottleUpload: "500k", @@ -739,3 +744,24 @@ func TestSendSetupFailureRemovesZip(t *testing.T) { assertNoZip(t, dir) } + +type fileListRecorder struct { + errorDelegate + lists []string +} + +func (d *fileListRecorder) OnFileList(l string) { d.lists = append(d.lists, l) } + +// A receive that finishes before poll's first tick must still report its list. +func TestPollReportsFileListOfTransferEndedWithinATick(t *testing.T) { + d := &fileListRecorder{} + s := &session{ + client: &croc.Client{FilesToTransfer: []croc.FileInfo{{Name: "a.txt", FolderRemote: ".", Size: 5}}}, + delegate: d, done: make(chan struct{}), pollDone: make(chan struct{}), + } + close(s.done) + s.poll() + if len(d.lists) != 1 { + t.Fatalf("OnFileList called %d times, want 1", len(d.lists)) + } +} diff --git a/crocmobile/store.go b/crocmobile/store.go index b8f72d07..3b8f60f7 100644 --- a/crocmobile/store.go +++ b/crocmobile/store.go @@ -28,11 +28,14 @@ const ( storeRevokeWait = 30 * time.Second ) -// StoreOptions configures StartStoreSend. +// StoreOptions configures the stored-transfer calls. StartStoreReceive and +// RevokeStored read only the proxies; nil means no proxy. 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 + Socks5Proxy string // as Options.Socks5Proxy + HTTPProxy string // as Options.HTTPProxy } // storedReceipt is the OnStored payload: everything the sender needs to share @@ -124,9 +127,13 @@ func StartStoreSend(pathsJoined string, o *StoreOptions, d Delegate) (t *Transfe if err := validateStoreOrigin(o.Server); err != nil { return nil, startError(err) } + client, err := storeClient(o) + if 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, + result, err := client.UploadWithOptions(ctx, server, paths, storeclient.UploadOptions{Downloads: downloads, Expiration: lifetime}, s.storeCallbacks()) if err != nil { return storeSummary{}, err @@ -156,7 +163,7 @@ func StartStoreSend(pathsJoined string, o *StoreOptions, d Delegate) (t *Transfe // 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) { +func StartStoreReceive(link, outDir 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())) @@ -172,8 +179,11 @@ func StartStoreReceive(link, outDir string, d Delegate) (t *Transfer, err error) if err != nil { return nil, startError(err) } + client, err := storeClient(o) + 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 @@ -209,7 +219,7 @@ func StartStoreReceive(link, outDir string, d Delegate) (t *Transfer, err error) // 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) { +func RevokeStored(origin, id, token string, o *StoreOptions) (err error) { defer func() { if r := recover(); r != nil { err = startError(fmt.Errorf("croc panic: %v\n%s", r, debug.Stack())) @@ -219,9 +229,13 @@ func RevokeStored(origin, id, token string) (err error) { // 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)} + client, err := storeClient(o) + if err != nil { + return startError(err) + } ctx, cancel := context.WithTimeout(context.Background(), storeRevokeWait) defer cancel() - err = new(storeclient.Client).Revoke(ctx, share, token) + err = client.Revoke(ctx, share, token) var httpErr *storeclient.HTTPError if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusGone) { return nil diff --git a/crocmobile/store_test.go b/crocmobile/store_test.go index c3868f1e..ff8df92e 100644 --- a/crocmobile/store_test.go +++ b/crocmobile/store_test.go @@ -150,7 +150,7 @@ func storeSend(t *testing.T, server string, o StoreOptions, paths []string) (*st func storeReceive(t *testing.T, link, out string, accept bool, policy string) *storeRecorder { t.Helper() rec := newStoreRecorder() - tr, err := StartStoreReceive(link, out, rec) + tr, err := StartStoreReceive(link, out, nil, rec) if err != nil { t.Fatalf("StartStoreReceive: %v", err) } @@ -219,7 +219,7 @@ func TestStoreSendReceiveRevoke(t *testing.T) { t.Fatalf("file list = %q (%v)", got.fileList, err) } - if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken); err != nil { + if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken, nil); err != nil { t.Fatalf("RevokeStored: %v", err) } gone := storeReceive(t, receipt.Link, t.TempDir(), true, PolicyKeepBoth) @@ -232,16 +232,16 @@ 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 { + if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken, nil); err != nil { t.Fatal(err) } - if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken); err != nil { + if err := RevokeStored(receipt.Origin, receipt.ID, receipt.UploadToken, nil); 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") + err := RevokeStored("http://127.0.0.1:1", strings.Repeat("A", 22), "token", nil) if err == nil { t.Fatal("revoke against a closed port succeeded") } @@ -315,7 +315,7 @@ func TestStoreReceiveCancelWhileWaiting(t *testing.T) { _, receipt := storeSend(t, server, StoreOptions{}, writeFiles(t, src, map[string]string{"a.txt": "x"})) rec := newStoreRecorder() - tr, err := StartStoreReceive(receipt.Link, t.TempDir(), rec) + tr, err := StartStoreReceive(receipt.Link, t.TempDir(), nil, rec) if err != nil { t.Fatal(err) } @@ -414,11 +414,11 @@ func TestStoreReceiveRejectsBadLinks(t *testing.T) { "http://example.com/s/AAAAAAAAAAAAAAAAAAAAAA#v1.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "https://example.com/s/short#v1.key", } { - if _, err := StartStoreReceive(link, t.TempDir(), newStoreRecorder()); err == nil { + if _, err := StartStoreReceive(link, t.TempDir(), nil, newStoreRecorder()); err == nil { t.Errorf("link %q accepted", link) } } - if _, err := StartStoreReceive("https://example.com/s/x", "", newStoreRecorder()); err == nil { + if _, err := StartStoreReceive("https://example.com/s/x", "", nil, newStoreRecorder()); err == nil { t.Error("empty output directory accepted") } } @@ -429,7 +429,7 @@ func TestStoreHoldsTheTransferLock(t *testing.T) { _, receipt := storeSend(t, server, StoreOptions{}, writeFiles(t, src, map[string]string{"a.txt": "x"})) rec := newStoreRecorder() - tr, err := StartStoreReceive(receipt.Link, t.TempDir(), rec) + tr, err := StartStoreReceive(receipt.Link, t.TempDir(), nil, rec) if err != nil { t.Fatal(err) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5b1fd631..94e643c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,7 +46,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` (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`). +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`, `invalidSetting`, `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`). `network.go` validates the Advanced options (`invalidSetting`) and sets croc's process-wide proxy and DNS globals for the length of one transfer, restored before `activeMu` is released; stored transfers get an explicit proxied HTTP client instead, because `RevokeStored` runs outside the lock. 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 4e7b4905..b040f365 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -109,8 +109,8 @@ A green build is **not** evidence a transfer works. CI runs only `verify-interop | Harness | Proves | Needs | Env vars | |---|---|---|---| -| `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-interop.sh` | 15 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), and the Advanced options that change the wire (15, each against the CLI with the matching flag: curve, hash, base port with transfers and the sender IP, multicast group, SOCKS5 and HTTP proxy, internal DNS; the curve and hash cases assert which side's setting the other adopts, a mismatch included). With `RELAY` set every transfer uses that relay and the pool (11), transport (13), proxy and internal DNS (15) scenarios are skipped; 14 and the rest of 15 need no public relay and still run | 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, 12 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`); then stored transfers (9, `MAC-STORE-OK`), a send started through the menu-bar view model with a file and with clipboard text (10, `MAC-MENUBAR-OK`; it saves and restores the text on the pasteboard), Open at login following `SMAppService.status` (11, `MAC-LOGIN-OK`; it registers the login item and unregisters it again), and the three Shortcuts actions run through their `perform()` against the CLI (12, `MAC-INTENT-OK`; the receive saves into the real `~/Downloads/CrocApp` and deletes the file); 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` | | `crockit-verify` (run via `swift run --package-path CrocKit crockit-verify send\|receive\|twice ...`) | Swift-layer send/receive, cancel fired mid-wire, and two transfers back-to-back in one process (proves fd0/stdout/cwd/mutex restoration composes) | outbound network, croc CLI, macOS only | optional `CROC_RELAY`, `CROC_PASS` to point at a private relay | diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 7f38a85c..63f94d48 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -16,6 +16,10 @@ Terms a new contributor or a fresh AI session hits in this repo and cannot infer | `--ignore-stdin` | croc CLI flag needed for non-interactive runs; the Go library equivalent (`Options.IgnoreStdin`) is always set `true` in `crocmobile/session.go`, since the app supplies its own prompt pipe instead of a real terminal. | | LAN peer discovery | UDP multicast peer discovery (`schollz/peerdiscovery`, ~500 ms window) that lets same-network transfers skip the relay; not guaranteed to work on every network, see `docs/knowledge/what-is-croc.md` §6. | | `--local` / only-local | forces a transfer to stay on the LAN, refusing the public relay; maps to `crocmobile.Options.OnlyLocal` / `AppSettings.onlyLocal` (F14). | +| sender IP | the receive-side Advanced setting (`AppSettings.peerIP`, `EngineOptions.peerIP`, croc `--ip`): the sender's `ip:port`, IPv4 or bracketed IPv6. The receiver connects straight to the sender's local relay, skipping relays, discovery and the public pool; the trust badge reads `RelayKind.directToSender`. | +| Advanced settings | R25 (F20-F28): `AdvancedSettingsView`, `AppSettings` keys `settings.advanced.*`, proxies in the Keychain (accounts `advanced.socks5Proxy`, `advanced.httpProxy`); croc's curve, hash, upload throttle, ports, multicast, proxies, built-in DNS and multiplexing (`docs/knowledge/app-ui-architecture.md`, "Advanced settings (R25)"). | +| menu-bar quick send | R26 (F31), macOS only: a `MenuBarExtra` (window style) showing `MenuBarQuickSendView`, driven by `MenuBarQuickSend`, on the one `TransferController`; shown while `AppSettings.showInMenuBar` is on. `LoginItem` wraps `SMAppService.mainApp` for Open at login (`docs/knowledge/app-ui-architecture.md`, "Menu bar and Shortcuts (R26-R28)"). | +| App Shortcut / App Intent | R27 (F34): `SendFilesIntent`, `SendTextIntent`, `ReceiveIntent` in `app/CrocApp/Intents/`, offered by `CrocAppShortcuts` (`AppShortcutsProvider`) with Siri and Spotlight phrases; they run in the foreground and depend on `TransferController` and `OutputFolderStore` through `AppDependencyManager` (`docs/knowledge/app-ui-architecture.md`, "Menu bar and Shortcuts (R26-R28)"). | | PAKE | Password-Authenticated Key Exchange (`github.com/schollz/pake/v3`, Boneh-Shoup construction); turns the weak code phrase into a strong session key without the relay ever learning it (`docs/knowledge/what-is-croc.md`). | | 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"). | diff --git a/docs/knowledge/app-ui-architecture.md b/docs/knowledge/app-ui-architecture.md index b7f6411f..68be1930 100644 --- a/docs/knowledge/app-ui-architecture.md +++ b/docs/knowledge/app-ui-architecture.md @@ -76,6 +76,27 @@ Relay strings use `""` to mean "croc default" (the `TextField` prompt reads "Pub `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:)`). +### Advanced settings (R25) + +Croc's own options, edited in `Views/AdvancedSettingsView.swift`: iOS pushes it from `SettingsScreen` (`Route.advancedSettings`, `--screen advanced`); macOS Settings is a `TabView` with General (the old form) and Advanced, because the Advanced controls and their footnotes would double a form that already scrolls. +- `AppSettings` has one property per control under `settings.advanced.` (curve, hashAlgorithm, throttleUpload, noMultiplexing, basePort, transfers, multicastAddress, peerIP, internalDNS). Defaults come from `EngineOptions()`, so they are croc's. `resetAdvancedToDefaults()` backs the Reset to Defaults button (persists); the harness `resetToDefaults()` calls it with `persist` off. +- **The two proxies are Keychain-only** (`SecretStore` accounts `advanced.socks5Proxy`, `advanced.httpProxy`, deleted when blank, same `persist` gate as `relayPassword`). They can carry credentials: `ProxySettings`, `EngineOptions` and `StoredSendOptions` describe themselves without the value; the Go engine never quotes a proxy in an error; nothing logs them. `AppSettingsAdvancedTests` asserts UserDefaults holds no proxy key or value. +- `Models/AdvancedSettingsValidation.swift` holds the inline checks (`socks5ProxyError`, `httpProxyError`, `multicastAddressError`, `senderIPError`, `portsError`, `throttleError`). They mirror `crocmobile/network.go` (tables in `AdvancedSettingsValidationTests` match `network_test.go`); two are slightly stricter than the engine on purpose: a proxy or sender port outside 1-65535, and a one-character upload limit (croc ignores it silently). Blank always means the default. +- `TransferController.baseOptions()` passes every setting; `startReceive` adds `peerIP`; store send, store receive and revoke pass `ProxySettings`. Multicast reaches the engine on macOS only (`#if os(macOS)`): iOS lacks the multicast entitlement (concern 8), so the control is hidden and the engine keeps its default. The engine reports a bad proxy, port, multicast address or sender IP as `invalidSetting`; it does not classify a bad upload limit, so `requireUsableUploadLimit()` fails a send with the same code first. Copy for `invalidSetting` points at Settings > Advanced. +- A receive with a sender IP shows `RelayKind.directToSender` in the trust badge (`AppSettings.receiveRelayKind`), never the public pool. + +### Menu bar and Shortcuts (R26-R28) + +Neither surface owns transfer state. Both drive the one `TransferController`, so they show and respect a transfer started anywhere, and a second start is refused while `isActive`. +- **Menu bar (macOS, R26).** `CrocAppApp` declares a `MenuBarExtra` in window style, inserted while `AppSettings.showInMenuBar` is on (`settings.showInMenuBar`, default true). `Views/MenuBarQuickSendView.swift` switches on `controller.phase`; `Models/MenuBarQuickSend.swift` holds the actions (`send(urls:)`, `sendClipboardText()`, `chooseFiles()`) and only a `notice` string. Sends use a generated code. `chooseFiles()` uses a modal `NSOpenPanel`: the window-style extra closes when focus moves to a SwiftUI file importer's sheet. `.incoming` shows "Open CrocApp", because the accept prompt lives in the main window. A drop lands only on the open window, never on the status item. +- **Open at login.** `Models/LoginItem.swift` wraps `SMAppService.mainApp` behind the `LoginItemService` protocol (injected in tests). The toggle reads `status`, never a stored copy, since the user can change it in System Settings > General > Login Items; `SettingsView` refreshes on appear and when the app becomes active, and shows an Open Settings button (`SMAppService.openSystemSettingsLoginItems()`) for `.requiresApproval`. +- **Intents (R27).** `Intents/` holds `SendFilesIntent`, `SendTextIntent`, `ReceiveIntent` (all `supportedModes: .foreground`, because a transfer outlives the ~30 s iOS gives a background action) and `CrocAppShortcuts`. The intents take `TransferController` and `OutputFolderStore` with `@Dependency`; `CrocAppApp.init` registers both in `AppDependencyManager.shared` before any window, since an action can run right after launch. +- `TransferController.wait(for:)` (`TransferIntentSupport.swift`) suspends until a phase transform yields a value, re-arming `withObservationTracking` on `phase` in the same main-actor turn as the check so no change slips between them. `awaitCode()` ends at `.waiting` (the send actions return there, not at the end of the transfer) and `awaitReceive()` at `.done`; `.failed` becomes `TransferIntentError.failed`. +- `ReceivedFileLocator` finds what a receive wrote. croc keeps the sender's modification dates, so it tells new files apart by creation date: `name.ext` or its Keep Both copy `name (n).ext` created since the action started. `controller.offeredFileNames` gives the names. +- `IntentStaging` writes files that arrive without a usable file URL, and a received text as `Received text.txt`, under `ShortcutsFiles` in the temporary directory. A send outlives its action, so nothing is deleted on return; `SendFilesIntent` sweeps the folder at the start of the next send. +- A shortcut does not answer the accept prompt (auto-accept applies as usual), and Keep Both or Replace follows the setting. +- **iPad (R28).** `Views/AppCommands.swift` shows Send… (⌘1), Receive… (⌘2) and Show Receive Folder (⇧⌘R) in the File menu on both platforms. On iPadOS the last one opens `shareddocuments://` plus the percent-encoded receive-folder path with `UIApplication.open`, which is the Files app's scheme for a local folder. Not verified on a device. + ## History `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)`. @@ -119,7 +140,7 @@ Asynchronous. `.incoming` is shown immediately with `conflicts: []`; `Self.scanC - `Models/AppRouter.swift` — `@Observable` singleton (`shared`). `path: [Route]` drives `HomeView`'s `NavigationStack`. `pendingSendURLs` is consumed by `SendView` (`onAppear` plus `onChange`, deduplicated against `pickedURLs`, then cleared). `isBusy` is a computed read of `controller.isActive` through a weak controller reference that `CrocAppApp.init` attaches with `attach(controller:)`, so a dock drop mid-transfer queues without navigating. It is a singleton because `AppDelegate` and `Commands` sit outside the environment graph. - Three drop entry points, one guard policy: the window drop (`ContentView`, macOS-only) refuses while active and filters `\.isFileURL`; the `SendView` list drop filters too; the dock drop (`Platform/AppDelegate.swift`, `application(_:open:)`) always queues and navigates only when idle. - Dock drop works because `Config/CrocApp-macOS-Info.plist` declares `public.item` / Viewer / rank None document types. Side effect: the app is listed in Finder's "Open With" for everything, and `INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=macosx*]=YES` is required or Xcode auto-injects an invalid `NO`. -- `Views/SettingsView.swift` — output-folder change/reset plus Show in Finder. `Views/AppCommands.swift` — Send ⌘1, Receive ⌘2 (disabled while active), Show Receive Folder ⇧⌘R. Window: `.defaultSize(560×700)`, content `minWidth 480` / `minHeight 560`. +- `Views/SettingsView.swift` — a General tab (output-folder change/reset plus Show in Finder, the power sections) and an Advanced tab. `Views/AppCommands.swift` — Send ⌘1, Receive ⌘2 (disabled while active), Show Receive Folder ⇧⌘R. Window: `.defaultSize(560×700)`, content `minWidth 480` / `minHeight 560`. - `CrocApp Direct` only (`#if DIRECT`, `app/CrocAppDirect/AppUpdater.swift`): a "Check for Updates…" item in the app menu right after "About CrocApp", backed by Sparkle's `SPUStandardUpdaterController`. The App Store target has no such item. - Default output folder is `~/Downloads/CrocApp`, needing the `files.downloads.read-write` entitlement; the folder is auto-created in `defaultFolder` and `defaultDisplayName` supplies the UI label. Receive-done offers "Show in Finder" via `NSWorkspace.activateFileViewerSelecting`. - Distribution: `scripts/build-appstore.sh [--upload] [ios|macos]` archives `CrocApp` and exports or uploads the iOS and Mac App Store builds with `Config/ExportOptions-{iOS,MAS}.plist` (automatic cloud signing). `scripts/build-devid.sh` archives the `CrocApp Direct` scheme with `ARCHS=arm64` and manual Developer ID signing, exports with `Config/ExportOptions-DevID.plist`, notarizes and staples the app, then builds, signs, notarizes and staples the DMG (it exits 1 without a Developer ID identity). `Config/ExportOptions-{iOS,MAS,DevID}.plist` carry the per-channel settings — no extra pbxproj configurations. diff --git a/docs/knowledge/crocmobile-bridge.md b/docs/knowledge/crocmobile-bridge.md index 8f78a418..7d7b8921 100644 --- a/docs/knowledge/crocmobile-bridge.md +++ b/docs/knowledge/crocmobile-bridge.md @@ -24,7 +24,7 @@ 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`, `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`. +- 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`, `invalidSetting`, `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. @@ -56,10 +56,26 @@ CrocKit Swift package (CrocEngine actor, AsyncStream) → app - That reader is process-wide and buffers ahead, so answers croc never asked for (the policy batch, extra sender-Ask `y` lines) would sit in it and answer the next transfer's accept prompt: a Go unit test showed the next transfer reading `n`, a decline. `drainPromptInput` (`session.go`) therefore reads `stdinReader` to EOF through `utils.GetInputContext`, with a 2 s timeout, at session end after the prompt pipe closes and before fd 0 is restored. This also covers the older sender-Ask path, where leftover `y` lines were once judged harmless. - A zipped-folder send writes `.zip` into the send cwd (`Options.WorkDir`) and marks it with `utils.MarkFileForRemoval`. The library deletes it itself only after a successful send; the CLI's `main.go` sweeps the rest with `utils.RemoveMarkedFiles()` on exit. crocmobile calls `RemoveMarkedFiles` after every send (success, failure, cancel) and when session setup fails after the zip was made. +## Advanced options on the wire + +`Options.Curve`, `HashAlgorithm`, `BasePort`/`Transfers`, `MulticastAddress`, `PeerIP`, `Socks5Proxy`, `HTTPProxy` and `InternalDNS` map one to one onto croc's flags of the same name (`buildCrocOptions`, `network.go`; `croctest` takes `-curve -hash -port -transfers -multicast -ip -socks5 -connect -internal-dns`). `verify-interop.sh` scenario 15 runs each against the croc v11.5.4 CLI. Observed there, with the croc source (`src/croc/croc.go`, `comm/comm.go`) read for the reason: + +| Option | Which side it matters on | Peer must match? | Mismatch | +|---|---|---|---| +| Curve | The **receiver** chooses it: its PAKE message carries the curve name and the sender adopts it (sender `--debug`: `using curve p384`). A sender's own curve is never read. | No | Never an error, either direction. wrapper send `siec` / CLI receive `p384` and CLI send `siec` / wrapper receive `p384` both complete, on the receiver's curve. A name croc does not know fails at the receiver start: `EVENT error {"code":"other","message":"no such curve"}` (the app picks from a list, so it cannot happen there) | +| Hash | The **sender** chooses it and the receiver adopts it (receiver `--debug`: `using hash algorithm: md5`). `imohash` goes out as `imohash-v2`. | No | Never an error: a receiver set to `highway` takes the sender's `md5`. An unknown name fails at the sender start: `{"code":"other","message":"unsupported algorithm: bogus"}` | +| Base port, Transfers | **Sender** only: its local relay listens on the base port and the next `Transfers` ports (the CLI's `--port`/`--transfers`). The announced banner lists the data ports, e.g. `banner: 26993,26994`. | The receiver needs the base port, through discovery (which announces it) or `PeerIP` | A receiver pointed at the wrong port fails, see PeerIP. If a port is busy, croc moves to the next open ones (`utils.FindOpenPorts`, read from source, not observed), so the announced port can differ from the setting | +| PeerIP | **Receiver** only (`--ip host:port`): connect straight to the sender's local relay and skip public relay and discovery, so it works only with a sender whose local relay is on (not `NoLocal`) and reachable | The sender must listen there | Nothing listening: fails at once, `EVENT error {"code":"relayUnreachable","message":"could not connect to 127.0.0.1:: ... connection refused"}`, no fallback to a relay | +| Multicast address | Both sides of **LAN discovery**: a sender announces `croc` on the group, a receiver announces `ok` and listens (default `239.255.255.250`, UDP port 9999). | Yes, for discovery: peers on different groups never hear each other (a listener on another group heard nothing from the sender). The relay path still works | Silent: discovery finds nobody, then the relay or `PeerIP` path carries the transfer. A transfer across two hosts on a shared group is not covered by the harness: croc drops discovery packets from the host's own addresses (`peerdiscovery` `AllowSelf` false), so two peers on one host never find each other, not even two CLIs | +| SOCKS5, HTTP proxy | The local side's own dials only (CONNECT, SOCKS5 before HTTP). Invisible to the peer | No | croc bypasses the proxy for local addresses (`utils.IsLocalIP`: loopback, link-local, private ranges), so a proxy affects only a public relay or Tailcat dial. With a local relay or a LAN transfer it is never used, with no error. Verified: with the public pool, 2 (send) to 9 (receive) connections went through a `croctest` proxy per transfer | +| Internal DNS | The local side's lookup of the relay name (built-in resolver against public DNS servers instead of the system's) | No | A transfer over the public pool works with it on. The harness cannot observe which resolver ran; `network_test.go` pins that the option reaches `models.INTERNAL_DNS` | + +Not an option mismatch but easy to confuse: `NoMultiplexing` and `ThrottleUpload` are sender-side only (scenarios 6, 7 and 11 use them). + ## 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`: 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-interop.sh`: 15 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), proxy and internal DNS (15) and pool (11) scenarios. Scenario 15 (Advanced options) is described under "Advanced options on the wire" above; its curve, hash, port and multicast cases run under `RELAY` too. 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/docs/knowledge/device-test-checklist.md b/docs/knowledge/device-test-checklist.md index 5e4a62cf..24f63a83 100644 --- a/docs/knowledge/device-test-checklist.md +++ b/docs/knowledge/device-test-checklist.md @@ -45,6 +45,34 @@ Owner-run on a physical iPhone, with the default server (`https://getcroc.com`) - [ ] 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 +## Menu bar on a Mac (R26) +Owner-run on a real Mac, with the croc CLI as the receiver (`croc --yes ` in an empty folder). `verify-app-mac.sh` scenarios 10 and 11 already cover a menu-bar send of a file and of clipboard text received by the CLI, and Open at login following the system status; these items cover the real gestures and the real login. Compare SHA-256 with `shasum -a 256`. VoiceOver on the menu-bar window is part of owner step O12, not repeated here. +- [ ] 1. Click the menu-bar icon, then drag a file from Finder onto "Drop files to send". Pass: the window shows the code, a QR and Copy Code, the CLI receives the file, and its SHA-256 equals the source's. Also try dropping onto the icon itself: expected to do nothing (`../known-issues.md`) +- [ ] 2. Click Choose Files…, pick a file and a folder in the open panel. Pass: the panel stays open until you confirm, the send starts, and the CLI receives both with equal SHA-256 +- [ ] 3. Copy some text, click Send Clipboard Text. Pass: the CLI receives the text. With an empty clipboard the window says "The clipboard has no text to send." +- [ ] 4. Start a send from the main window. Pass: the menu-bar window shows the same transfer, and its drop zone and buttons are not offered until it ends +- [ ] 5. Settings > General > Show in menu bar off, then on. Pass: the icon disappears and returns without a relaunch +- [ ] 6. Turn Open at login on, log out and back in. Pass: CrocApp starts at login. Then remove it in System Settings > General > Login Items and reopen CrocApp Settings. Pass: the toggle is off +- [ ] 7. If macOS asks for approval (the toggle is on and the setting says to allow CrocApp in Login Items), click Open Settings. Pass: System Settings opens at Login Items; after you allow CrocApp the note disappears + +## Shortcuts actions (R27) +Owner-run on a Mac and on an iPhone, each against the croc CLI (`croc` v11). Hash with `shasum -a 256`; copy a file received on the iPhone to the Mac to hash it. The actions are not verified on a device yet. +- [ ] 1. Mac, Shortcuts app: run "Send Files with CrocApp" with a file. Pass: CrocApp comes to the front, the action returns the code and shows "The code is X. Keep CrocApp open until the transfer ends.", `croc --yes ` receives the file, and the SHA-256 equals the source's +- [ ] 2. iPhone: same as 1. Pass: the same, with CrocApp in the foreground for the whole transfer +- [ ] 3. Mac and iPhone: run "Send Text with CrocApp" with a short text. Pass: the code comes back and `croc --yes ` prints the text +- [ ] 4. Mac and iPhone: start `croc send ` on the CLI and run "Receive with CrocApp" with its code. Pass: the incoming prompt appears in the app (or is skipped with Auto-accept on), the action waits for the end, returns the file, shows "Received 1 item.", and the SHA-256 equals the source's +- [ ] 5. Receive with a `crocapp.dev/r/#` link and with a Send as link link. Pass: both work as in 4 +- [ ] 6. Receive a text sent with `croc send --text hi`. Pass: the action returns a file named "Received text.txt" +- [ ] 7. Receive a file whose name already exists in the receive folder, once with Keep Both and once with Replace. Pass: the returned file is the new copy (`name (1).ext` for Keep Both) +- [ ] 8. Start a transfer, then run any of the three actions. Pass: it fails with "CrocApp is already sending or receiving. Wait for it to finish." +- [ ] 9. Run "Send files with CrocApp" and "Receive with CrocApp" from Siri and Spotlight. Pass: each starts the action +- [ ] 10. Mac: build a shortcut that uses "Send Files with CrocApp" and enable it as a Quick Action in Finder (shortcut details > Use as Quick Action). Pass: it appears in Finder's Quick Actions for selected files and returns the code. Inferred to work, never run + +## iPad File menu (R28) +Owner-run on a real iPad with iPadOS 26 and a keyboard or the pointer to open the menu bar. +- [ ] 1. Open the menu bar, File. Pass: Send… (⌘1), Receive… (⌘2) and Show Receive Folder in Files (⇧⌘R) are listed +- [ ] 2. After a receive, choose Show Receive Folder in Files, or press ⇧⌘R. Pass: the Files app opens at the CrocApp receive folder. Record what happens when the receive folder is a user-picked folder from a provider such as iCloud Drive: the `shareddocuments://` scheme resolves local paths only, so a silent no-op is expected there (see "Files app visibility") + ## 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 4766b1c9..70516bf9 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..F43 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..F44 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`). @@ -35,6 +35,15 @@ Status is verified against the code, not against intent. **Shipped** means the f | F18 | Auto-accept toggle (off by default, warning shown) | `--yes` | shipped | | F19 | Both-sides confirm | `--ask` | shipped | | F41 | Connection: Automatic or Relay only (sender-side; a receive follows) | `--transport auto\|relay` | shipped | +| F20 | SOCKS5 proxy (Settings > Advanced) | `--socks5` | shipped | +| F21 | HTTP proxy | `--connect` | shipped | +| F22 | Upload speed limit (sender-side) | `--throttleUpload` | shipped | +| F23 | Curve choice (the receiver's wins; the sender adopts it) | `--curve` | shipped | +| F24 | Hash algorithm choice (sender-side) | `--hash` | shipped | +| F25 | Sender IP, direct connect (receive-side) | `--ip` | shipped | +| F26 | Custom multicast address (macOS only: iOS has no multicast entitlement) | `--multicast` | shipped | +| F27 | Base port, parallel connections, multiplexing toggle (sender-side) | `--port --transfers --no-multi` | shipped | +| F28 | Built-in DNS resolver | `--internal-dns` | shipped | ### GUI-native @@ -45,28 +54,20 @@ Status is verified against the code, not against intent. **Shipped** means the f | 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 | +| F31 | macOS menu bar quick send (R26): `MenuBarExtra` window with a drop zone, Choose Files… and Send Clipboard Text, then the code, QR and progress of the one shared transfer; generated codes only; settings Show in menu bar (default on) and Open at login (default off, `SMAppService`). Verified by unit tests and `verify-app-mac.sh` scenarios 10 and 11; the owner's Mac checks are in `device-test-checklist.md` | shipped, macOS only | +| F34 | Shortcuts and Siri actions (R27, App Intents): Send Files, Send Text and Receive with CrocApp, also offered as App Shortcuts. They run in the foreground; the send actions return the code, Receive returns the files. Not verified on a device; the Finder Quick Action is inferred (`device-test-checklist.md`) | shipped | +| F44 | iPad File menu (R28): Show Receive Folder in Files (⇧⌘R) beside Send… and Receive…. The Files hand-off is not verified on a device | shipped, iPadOS only | The whole V1 set is implemented. What stands between it and release is in `../known-issues.md` under "Blocking release", not in this table. ## V1.x (approved, post-V1 fast follow) -None started. The engine already exposes several of these from Go (`RelayPorts`, `Curve`, `HashAlgorithm`, `ThrottleUpload`, `NoMultiplexing`); wiring them means plumbing `EngineOptions` in CrocKit first (`crocmobile-bridge.md`). +The Advanced options (F20-F28) are in the power options table above: `EngineOptions` carries them, `AppSettings` stores them (the proxies in the Keychain), `Views/AdvancedSettingsView.swift` edits them (`app-ui-architecture.md`, "Advanced settings (R25)"). The menu bar quick send (F31) and App Intents (F34) shipped and moved to the GUI-native table above (`app-ui-architecture.md`, "Menu bar and Shortcuts (R26-R28)"). Nothing else in this table has been started. | # | Feature | croc flag / origin | |---|---|---| -| F20 | SOCKS5 / Tor proxy | `--socks5` | -| F21 | HTTP proxy | `--connect` | -| F22 | Upload throttle | `--throttleUpload` | -| F23 | Curve choice | `--curve` | -| F24 | Hash algorithm choice | `--hash` | -| F25 | Direct IP connect | `--ip` | -| F26 | Custom multicast address | `--multicast` | -| F27 | Ports/transfers tuning, disable multiplexing | `--port --transfers --no-multi` | -| F28 | Internal DNS resolver | `--internal-dns` | | F29 | Run own relay from Mac app (menu-bar relay server) | `croc relay` | -| F31 | macOS menu bar quick-send (drag-drop) | GUI-native | | F33 | Saved codes / favorite peers | GUI-native | -| F34 | App Intents / Shortcuts ("Send via croc") | GUI-native | | F35 | Relay health check + diagnostics screen | GUI-native | ## Later diff --git a/docs/knowledge/screenshots.md b/docs/knowledge/screenshots.md index f7d0e361..cc868a5e 100644 --- a/docs/knowledge/screenshots.md +++ b/docs/knowledge/screenshots.md @@ -10,7 +10,7 @@ The app is driven by the DEBUG-only flags in `AutoVerify.swift`, not by clicking | Flag | Does | |---|---| -| `--screen ROUTE` | pushes `AppRouter.shared.path` on launch. ROUTE is one of `home`, `send`, `receive`, `settings`, `howItWorks`, `history`, `onboarding`; `home` and `onboarding` push no path (`onboarding` forces the first-run sheet instead). Without the flag the harness runs the transfer while the UI sits on HomeView | +| `--screen ROUTE` | pushes `AppRouter.shared.path` on launch. ROUTE is one of `home`, `send`, `receive`, `settings`, `advanced` (Settings with the Advanced page pushed, iOS), `howItWorks`, `history`, `onboarding`; `home` and `onboarding` push no path (`onboarding` forces the first-run sheet instead). Without the flag the harness runs the transfer while the UI sits on HomeView | | `--stage PATH` | preloads the Send screen's file list, repeatable. Only takes effect with `--screen send` | | `--seed-history` | writes sample `TransferRecord`s into the in-memory store | | `--hold` | parks at the incoming prompt instead of auto-accepting | diff --git a/docs/known-issues.md b/docs/known-issues.md index ca950f2b..eab4681b 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -14,6 +14,8 @@ Engine and bridge invariants live in `knowledge/crocmobile-bridge.md`; UI state- - **`verify-interop.sh` can still lose the receiver-first race.** Its scenarios start the receiver after a fixed `sleep 3`, and croctest has no ready marker; on 2026-10-13 a public-pool run failed `relay 1: wrapper custom-code send` with `flate: corrupt input before offset 6` on the CLI receiver, and the retry passed. Port a ready signal from croctest (for example its `EVENT code` line) if it recurs. - **`verify-app-mac.sh` exits with no `FAIL:` line when a CLI receive fails** (`recv_from_app`, under `set -e`). Its log is still in `$TMPDIR`. `verify-share-sim.sh` and `verify-interop.sh` already report this case. +- **No harness proves a LAN-discovery transfer on a custom multicast address.** croc drops discovery packets from its own host's addresses, so two peers on one machine never find each other (CLI to CLI included). `verify-interop.sh` scenario 15 instead listens on the group and asserts what each side announces. A real two-device check on a shared non-default group is not done. +- **A delegate panic in `OnCodeReady` leaves a text send's temp file behind.** `startSession`'s uncommitted cleanup removes the zip, cwd and lock but not `tempPath` (`crocmobile/session.go`). Found by `TestNetworkGlobalsRestoredAfterPanic`; harmless in the app (the file sits in the temporary directory), not fixed in B19. - **`BUILDING.md` §5's `croc relay` runs whichever `croc` is first on `PATH`.** On a machine set up with `CROC_V10` at `~/go/bin/croc` ahead of Homebrew, that is v10, which rejects `--source-join-limit`, and every scenario then fails `connection refused`. Run `"$(brew --prefix)/bin/croc" relay ...` there. - **On the development Mac, `croc --transport derp` CLI to CLI does not complete.** 130 MiB failed twice with `DERP connection failed: peer connection: dial Tailcat streams: context deadline exceeded` after 10 s, while all four `*.ipn.dev` DERP hosts answered HTTPS (200). Cause unknown; both peers on one host is a suspect, not proven. On a physical iPhone `derp` completed (owner, 2026-10-14, `knowledge/device-test-checklist.md`, "Transport and Tailcat gate" (d)), so Tailcat's data path works on iOS and the gate passed. Under `auto` on the Mac the relay carries the data, so transfers succeed, and `verify-interop.sh` scenario 13 and `verify-app-mac.sh` scenario 8 only assert the `tailcat-offer`, not that Tailcat carried the bytes. @@ -73,3 +75,5 @@ Engine and bridge invariants live in `knowledge/crocmobile-bridge.md`; UI state- - **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`. - **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. +- **The menu-bar window cannot take a drop on the icon itself.** `MenuBarQuickSendView`'s drop zone exists only while the window is open, so a user has to click the icon first and then drop. A `MenuBarExtra` has no drop target on its status item. Accepted: the window opens with one click and the "Choose Files…" button needs no drag. +- **A Shortcuts send action returns at the code, not at the end of the transfer.** `SendFilesIntent` and `SendTextIntent` finish when `awaitCode()` sees `.waiting`, so a shortcut that goes on to quit CrocApp, or a user who closes it, ends the transfer before the receiver has the files. The dialog says "Keep CrocApp open until the transfer ends." Accepted: the code has to reach the receiver while the sender is still waiting, so the action cannot wait for the end. Receive does wait for the end. Neither the actions nor the Finder Quick Action have been run on a device (`knowledge/device-test-checklist.md`, "Shortcuts actions (R27)"). diff --git a/scripts/verify-app-mac.sh b/scripts/verify-app-mac.sh index f40ed7d0..9aa393c2 100755 --- a/scripts/verify-app-mac.sh +++ b/scripts/verify-app-mac.sh @@ -311,3 +311,96 @@ case "$got" in esac [ ! -e "$DOCS/storeme.bin" ] || fail "revoked upload wrote a file (app)" echo MAC-STORE-OK + +# --- 10: menu-bar quick send, files and clipboard text (R26) ----------------- +# --auto-menubar-send and --auto-menubar-clipboard start the send through +# MenuBarQuickSend, the view model behind the menu-bar window. The code is +# generated, so the app writes it to verify-code.txt for the CLI receiver. +rm -f "$RESULT" "$HISTORY" "$DOCS/verify-code.txt" +echo "mac menubar send $$" > "$DOCS/menubar.txt" +run_app --auto-menubar-send "$DOCS/menubar.txt" > "$TMPROOT/mac-app-menubar.log" 2>&1 +await_ready +C=$(cat "$DOCS/verify-code.txt") +recv_from_app menubar "$DOCS/menubar.txt" "$TMP/d10" + +# The pasteboard is the user's: put it back afterwards. +SAVED_PASTE="$TMP/pasteboard.txt" +pbpaste > "$SAVED_PASTE" +rm -f "$RESULT" "$HISTORY" "$DOCS/verify-code.txt" +CLIP="mac menubar clipboard $$" +printf '%s' "$CLIP" | pbcopy +run_app --auto-menubar-clipboard > "$TMPROOT/mac-app-menubar-clip.log" 2>&1 +await_ready +C=$(cat "$DOCS/verify-code.txt") +mkdir -p "$TMP/d10t" +( cd "$TMP/d10t" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin --yes ) \ + > "$TMPROOT/mac-cli-menubar-clip.log" 2>&1 +await_app 30 +pbcopy < "$SAVED_PASTE" +check_result menubar-clipboard +grep -qF "$CLIP" "$TMPROOT/mac-cli-menubar-clip.log" || fail "menu-bar clipboard text did not reach the CLI" +rm -f "$DOCS/menubar.txt" +echo MAC-MENUBAR-OK + +# --- 11: Open at login matches the system status (R26) ----------------------- +# The check registers the app's login item and unregisters it again, so a +# stray item survives only if the run is killed between the two (a login item +# already present before the run is left unregistered). The status +# values are SMAppService.Status raw values: 0 notRegistered, 1 enabled, +# 2 requiresApproval. +rm -f "$RESULT" +run_app --auto-login-item > "$TMPROOT/mac-app-login.log" 2>&1 +await_file "$RESULT" 30 || fail "login item check wrote no result" +kill "$APP_PID" 2>/dev/null || true +wait "$APP_PID" 2>/dev/null || true +got=$(cat "$RESULT") +echo "login item result: $got" +case "$got" in + "ok before="?" registered=1 toggleOn=true after=0 toggleOff=true") ;; + "ok before="?" registered=2 toggleOn=true after=0 toggleOff=true") ;; + *) fail "login item: status did not follow register/unregister, got '$got'" ;; +esac +echo MAC-LOGIN-OK + +# --- 12: the Shortcuts actions against the CLI (R27) ------------------------- +# Shortcuts cannot be driven from a script, so --auto-intent calls each +# action's perform() directly (the value rides behind --intent-arg: two bare +# arguments in a row make AppKit skip the window). The send actions return their code, which the +# app writes to verify-code.txt; Receive saves into the app's receive folder +# (the real ~/Downloads/CrocApp), so the file gets a unique name and is +# removed afterwards. +rm -f "$RESULT" "$HISTORY" "$DOCS/verify-code.txt" +echo "mac intent file $$" > "$DOCS/intentsend.txt" +run_app --auto-intent send-files --intent-arg "$DOCS/intentsend.txt" > "$TMPROOT/mac-app-intent-files.log" 2>&1 +await_ready +C=$(cat "$DOCS/verify-code.txt") +recv_from_app intent-send-files "$DOCS/intentsend.txt" "$TMP/d12" + +rm -f "$RESULT" "$HISTORY" "$DOCS/verify-code.txt" +INTENT_TEXT="mac intent text $$" +run_app --auto-intent send-text --intent-arg "$INTENT_TEXT" > "$TMPROOT/mac-app-intent-text.log" 2>&1 +await_ready +C=$(cat "$DOCS/verify-code.txt") +mkdir -p "$TMP/d12t" +( cd "$TMP/d12t" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin --yes ) \ + > "$TMPROOT/mac-cli-intent-text.log" 2>&1 +await_app 30 +check_result intent-send-text +grep -qF "$INTENT_TEXT" "$TMPROOT/mac-cli-intent-text.log" || fail "Send Text action: text did not reach the CLI" + +C=$(code intr) +RECV_NAME="intent-recv-$$.txt" +RECV_DIR="$HOME/Downloads/CrocApp" +rm -f "$RESULT" "$HISTORY" +echo "mac intent receive $$" > "$TMP/$RECV_NAME" +( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin send "$RECV_NAME" ) \ + > "$TMPROOT/mac-cli-intent-send.log" 2>&1 & +sleep 3 +run_app --auto-intent receive --intent-arg "$C" > "$TMPROOT/mac-app-intent-recv.log" 2>&1 +await_app 120 +got=$(cat "$RESULT" 2>/dev/null || echo missing) +echo "intent receive result: $got" +diff "$TMP/$RECV_NAME" "$RECV_DIR/$RECV_NAME" || fail "Receive action: file differs" +rm -f "$RECV_DIR/$RECV_NAME" "$DOCS/intentsend.txt" +[ "$got" = "ok success=true files=$RECV_NAME" ] || fail "Receive action: expected the file name back, got '$got'" +echo MAC-INTENT-OK diff --git a/scripts/verify-interop.sh b/scripts/verify-interop.sh index cfb1a4e8..7a323237 100755 --- a/scripts/verify-interop.sh +++ b/scripts/verify-interop.sh @@ -4,13 +4,14 @@ # 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), stored transfers against a local store server -# (R18-R20), and every public pool relay (custom, wrapper-generated and -# CLI-generated codes). +# (R18-R20), the Advanced options that change the wire (R25: curve, hash, ports, +# multicast group, proxies, internal DNS), 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 -# pool, and skips the transport and pool scenarios (CI runs this against a local -# `croc relay`). +# pool, and skips the transport, proxy, internal DNS and pool scenarios (CI runs +# this against a local `croc relay`). set -euo pipefail cd "$(dirname "$0")/.." . scripts/lib.sh @@ -371,8 +372,172 @@ pass "store: existing file with Keep Both and Replace, decline keeps the transfe kill "$STORE_PID" wait "$STORE_PID" 2>/dev/null || true +# 15. Advanced options that change the wire (R25), against the croc v11 CLI with +# the matching flag. What each case pins down was observed with croc v11.5.4: +# - curve: the receiver chooses it and the sender adopts it ("using curve" in +# the CLI sender's --debug log); the sender's own curve is never used, so a +# mismatch is not an error in either direction. +# - hash: the sender chooses it and the receiver adopts it ("using hash +# algorithm" in the CLI receiver's --debug log); -hash imohash is sent as +# imohash-v2. +# - base port and transfers: the sender's local relay listens on the base port +# and the next `transfers` ports, so a receiver given the sender IP reaches +# it there, and the banner it reads lists those data ports. +# - multicast: croc's peer discovery drops packets from the host's own +# addresses, so two peers on one host never find each other. A listener on +# the group sees what each side announces instead. +# The CLI runs with a throwaway config dir, because it remembers its flags. +SCENARIO=15 +CFG15="$TMP/croc-config15" +mkdir -p "$CFG15" +# A pool relay admits 30 connections a minute from one address, and scenario 12 +# spends most of that; see scenario 11. +[ -n "${RELAY:-}" ] || sleep 60 + +free_port() { # a base port with 3 free loopback ports from it + local p k i busy + for (( k = 0; k < 50; k++ )); do + p=$(( 20000 + RANDOM % 9000 )) + busy=0 + for i in 0 1 2; do + ( exec 3<>"/dev/tcp/127.0.0.1/$((p + i))" ) 2>/dev/null && busy=1 + done + [ "$busy" = 0 ] && { echo "$p"; return 0; } + done + return 1 +} + +adv_ws() { # adv_ws NAME FILE CLI_FLAGS CT_FLAGS: wrapper sends, CLI receives + local name=$1 file=$2 cli_flags=$3 ct_flags=$4 dir="$TMP/15$1" C + C=$(code "$name") + mkdir -p "$dir" + # shellcheck disable=SC2086 # a flag string, split on purpose + timeout 90 "$CT" send ${CT_R:+"$CT_R"} -no-multi $ct_flags -code "$C" "$TMP/src/$file" > "$TMP/15$name.log" 2>&1 & + sleep 3 + # shellcheck disable=SC2086 + ( cd "$dir" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 60 "$CROC" ${CLI_R:+"$CLI_R"} --debug --yes --overwrite $cli_flags ) > "$TMP/15$name.cli" 2>&1 || true + wait + cmp -s "$TMP/src/$file" "$dir/$file" || fail "$name: wrapper send, CLI receive: bytes differ" +} + +adv_sw() { # adv_sw NAME FILE CLI_GLOBAL CLI_SEND CT_FLAGS: CLI sends, wrapper receives + local name=$1 file=$2 cli_global=$3 cli_send=$4 ct_flags=$5 dir="$TMP/15$1" C + C=$(code "$name") + mkdir -p "$dir" + # shellcheck disable=SC2086 + ( cd "$TMP/src" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 90 "$CROC" ${CLI_R:+"$CLI_R"} --debug --disable-clipboard --ignore-stdin $cli_global send --no-multi $cli_send "$file" ) > "$TMP/15$name.cli" 2>&1 & + sleep 3 + # shellcheck disable=SC2086 + timeout 60 "$CT" receive ${CT_R:+"$CT_R"} -out "$dir" -answer y $ct_flags "$C" > "$TMP/15$name.log" 2>&1 || true + wait + cmp -s "$TMP/src/$file" "$dir/$file" || fail "$name: CLI send, wrapper receive: bytes differ" +} + +# 15 curve. Sender and receiver agree: the transfer succeeds. They disagree: it +# still succeeds, in both directions, and the receiver's curve is the one used. +adv_ws cv1 a.txt "--curve siec" "-curve siec" +pass "curve: wrapper sends -curve siec, CLI receives --curve siec" +adv_sw cv2 a.txt "--curve siec" "" "-curve siec" +grep -q "using curve siec" "$TMP/15cv2.cli" || fail "curve: CLI sender did not use the wrapper receiver's siec" +pass "curve: CLI sends --curve siec, wrapper receives -curve siec" +adv_ws cv3 a.txt "--curve p384" "-curve siec" +pass "curve mismatch: wrapper sends -curve siec, CLI receives --curve p384, still succeeds" +adv_sw cv4 a.txt "--curve siec" "" "-curve p384" +grep -q "using curve p384" "$TMP/15cv4.cli" || fail "curve mismatch: the receiver's curve was not the one used" +pass "curve mismatch: CLI sends --curve siec, wrapper receives -curve p384, succeeds on the receiver's curve" + +# 15 hash. The receiver takes the sender's algorithm whatever it is set to. +adv_ws hs1 big.bin "" "-hash imohash" +grep -q "using hash algorithm: imohash-v2" "$TMP/15hs1.cli" || fail "hash: CLI receiver did not adopt the wrapper's imohash" +pass "hash: wrapper sends -hash imohash, CLI receives it as imohash-v2" +adv_ws hs2 big.bin "" "-hash md5" +grep -q "using hash algorithm: md5" "$TMP/15hs2.cli" || fail "hash: CLI receiver did not adopt the wrapper's md5" +pass "hash: wrapper sends -hash md5, CLI receives md5" +adv_sw hs3 big.bin "" "--hash md5" "" +pass "hash: CLI sends --hash md5, wrapper receives" +adv_sw hs4 big.bin "" "--hash md5" "-hash highway" +pass "hash mismatch: CLI sends --hash md5, wrapper receives with -hash highway, still succeeds" + +# 15 base port and transfers, with the sender IP. -only-local keeps the relay +# out of it: the receiver can reach the sender only at the base port it was given. +P=$(free_port) || fail "ports: no free port range" +C=$(code pt1) +mkdir -p "$TMP/dst15pt1" +timeout 90 "$CT" send -only-local -port "$P" -transfers 2 -code "$C" "$TMP/src/big.bin" > "$TMP/15pt1.log" 2>&1 & +sleep 3 +( cd "$TMP/dst15pt1" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 60 "$CROC" --debug --yes --overwrite --ip "127.0.0.1:$P" ) > "$TMP/15pt1.cli" 2>&1 || true +wait +cmp -s "$TMP/src/big.bin" "$TMP/dst15pt1/big.bin" || fail "ports: wrapper send -port $P, CLI receive --ip: bytes differ" +grep -q "banner: $((P + 1)),$((P + 2))$" "$TMP/15pt1.cli" || fail "ports: banner is not the two data ports after $P" +pass "ports: wrapper sends -port $P -transfers 2, CLI receives --ip 127.0.0.1:$P over those ports" + +P=$(free_port) || fail "ports: no free port range" +C=$(code pt2) +mkdir -p "$TMP/dst15pt2" +( cd "$TMP/src" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 90 "$CROC" --debug --disable-clipboard --ignore-stdin --local send --port "$P" --transfers 2 big.bin ) > "$TMP/15pt2.cli" 2>&1 & +sleep 3 +timeout 60 "$CT" receive -ip "127.0.0.1:$P" -out "$TMP/dst15pt2" -answer y "$C" > "$TMP/15pt2.log" 2>&1 || true +wait +cmp -s "$TMP/src/big.bin" "$TMP/dst15pt2/big.bin" || fail "ports: CLI send --port $P, wrapper receive -ip: bytes differ" +pass "ports: CLI sends --port $P --transfers 2, wrapper receives -ip 127.0.0.1:$P" + +# A sender IP with nothing listening fails at once, instead of falling back to a relay. +P=$(free_port) || fail "ports: no free port range" +timeout 30 "$CT" receive -ip "127.0.0.1:$P" -out "$TMP/dst15pt3" -answer y "$(code pt3)" > "$TMP/15pt3.log" 2>&1 && fail "ports: receive from a dead sender IP exited 0" +grep -q 'EVENT error {"code":"relayUnreachable"' "$TMP/15pt3.log" || fail "ports: dead sender IP: not relayUnreachable" +pass "ports: a sender IP with nothing listening fails with relayUnreachable" + +# 15 multicast. Each side announces on the group it was given: a sender +# "croc", a receiver "ok". The CLI cases show the listener sees what +# real croc sends, and the last case that another group stays unheard. +MC_GROUP=239.255.42.99 +MC_OTHER=239.255.42.98 +mc_listen() { # mc_listen GROUP LOG: join for 6 s in the background + "$CT" multicast-listen "$1" 6 > "$2" 2>&1 & + MC_PID=$! + sleep 1 +} +mc_heard() { # mc_heard LOG PAYLOAD LABEL + wait "$MC_PID" || fail "multicast: listener failed ($3)" + grep -qx "MULTICAST-PAYLOAD $2" "$1" || fail "multicast: $3: group did not carry '$2'" +} +if "$CT" multicast-listen "$MC_GROUP" 1 > "$TMP/15mcprobe.log" 2>&1; then + P=$(free_port) || fail "multicast: no free port range" + C=$(code mc1) + mc_listen "$MC_GROUP" "$TMP/15mc1.listen" + timeout 4 "$CT" send -only-local -port "$P" -multicast "$MC_GROUP" -code "$C" "$TMP/src/a.txt" > "$TMP/15mc1.log" 2>&1 || true + mc_heard "$TMP/15mc1.listen" "croc$P" "wrapper sender" + pass "multicast: wrapper -multicast $MC_GROUP announces croc$P on that group" + + C=$(code mc2) + mc_listen "$MC_GROUP" "$TMP/15mc2.listen" + timeout 4 "$CT" receive -only-local -multicast "$MC_GROUP" -out "$TMP/dst15mc" -answer y "$C" > "$TMP/15mc2.log" 2>&1 || true + mc_heard "$TMP/15mc2.listen" "ok" "wrapper receiver" + pass "multicast: wrapper receive -multicast $MC_GROUP looks for senders on that group" + + C=$(code mc3) + mc_listen "$MC_GROUP" "$TMP/15mc3.listen" + ( cd "$TMP/src" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 4 "$CROC" --disable-clipboard --ignore-stdin --local --multicast "$MC_GROUP" send --port "$P" a.txt ) > "$TMP/15mc3.cli" 2>&1 || true + mc_heard "$TMP/15mc3.listen" "croc$P" "CLI sender" + C=$(code mc4) + mc_listen "$MC_GROUP" "$TMP/15mc4.listen" + CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 4 "$CROC" --disable-clipboard --local --multicast "$MC_GROUP" --yes --out "$TMP/dst15mc" > "$TMP/15mc4.cli" 2>&1 || true + mc_heard "$TMP/15mc4.listen" "ok" "CLI receiver" + pass "multicast: the CLI announces the same payloads on the same group" + + C=$(code mc5) + mc_listen "$MC_GROUP" "$TMP/15mc5.listen" + timeout 4 "$CT" send -only-local -multicast "$MC_OTHER" -code "$C" "$TMP/src/a.txt" > "$TMP/15mc5.log" 2>&1 || true + wait "$MC_PID" || fail "multicast: listener failed (mismatch)" + ! grep -q '^MULTICAST-PAYLOAD' "$TMP/15mc5.listen" || fail "multicast mismatch: a sender on $MC_OTHER was heard on $MC_GROUP" + pass "multicast mismatch: a sender on $MC_OTHER is not heard on $MC_GROUP" +else + echo "SKIP: multicast cases (15): this host has no multicast interface to join" +fi + if [ -n "${RELAY:-}" ]; then - echo "ALL INTEROP CHECKS PASSED (relay $RELAY; transport and public pool scenarios skipped)" + echo "SKIP: proxy and internal DNS cases (15): the relay at $RELAY is a local address, which croc's dialer reaches without the proxy" + echo "ALL INTEROP CHECKS PASSED (relay $RELAY; transport, proxy, internal DNS and public pool scenarios skipped)" exit 0 fi @@ -417,6 +582,73 @@ transport_receive relay pass "transport relay: wrapper receives 130 MiB, no Tailcat offer" rm -f "$TMP/huge.bin" "$TMP"/13[sr]-*/huge.bin +# 15 proxies and internal DNS (R25). croc's dialer bypasses proxies for local +# addresses, so these need the public pool: the transfer goes through a croctest +# proxy that counts the connections it tunnels. -no-local keeps the data off the +# LAN. Skipped under RELAY, whose address is local. +SCENARIO=15 +start_proxy() { # start_proxy KIND: sets PROXY_PID and PROXY_ADDR + "$CT" "$1-proxy" > "$TMP/15px-$1.log" 2>&1 & + PROXY_PID=$! + PROXY_ADDR="" + for _ in $(seq 1 50); do + PROXY_ADDR=$(sed -n 's/^PROXY-ADDR //p' "$TMP/15px-$1.log") + [ -n "$PROXY_ADDR" ] && break + sleep 0.2 + done + [ -n "$PROXY_ADDR" ] || fail "$1 proxy did not start" +} +tunnels() { grep -c '^PROXY-TUNNEL' "$TMP/15px-$1.log" || true; } + +proxy_send() { # proxy_send KIND FLAG: wrapper sends through the proxy, CLI receives + local kind=$1 flag=$2 L="$TMP/15px-$1-send" C job + C=$(code "px$kind") + mkdir -p "$L" + start_proxy "$kind" + timeout 100 "$CT" send -no-local -no-multi "$flag" "$PROXY_ADDR" -code "$C" "$TMP/src/a.txt" > "$L.log" 2>&1 & + job=$! + sleep 4 + ( cd "$L" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 80 "$CROC" --debug --yes --overwrite ) > "$L.cli" 2>&1 || true + wait "$job" || true + kill "$PROXY_PID" + wait "$PROXY_PID" 2>/dev/null || true + cmp -s "$TMP/src/a.txt" "$L/a.txt" || fail "$kind proxy: wrapper send: bytes differ" + [ "$(tunnels "$kind")" -ge 1 ] || fail "$kind proxy: wrapper send tunnelled no connection" + pass "$kind proxy: wrapper $flag sends through $(tunnels "$kind") tunnelled connections" +} +proxy_receive() { # proxy_receive KIND FLAG: CLI sends, wrapper receives through the proxy + local kind=$1 flag=$2 L="$TMP/15px-$1-recv" C job + C=$(code "py$kind") + mkdir -p "$L" + start_proxy "$kind" + ( cd "$TMP/src" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 100 "$CROC" --debug --disable-clipboard --ignore-stdin send --no-local --no-multi a.txt ) > "$L.cli" 2>&1 & + job=$! + sleep 4 + timeout 80 "$CT" receive -no-local "$flag" "$PROXY_ADDR" -out "$L" -answer y "$C" > "$L.log" 2>&1 || true + wait "$job" || true + kill "$PROXY_PID" + wait "$PROXY_PID" 2>/dev/null || true + cmp -s "$TMP/src/a.txt" "$L/a.txt" || fail "$kind proxy: wrapper receive: bytes differ" + [ "$(tunnels "$kind")" -ge 1 ] || fail "$kind proxy: wrapper receive tunnelled no connection" + pass "$kind proxy: wrapper $flag receives through $(tunnels "$kind") tunnelled connections" +} +proxy_send socks5 -socks5 +proxy_receive socks5 -socks5 +proxy_send http -connect +proxy_receive http -connect + +# Internal DNS only swaps the resolver for the relay's name; the transfer +# working is all the harness can see of it. +C=$(code dns1) +mkdir -p "$TMP/15dns" +timeout 100 "$CT" send -no-local -no-multi -internal-dns -code "$C" "$TMP/src/a.txt" > "$TMP/15dns.log" 2>&1 & +job=$! +sleep 4 +( cd "$TMP/15dns" && CROC_CONFIG_DIR="$CFG15" CROC_SECRET="$C" timeout 80 "$CROC" --debug --yes --overwrite ) > "$TMP/15dns.cli" 2>&1 || true +wait "$job" || true +cmp -s "$TMP/src/a.txt" "$TMP/15dns/a.txt" || fail "internal DNS: wrapper send over the public pool: bytes differ" +pass "internal DNS: wrapper -internal-dns sends over the public pool" + # 11. public relay pool: every pool relay, both directions, with custom codes # and with wrapper-generated codes forced to each relay, plus one CLI-generated # code. LAN is off on both sides, so a transfer completes only when both sides diff --git a/web/docs/content/guide/advanced-settings.md b/web/docs/content/guide/advanced-settings.md new file mode 100644 index 00000000..e0a9fb08 --- /dev/null +++ b/web/docs/content/guide/advanced-settings.md @@ -0,0 +1,63 @@ +--- +sidebar_position: 5.5 +title: Advanced Settings +description: Proxies, ports, encryption curve, upload speed limit and more, with each option's default, which device it affects, and whether the other device has to match. +--- + +# Advanced settings + +**Advanced** holds the croc options most people never need: proxies, the encryption curve, an upload speed limit, ports and a few more. Every default is croc's own, so you can ignore this page unless you know you need something here. + +On iOS, open **Settings** from the home toolbar and tap **Advanced**. On macOS, open Settings (⌘,) and choose the **Advanced** tab. **Reset to Defaults** at the bottom puts every setting back after a confirmation, and removes the saved proxies. + +Each field checks what you type and shows a message under it. If a saved value is not usable, a transfer fails to start with "An Advanced setting can't be used" instead of falling back to something else. + +## Who has to match + +| Setting | Default | Affects | Other device must match? | +|---|---|---|---| +| Curve | P-256 | Receiving | No, the sender uses the receiver's choice | +| File hash | xxHash | Sending | No, the receiver uses the sender's choice | +| SOCKS5 proxy | None | This device | No | +| HTTP proxy | None | This device | No | +| Built-in DNS resolver | Off | This device | No | +| Multicast address (macOS only) | `239.255.255.250` | Both devices | Yes, the same address, to find each other on the local network | +| Sender IP | None | Receiving | No, but it must be the sender's address and base port | +| Upload speed limit | No limit | Sending | No | +| Base port | 9009 | Sending | A receiver that connects by sender IP must use it | +| Parallel connections | 4 | Sending | No | +| Multiplexing | On | Sending | No, the receiver follows | + +"Sending" means the setting applies when this device sends; the receiving device follows what the sender does. + +## Security + +**Curve** picks the key-exchange curve: P-256 (the default), P-384, P-521, SIEC or Ed25519. The receiver's choice wins: the sender switches to it, so the two devices never have to match, and the setting only matters when this device receives. + +**File hash** picks how the sender fingerprints files so the receiver can check them: xxHash (the default), imohash, MD5 or HighwayHash. Only the sender's choice matters. + +## Network + +**SOCKS5 proxy** and **HTTP proxy** send this device's connections through a proxy. Enter `host:port`, or an address such as `socks5://user:password@host:1080` (SOCKS5: `socks5://` or `socks5h://`; HTTP: `http://` or `https://`). If both are set, the SOCKS5 proxy is used. Connections to devices on your local network skip the proxy. The proxies also apply to [Send as link](stored-transfers.md) uploads, downloads and revokes. + +A proxy address can contain a password, so CrocApp keeps both proxies in the Keychain, never in the settings file, and never shows them in an error message. + +**Built-in DNS resolver** looks up relay names with croc's own resolver instead of the system's. It is off by default and only affects this device. + +**Multicast address** is the group croc uses to find other devices on the local network. Both devices must use the same address, or they will not find each other locally (unless **Local network only** is on, they can still connect through a relay). It must be an IPv4 multicast address, from `224.0.0.0` to `239.255.255.255`. This setting is available on macOS only for now: iOS needs an Apple entitlement for multicast that CrocApp does not have, so iOS always uses the default address. + +**Sender IP** is for receiving. Enter the sender's address and port, such as `192.168.1.20:9009` or `[fe80::1]:9009`, and CrocApp connects straight to the sender and skips relays and discovery. The port is the sender's **Base port**, 9009 unless they changed it. The sender must be reachable at that address, for example on the same network. If nothing answers there, the receive fails at once; it does not fall back to a relay. While it is set, the trust badge on a receive reads "Straight to the sender, no relay in between". + +## Performance + +**Upload speed limit** caps how fast this device sends, in bytes per second: digits with an optional `k`, `m` or `g`, such as `500k`, `2m` or `1g`. Leave it blank for no limit. + +**Base port** and **Parallel connections** set the ports the sender's local relay listens on: the base port, then one more per connection. With the defaults that is 9009 to 9013. The last port must stay at or below 65535. They matter when a receiver connects by sender IP, or when something else on your device already uses those ports. + +**Multiplexing** is on by default. Turning it off sends over one data connection. The receiving device follows. + +## See also + +- [Power options](power-options.md) for relay, connection, compression and confirmation settings. +- [Receiving files](receiving.md) for connecting by sender IP. +- [Settings and trust](settings-and-trust.md) for where settings are stored. diff --git a/web/docs/content/guide/menu-bar.md b/web/docs/content/guide/menu-bar.md new file mode 100644 index 00000000..8f03c1f7 --- /dev/null +++ b/web/docs/content/guide/menu-bar.md @@ -0,0 +1,48 @@ +--- +sidebar_position: 8 +title: Menu Bar +description: Send files and clipboard text from the Mac menu bar, open CrocApp at login, and use the File menu commands on iPad. +--- + +# Menu bar + +## macOS: quick send + +CrocApp puts an icon in the Mac menu bar. Click it to open a small window where you can send without opening the main window. + +| Control | What it does | +|---|---| +| **Drop files to send** | Drop files or folders on this area to send them | +| **Choose Files…** | Opens a file panel; pick files and folders to send | +| **Send Clipboard Text** | Sends the text on the clipboard. If the clipboard has no text, the window says so | + +Dropping works only once the window is open. You cannot drop files onto the menu bar icon itself: click the icon first, then drop. + +Once a send starts, the window shows the code, a QR code, **Copy Code**, progress and **Cancel**, the same as the Send screen. Quick send always uses a generated code; to choose your own, use the main window ([Sending files](sending.md)). + +The window shares the one active transfer with the main window. It shows a transfer you started there, or from [Shortcuts](shortcuts.md), and it refuses to start a new one while another runs. If a receive needs your answer to the incoming-files prompt, the window tells you to open CrocApp, because the prompt lives in the main window. **Open CrocApp** at the bottom brings the main window forward. + +### Settings + +Under **Menu bar** in Settings (⌘,), on the General tab: + +- **Show in menu bar** is on by default. Turn it off to remove the icon. +- **Open at login** is off by default. Turn it on to start CrocApp when you log in. The switch shows what macOS reports, so it follows a change you make in System Settings > General > Login Items. If macOS needs you to approve CrocApp there, the setting says so and an **Open Settings** button takes you to Login Items. + +## iPad: File menu commands + +On iPad, the iPadOS 26 menu bar lists the same File commands as the Mac: + +| Command | Shortcut | +|---|---| +| **Send…** | ⌘1 | +| **Receive…** | ⌘2 | +| **Show Receive Folder in Files** | ⇧⌘R | + +**Show Receive Folder in Files** opens the Files app at your receive folder. On a Mac the same shortcut reveals the folder in Finder. The Files hand-off has not been checked on an iPad yet. + +## See also + +- [Sending files](sending.md) for the full Send screen. +- [Shortcuts](shortcuts.md) for automating sends and receives. +- [Settings and trust](settings-and-trust.md) for where settings live. diff --git a/web/docs/content/guide/power-options.md b/web/docs/content/guide/power-options.md index ec922fe8..fb220ebd 100644 --- a/web/docs/content/guide/power-options.md +++ b/web/docs/content/guide/power-options.md @@ -8,7 +8,7 @@ description: The shipped power-user settings and the croc CLI flag each one maps ![CrocApp Settings on iOS showing the power options under the settings screen](/img/screenshots/ios-settings-light.png#gh-light-mode-only)![CrocApp Settings on iOS showing the power options under the settings screen](/img/screenshots/ios-settings-dark.png#gh-dark-mode-only) -CrocApp exposes a set of power options with sane defaults. On macOS they live in the Settings scene (⌘,); on iOS they're under the settings screen from the home toolbar. All eight below are shipped. +CrocApp exposes a set of power options with sane defaults. On macOS they live in the Settings scene (⌘,); on iOS they're under the settings screen from the home toolbar. All eight below are shipped. The croc options most people never need, such as proxies and ports, are on a separate page: [Advanced settings](advanced-settings.md). | # | Option | Control | croc equivalent | |---|---|---|---| @@ -53,4 +53,4 @@ When on, the sender also has to approve the transfer (via a **Receiver connected ## Not yet built -A further set of croc capabilities (SOCKS5/Tor proxy, HTTP proxy, upload throttling, curve and hash algorithm choice, direct IP connect, custom multicast address, port/transfer tuning, an internal DNS resolver, and running your own relay from the Mac app) is planned for a later release but has not been started. +Running your own relay from the Mac app is planned for a later release but has not been started. The other croc options (proxies, upload throttling, curve and hash choice, sender IP, multicast address, ports and the internal DNS resolver) are under [Advanced settings](advanced-settings.md). diff --git a/web/docs/content/guide/receiving.md b/web/docs/content/guide/receiving.md index f97e4837..94faf19f 100644 --- a/web/docs/content/guide/receiving.md +++ b/web/docs/content/guide/receiving.md @@ -36,6 +36,8 @@ After you tap **Receive** and connect to the sender, CrocApp shows **Incoming tr If the sender has [Confirm on both sides](power-options.md) turned on, the sender must also confirm before the transfer proceeds. +If you know the sender's address, you can connect to it directly with **Sender IP** under [Advanced settings](advanced-settings.md#network). + ## Overwrite and conflicts (F8) CrocApp never overwrites an existing file unless you say so. If any incoming file already exists at the destination, the accept screen shows a banner ("N item(s) already exist. Keep Both saves the new ones under a new name; Replace overwrites them and resumes partly received files.") and replaces **Accept** with two choices, besides **Decline**: @@ -53,4 +55,6 @@ Auto-accept is **off by default**. When off, you always see the file-list previe ## After the transfer -When it finishes, CrocApp shows **Transfer complete** (or a message if something went wrong) with a button to open the destination: **Open in Files** on iOS, **Show in Finder** on macOS. +When it finishes, CrocApp shows **Transfer complete** (or a message if something went wrong) with a button to open the destination: **Open in Files** on iOS, **Show in Finder** on macOS. The File menu also has **Show Receive Folder in Finder** (macOS) and **Show Receive Folder in Files** (iPad), both ⇧⌘R; see [Menu bar](menu-bar.md#ipad-file-menu-commands). + +A [Shortcuts](shortcuts.md) action can receive for you with a code or link and return the files. diff --git a/web/docs/content/guide/sending.md b/web/docs/content/guide/sending.md index bc65bd16..fe3c5031 100644 --- a/web/docs/content/guide/sending.md +++ b/web/docs/content/guide/sending.md @@ -33,6 +33,10 @@ Tap **Send**. CrocApp shows the generated (or custom) code phrase, a **Copy Code 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). +## Menu bar and Shortcuts + +On a Mac, the [menu bar icon](menu-bar.md) sends files or clipboard text without opening the main window. On any device, [Shortcuts](shortcuts.md) can send files and text for you and hand back the code. + ## 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 a58ace9a..b0ed112b 100644 --- a/web/docs/content/guide/settings-and-trust.md +++ b/web/docs/content/guide/settings-and-trust.md @@ -31,6 +31,14 @@ The **Send as link** section holds the **Server** field for [stored transfers](s 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. +## Advanced + +Proxies, ports, the encryption curve and similar options are under **Advanced**; see [Advanced settings](advanced-settings.md). The two proxy addresses can contain a password, so they are kept in the Keychain. + +## Menu bar (macOS) + +The **Menu bar** section of Settings has **Show in menu bar** and **Open at login**; see [Menu bar](menu-bar.md#settings). + ## The trust badge During a waiting, confirming, or transferring screen, CrocApp shows a trust badge: an **End-to-end encrypted** label plus a line describing the relay path actually in use for that transfer: @@ -38,9 +46,10 @@ During a waiting, confirming, or transferring screen, CrocApp shows a trust badg - "Local network only — nothing leaves your network" - "Via custom relay `
` — it sees only encrypted data" - "Via the public croc relay — it sees only encrypted data" +- "Straight to the sender, no relay in between" (a receive with a [sender IP](advanced-settings.md#network) set) This is captured at the moment the transfer starts, so changing settings mid-transfer can't make the badge say something that isn't true for the transfer in progress. ## What is stored -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. +Settings (relay address, relay password, connection, send-as-link server, local-only, compression, zip, exclude patterns, gitignore, both-sides confirm, auto-accept, and the Advanced options) are stored locally on the device. The relay password and the two proxy addresses are in the Keychain. 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/shortcuts.md b/web/docs/content/guide/shortcuts.md new file mode 100644 index 00000000..a48414f3 --- /dev/null +++ b/web/docs/content/guide/shortcuts.md @@ -0,0 +1,44 @@ +--- +sidebar_position: 9 +title: Shortcuts +description: Three CrocApp actions for the Shortcuts app, Siri and Spotlight, to send files, send text and receive, and what each one returns. +--- + +# Shortcuts + +CrocApp adds three actions to the Shortcuts app on iPhone, iPad and Mac. Use them in your own shortcuts, or say or type the phrase in Siri and Spotlight. + +| Action | Phrase | Input | Returns | +|---|---|---|---| +| **Send Files with CrocApp** | "Send files with CrocApp" | Files or folders | The code, as text | +| **Send Text with CrocApp** | "Send text with CrocApp" | Text | The code, as text | +| **Receive with CrocApp** | "Receive with CrocApp" | A code or a link | The received files | + +The actions also show up in the Shortcuts app as ready-made shortcuts, so you can run them without setting anything up. + +## Sending + +Both send actions use a generated code. They finish as soon as croc has a code, not when the transfer ends, and show "The code is X. Keep CrocApp open until the transfer ends." Pass the returned code to a Message, a Copy to Clipboard action or anything else that can reach the receiver. + +**Keep CrocApp open until the receiver finishes.** The sender has to stay online for the whole transfer, and closing CrocApp early ends it. For this reason the actions open CrocApp in the foreground: a transfer can last longer than the 30 seconds iOS gives a background action. + +## Receiving + +**Receive with CrocApp** takes a croc code or any link CrocApp opens: a `crocapp://` link, a `crocapp.dev/r/` link, a `getcroc.com` link, or a [Send as link](stored-transfers.md) link. It waits until the transfer ends, then returns the received files and shows "Received N items." + +Received text comes back as a file named `Received text.txt`. + +The action does not answer the incoming-files prompt. The prompt appears in CrocApp unless **Auto-accept** is on in [Settings](power-options.md). If a received file has the same name as an existing one, the **When a file already exists** setting decides (Keep Both or Replace), and the action returns the new copies. + +## One transfer at a time + +CrocApp runs one transfer at a time. If one is already running, the action fails with "CrocApp is already sending or receiving. Wait for it to finish." + +## Finder Quick Action + +On a Mac, Shortcuts can offer a shortcut as a Quick Action in Finder. A shortcut that uses **Send Files with CrocApp** should work as one, with no extra setup. This has not been checked on a Mac yet. + +## See also + +- [Sending files](sending.md) and [Receiving files](receiving.md) for the same flows in the app. +- [Menu bar](menu-bar.md) for sending from the Mac menu bar. diff --git a/web/docs/content/guide/stored-transfers.md b/web/docs/content/guide/stored-transfers.md index 26e4b85e..8018a64c 100644 --- a/web/docs/content/guide/stored-transfers.md +++ b/web/docs/content/guide/stored-transfers.md @@ -34,7 +34,7 @@ A line under the options names the server the files will go to. While it runs, t 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 relay, **Connection** and **Local network only** settings do not apply to an upload. The SOCKS5 and HTTP proxies under [Advanced settings](advanced-settings.md#network) do apply, to uploads, downloads and revokes. If the server address in [Settings](#the-server-setting) is invalid, the Send screen shows the problem and **Upload** stays disabled. ## The result screen diff --git a/web/docs/content/reference/feature-matrix.md b/web/docs/content/reference/feature-matrix.md index 18a64d0c..c50da218 100644 --- a/web/docs/content/reference/feature-matrix.md +++ b/web/docs/content/reference/feature-matrix.md @@ -37,6 +37,15 @@ Feature numbers (`F1`…) are stable identifiers used across the project. "Shipp | F18 | Auto-accept toggle (off by default, warning shown) | `--yes` | | F19 | Both-sides confirm | `--ask` | | F41 | Connection: Automatic or Relay only (the sender's choice applies, a receive follows it) | `--transport auto\|relay` | +| F20 | SOCKS5 proxy (Settings > Advanced) | `--socks5` | +| F21 | HTTP proxy | `--connect` | +| F22 | Upload speed limit (sender-side) | `--throttleUpload` | +| F23 | Curve choice (the receiver's wins; the sender adopts it) | `--curve` | +| F24 | Hash algorithm choice (sender-side) | `--hash` | +| F25 | Sender IP, direct connect (receive-side) | `--ip` | +| F26 | Custom multicast address (macOS only: iOS has no multicast entitlement) | `--multicast` | +| F27 | Base port, parallel connections, multiplexing toggle (sender-side) | `--port --transfers --no-multi` | +| F28 | Built-in DNS resolver | `--internal-dns` | ## GUI-native (shipped) @@ -47,6 +56,9 @@ Feature numbers (`F1`…) are stable identifiers used across the project. "Shipp | 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 | +| F31 | macOS menu bar quick send: drop or choose files, or send clipboard text, from a menu-bar window; Show in menu bar and Open at login settings (see [Menu bar](../guide/menu-bar.md)) | shipped, macOS only | +| F34 | Shortcuts and Siri actions: Send Files, Send Text and Receive with CrocApp (see [Shortcuts](../guide/shortcuts.md)) | shipped | +| F44 | iPad File menu: Show Receive Folder in Files | shipped, iPad only | ## Planned, not started (V1.x) @@ -54,19 +66,8 @@ None of these are built yet. | # | Feature | croc flag / origin | |---|---|---| -| F20 | SOCKS5 / Tor proxy | `--socks5` | -| F21 | HTTP proxy | `--connect` | -| F22 | Upload throttle | `--throttleUpload` | -| F23 | Curve choice | `--curve` | -| F24 | Hash algorithm choice | `--hash` | -| F25 | Direct IP connect | `--ip` | -| F26 | Custom multicast address | `--multicast` | -| F27 | Ports/transfers tuning, disable multiplexing | `--port --transfers --no-multi` | -| F28 | Internal DNS resolver | `--internal-dns` | | F29 | Run own relay from Mac app (menu-bar relay server) | `croc relay` | -| F31 | macOS menu bar quick-send (drag-drop) | GUI-native | | F33 | Saved codes / favorite peers | GUI-native | -| F34 | App Intents / Shortcuts ("Send via croc") | GUI-native | | F35 | Relay health check + diagnostics screen | GUI-native | ## Later diff --git a/web/docs/content/reference/glossary.md b/web/docs/content/reference/glossary.md index f0c997bd..396543de 100644 --- a/web/docs/content/reference/glossary.md +++ b/web/docs/content/reference/glossary.md @@ -13,13 +13,16 @@ description: User-facing terms used throughout the CrocApp documentation. | Link | A `https://crocapp.dev/r/#` address that carries a code phrase after the `#`. The QR code on the sender's screen and **Share Link** are this link. Opening one fills in **Receive** and never connects by itself. | | Relay | The server both sides connect to when a direct local-network path isn't available. Sees ciphertext only, never the key. | | Room | The rendezvous channel on the relay, derived from the first word of the code phrase. Two codes sharing that word can collide into the same room. | -| Curve | The elliptic curve PAKE runs over. CrocApp uses croc's default. | +| Curve | The elliptic curve PAKE runs over. CrocApp uses croc's default, P-256; the receiver's choice wins and the sender adopts it, so the devices never have to match (see [Advanced settings](../guide/advanced-settings.md)). | +| Sender IP | The sender's address and port, entered on the receiving device to connect straight to it and skip relays. See [Advanced settings](../guide/advanced-settings.md#network). | | Hash algorithm | The integrity check used on transferred files. | | LAN peer discovery | The mechanism that lets same-network transfers skip the relay entirely; not guaranteed to work on every network. | | Connection | The setting that chooses how a send carries large files: Automatic (default) or Relay only. Only the sender's choice applies; a receive follows it. | | Tailcat | The direct, peer-to-peer path that Automatic can use for a send of 128 MB or more. It is set up through `tailcat.dev` and falls back to its own relays when the devices cannot reach each other. Still end-to-end encrypted. | | Local-only | A setting that forces a transfer to stay on the local network, refusing the relay if a direct path isn't available. | | Compression | File data is compressed by default in transit; it can be turned off. | +| Menu bar quick send | The CrocApp icon in the Mac menu bar. Its window sends files or clipboard text and shows the code and progress. See [Menu bar](../guide/menu-bar.md). | +| Shortcuts action | One of three CrocApp actions (Send Files, Send Text, Receive) for the Shortcuts app, Siri and Spotlight. See [Shortcuts](../guide/shortcuts.md). | | Trust badge | The on-screen indicator showing that a transfer is end-to-end encrypted and which relay path it's using. | | Transfer history | CrocApp's local, on-device-only log of past transfers. |