From faaa1a5ca7e5e64e02f89c7ddb0959f3e058c606 Mon Sep 17 00:00:00 2001 From: Kevin Zhong Date: Tue, 22 Sep 2026 18:14:16 -0500 Subject: [PATCH 1/3] fix: sessions survive outages, tokens stay in the Keychain, scripts time out - Launch: only the server refusing the session signs out. Offline, a timeout, a 5xx, or a captive portal keeps the tokens and shows a "can't reach inklet" screen with Try Again; requests no longer wait for connectivity, so the splash reaches it in about 20 s. - Release builds never write tokens to a plaintext file: a Keychain failure keeps the session in memory for that launch, and a file left by an older release is moved into the Keychain and deleted. - Location: add the hardened-runtime location entitlement; weather is off by default and asks for location only when turned on. - AppleScript context capture runs in an osascript child, so the deadline is real (the process is killed) and nothing blocks the main thread. Photos exports live in one temp folder that is emptied at launch, on each summon, after a send, and on sign-out. - Attachments load off the main thread, capped at 10 MiB each and 50 items per send, with the backend's list of accepted types. - Opt out of automatic and sudden termination: the menu bar app must keep its hot key, Services, and run tracking with no window open. - One API host in InkletPresentationKit (still dev.iminklet.com), overridable at build time with INKLET_API_BASE_URL for the app and the widget. - A 401 on History and Quote/0 signs out like everywhere else; Services keeps what was sent while signed out and opens it after sign-in; the OAuth callback accepts exactly inklet://auth/callback. - Settings shows Accessibility status for highlighted-text capture. Drop the unsupported public.image Services type and the unused widget entitlements file; the README lists the URL scheme actually registered. Tests: 49 -> 71 (session restore, OAuth callback parsing, attachment limits, API host). --- README.md | 42 ++-- Resources/InkletMac.entitlements | 2 + Scripts/build-app.sh | 10 +- Scripts/build-widgets.sh | 2 + Scripts/widget-configuration.sh | 14 ++ Sources/InkletMac/InkletMacApp.swift | 29 +-- Sources/InkletMac/Models/AppModel.swift | 16 +- .../InkletMac/Models/AttachmentRules.swift | 63 ++++++ Sources/InkletMac/Models/HistoryModel.swift | 17 ++ Sources/InkletMac/Services/AppContext.swift | 177 +++++++++++++--- Sources/InkletMac/Services/InkletAPI.swift | 115 ++++++++--- .../InkletMac/Services/SelectionContext.swift | 9 + .../InkletMac/Services/ServicesProvider.swift | 40 +++- Sources/InkletMac/Services/Session.swift | 100 ++++++++- .../InkletMac/Services/SystemSettings.swift | 5 + Sources/InkletMac/Services/TokenStore.swift | 90 +++++--- Sources/InkletMac/Views/ComposerView.swift | 77 ++++--- Sources/InkletMac/Views/HomeView.swift | 4 +- Sources/InkletMac/Views/LoginView.swift | 49 +++++ Sources/InkletMac/Views/SettingsView.swift | 32 ++- .../InkletPresentationKit/InkletServer.swift | 26 +++ .../VirtualDisplayCore.swift | 2 +- .../InkletMacTests/AttachmentRulesTests.swift | 76 +++++++ .../InkletMacTests/SessionRestoreTests.swift | 194 ++++++++++++++++++ .../InkletMacTests/WebAuthCallbackTests.swift | 53 +++++ .../InkletServerTests.swift | 30 +++ WidgetExtension/Info.plist | 2 + WidgetExtension/InkletMac.entitlements | 10 - 28 files changed, 1127 insertions(+), 159 deletions(-) create mode 100644 Sources/InkletMac/Models/AttachmentRules.swift create mode 100644 Sources/InkletPresentationKit/InkletServer.swift create mode 100644 Tests/InkletMacTests/AttachmentRulesTests.swift create mode 100644 Tests/InkletMacTests/SessionRestoreTests.swift create mode 100644 Tests/InkletMacTests/WebAuthCallbackTests.swift create mode 100644 Tests/InkletPresentationKitTests/InkletServerTests.swift delete mode 100644 WidgetExtension/InkletMac.entitlements diff --git a/README.md b/README.md index 3401b94..650c2f3 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Useful environment variables: | `INKLET_TEAM_ID` | Required when the identity string does not end with the Team ID | | `INKLET_APP_GROUP` | Overrides the App Group derived from the Team ID | | `INKLET_RELEASE=1` | Compiles with `-DINKLET_RELEASE` | +| `INKLET_API_BASE_URL` | API host for the app and the widget, written to both Info.plists as `InkletAPIBaseURL`; must be `https://`. Unset, both use the default in `Sources/InkletPresentationKit/InkletServer.swift` (`https://dev.iminklet.com`) | | `INKLET_OUTPUT_DIR` | Output directory, default `build/` | Ad-hoc builds use per-app widget storage. Only signed builds share live data @@ -83,14 +84,20 @@ renders every widget in light and dark appearance for visual review. Pushing an annotated `v*` tag runs `.github/workflows/release.yml`: -1. create a draft GitHub Release from the tag message; -2. build the universal app, sign it and the widget with Developer ID; -3. create and sign `dist/inklet-portal--mac-universal.dmg`; -4. notarize with Apple, staple the ticket, upload the versioned disk image and +1. check the tag: `vMAJOR.MINOR.PATCH` with an optional pre-release suffix + (`-alpha.N`, `-beta.N`). A stable tag must point at a commit on `main`; + pre-release tags may come from any branch; +2. create a draft GitHub Release from the tag message; +3. check out the tagged commit, run the tests, build the universal app, sign it + and the widget with Developer ID; +4. create and sign `dist/inklet-portal--mac-universal.dmg`; +5. notarize with Apple, staple the ticket, upload the versioned disk image and a stable `inklet-portal-macOS.dmg`; -5. publish the Release. +6. publish the Release. -`workflow_dispatch` can rebuild the macOS assets for an existing Release. +`workflow_dispatch` rebuilds the macOS assets for an existing Release from its +tag: it takes the tag, builds that tag's commit, and derives the version from +the tag name. Required repository secrets: @@ -113,9 +120,10 @@ a universal ad-hoc build on every pull request and push to `main`. | Launch at login | `SMAppService.mainApp`; the toggle reads the system's status, and a "requires approval" state links to System Settings | | Show in Dock | Switches the activation policy between regular and accessory. The menu bar item is always present, so the app stays reachable with the Dock icon off | | Notifications | `UNUserNotificationCenter`: a card on its way, a failed send, a display that went offline. Permission is requested the first time an alert is turned on | -| Weather on Home | CoreLocation for an approximate position, conditions from Open-Meteo (no key, no WeatherKit entitlement) | +| Weather on Home | Off by default. CoreLocation for an approximate position, asked for when the setting is turned on; conditions from Open-Meteo (no key, no WeatherKit entitlement). The hardened runtime needs `com.apple.security.personal-information.location` in `Resources/InkletMac.entitlements` | +| Highlighted text | The composer's selected-text suggestion needs Accessibility access. Settings shows whether it is granted, asks once, then links to Privacy & Security → Accessibility | -All four need a packaged `.app`; under `swift run` they show as unavailable. +The first four need a packaged `.app`; under `swift run` they show as unavailable. ## Automatic updates @@ -146,7 +154,8 @@ ID build. ## Backend dependencies -The app talks to `https://dev.iminklet.com` with an inklet user access token: +The app and the widget talk to `https://dev.iminklet.com` (`INKLET_API_BASE_URL` +overrides it at build time) with an inklet user access token: - `/api/app/v1/contents`, `/api/app/v1/analyses`, `/api/app/v1/presentations`: the Content → Analysis → Presentation pipeline. Every send from the composer @@ -166,9 +175,18 @@ The app talks to `https://dev.iminklet.com` with an inklet user access token: [docs/virtual-displays.md](docs/virtual-displays.md). Identifiers that must stay stable across releases: bundle ID -`com.iminklet.mac`, widget bundle ID `com.iminklet.mac.widgets`, URL schemes -`inklet` and `inklet-mac`, and the App Group derived in -`Scripts/widget-configuration.sh`. +`com.iminklet.mac`, widget bundle ID `com.iminklet.mac.widgets`, the App Group +derived in `Scripts/widget-configuration.sh`, and two URL schemes: + +- `inklet-mac`, the only scheme the app registers (`CFBundleURLTypes`). Widget + clicks open `inklet-mac://send`, `inklet-mac://activity` and + `inklet-mac://display/`. +- `inklet`, which the app does not register. Google and Apple sign-in end at the + portal's desktop callback page, which redirects to + `inklet://auth/callback?…`; `ASWebAuthenticationSession` claims the scheme + for the length of that sign-in only. The widget link parsers also accept + `inklet:` (iOS's registered scheme, in code shared with it), but macOS only + delivers the scheme the app registers. ## Related repositories diff --git a/Resources/InkletMac.entitlements b/Resources/InkletMac.entitlements index ee39e52..daed545 100644 --- a/Resources/InkletMac.entitlements +++ b/Resources/InkletMac.entitlements @@ -4,6 +4,8 @@ com.apple.security.automation.apple-events + com.apple.security.personal-information.location + com.apple.security.application-groups group.com.iminklet.portal diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index 4b2e357..20ebbbe 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -18,6 +18,7 @@ SPARKLE_FEED_URL="${INKLET_SPARKLE_FEED_URL:-https://raw.githubusercontent.com/i SPARKLE_PUBLIC_KEY="${INKLET_SPARKLE_PUBLIC_KEY:-V8ABE8pxqFcEA8x5OycPwX+42/hPESXcBaReS2HXEPA=}" source "$SCRIPT_DIR/widget-configuration.sh" inklet_configure_widget_group +inklet_configure_api_base if [[ -z "${DEVELOPER_DIR:-}" && -d /Applications/Xcode.app/Contents/Developer ]]; then export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer @@ -93,6 +94,10 @@ if [[ -f "$ICON_SOURCE" ]]; then rm -rf "$(dirname "$ICONSET")" fi +# Automatic and sudden termination are both off. With "Show in Dock" off and +# no window open the app looks idle to macOS, but it is the global shortcut, +# the Services receiver, the runs being followed and their notifications; the +# system must not quit it to reclaim memory, or kill it without asking. cat > "$APP/Contents/Info.plist" < @@ -110,6 +115,7 @@ cat > "$APP/Contents/Info.plist" <NSHighResolutionCapable InkletAppGroupIdentifier$INKLET_APP_GROUP InkletWidgetStorageMode$INKLET_WIDGET_STORAGE_MODE + InkletAPIBaseURL$INKLET_API_BASE_URL CFBundleURLTypes CFBundleURLNamecom.iminklet.mac.widgets @@ -133,7 +139,6 @@ cat > "$APP/Contents/Info.plist" <public.utf8-plain-text public.file-url public.url - public.image @@ -142,7 +147,8 @@ cat > "$APP/Contents/Info.plist" <SUEnableAutomaticChecks SUScheduledCheckInterval86400 NSPrincipalClassNSApplication - NSSupportsAutomaticTermination + NSSupportsAutomaticTermination + NSSupportsSuddenTermination PLIST diff --git a/Scripts/build-widgets.sh b/Scripts/build-widgets.sh index c54e66f..81e9207 100644 --- a/Scripts/build-widgets.sh +++ b/Scripts/build-widgets.sh @@ -8,6 +8,7 @@ OUTPUT_DIR="${INKLET_OUTPUT_DIR:-$ROOT_DIR/build}" DERIVED_DATA="${INKLET_WIDGET_DERIVED_DATA:-$OUTPUT_DIR/WidgetDerivedData}" source "$SCRIPT_DIR/widget-configuration.sh" inklet_configure_widget_group +inklet_configure_api_base # Use the full SDK without changing the user's xcode-select preference. if ! xcodebuild -version >/dev/null 2>&1; then @@ -33,6 +34,7 @@ BUILD_ARGS=(-project "$ROOT_DIR/WidgetExtension/InkletWidgets.xcodeproj" CODE_SIGNING_ALLOWED=NO ENABLE_DEBUG_DYLIB=NO "INKLET_APP_GROUP=$INKLET_APP_GROUP" "INKLET_WIDGET_STORAGE_MODE=$INKLET_WIDGET_STORAGE_MODE" + "INKLET_API_BASE_URL=$INKLET_API_BASE_URL" "MARKETING_VERSION=${INKLET_VERSION:-0.2.0}" "CURRENT_PROJECT_VERSION=${INKLET_BUILD_NUMBER:-1}") if [[ -n "${INKLET_ARCHS:-}" ]]; then diff --git a/Scripts/widget-configuration.sh b/Scripts/widget-configuration.sh index cf8aeda..68a1940 100644 --- a/Scripts/widget-configuration.sh +++ b/Scripts/widget-configuration.sh @@ -27,3 +27,17 @@ inklet_configure_widget_group() { return 1 fi } + +# The API host for both bundles, so the widget cannot read frames from a +# different server than the app publishes them to. Empty means the default in +# Sources/InkletPresentationKit/InkletServer.swift, the only place it is written. +inklet_configure_api_base() { + export INKLET_API_BASE_URL="${INKLET_API_BASE_URL:-}" + INKLET_API_BASE_URL="${INKLET_API_BASE_URL%/}" + # The app would quietly fall back to the default on anything else; a build + # that asked for another host should fail instead. + if [[ -n "$INKLET_API_BASE_URL" && ! "$INKLET_API_BASE_URL" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$ ]]; then + echo "INKLET_API_BASE_URL must be an https:// URL with no query or credentials, got: $INKLET_API_BASE_URL" >&2 + return 1 + fi +} diff --git a/Sources/InkletMac/InkletMacApp.swift b/Sources/InkletMac/InkletMacApp.swift index 994cea1..e628fce 100644 --- a/Sources/InkletMac/InkletMacApp.swift +++ b/Sources/InkletMac/InkletMacApp.swift @@ -12,7 +12,11 @@ struct InkletMacApp: App { // menus but also greys out the window's traffic lights, so it's not worth it. // The clean fix is an `AccentColor` asset (app-scoped, leaves traffic lights // alone) — that needs Xcode's actool, so it lands when we move to an Xcode project. - init() { BrandFonts.register() } + init() { + BrandFonts.register() + // Photos exports from a previous run belong to nobody now. + Task.detached(priority: .utility) { AppContext.discardStaleExports() } + } static let mainWindowID = "main" @@ -84,15 +88,15 @@ struct InkletMacApp: App { private struct AppGate: View { @Environment(Session.self) private var session @Environment(AppModel.self) private var model - - /// AppKit keeps `servicesProvider` weakly, so this holds the only reference. - @State private var services: ServicesProvider? + @Environment(WidgetRouter.self) private var widgetRouter var body: some View { Group { switch session.state { case .restoring: SplashView() + case .unreachable: + UnreachableView() case .signedOut: LoginView() case .signedIn: @@ -101,7 +105,11 @@ private struct AppGate: View { } .task { DockVisibility.install() - await session.restore() + ServicesProvider.install(model: model, session: session) { widgetRouter.openMainWindow?() } + // Only a launch checks the stored session. This view goes away with + // its window and comes back with the next one, and a session that + // is already settled must not be put through the check again. + if session.state == .restoring { await session.restore() } } .onChange(of: session.state) { _, state in switch state { @@ -110,12 +118,12 @@ private struct AppGate: View { model.account = Account(dto: user) Task { await model.load() } installHotKey() - installServices() + ServicesProvider.installed?.deliverPending() case .signedOut: model.reset() ComposerPanelController.shared.hide() ShortcutStore.shared.deactivate() - case .restoring: + case .restoring, .unreachable: break } } @@ -126,11 +134,4 @@ private struct AppGate: View { private func installHotKey() { ShortcutStore.shared.activate { model.toggleComposer() } } - - private func installServices() { - guard services == nil else { return } - let provider = ServicesProvider(model: model) - provider.install() - services = provider - } } diff --git a/Sources/InkletMac/Models/AppModel.swift b/Sources/InkletMac/Models/AppModel.swift index 69d66f4..9475590 100644 --- a/Sources/InkletMac/Models/AppModel.swift +++ b/Sources/InkletMac/Models/AppModel.swift @@ -15,6 +15,7 @@ final class AppModel { virtualDisplays = VirtualDisplayController { path, method, body, headers in try await InkletAPI.shared.virtualDisplayRequest(path, method: method, body: body, headers: headers) } + history.onSessionExpired = { [weak self] in await self?.session?.invalidate() } } var account = Account(username: "", email: "", plan: "free") var devices: [Device] = [] @@ -93,6 +94,7 @@ final class AppModel { WidgetCenter.shared.reloadAllTimelines() account = Account(username: "", email: "", plan: "free") suggestion = nil + AppContext.discardExports() composerTarget = nil composerVirtualTargetID = nil devices = [] @@ -278,7 +280,16 @@ final class AppModel { /// name, and a name that fails to stick is a rename away — the bind is the /// part that must not be lost. An empty name leaves the serial number. func bindQuote0(apiKey: String, serial: String, nickname: String = "") async throws -> Device { - let device = Device(dto: try await InkletAPI.shared.bindQuote0(apiKey: apiKey, serial: serial)) + let dto: DeviceDTO + do { + dto = try await InkletAPI.shared.bindQuote0(apiKey: apiKey, serial: serial) + } catch { + // A dead session ends here the way it does everywhere else; the + // form is left with only the refusals that are about the panel. + if APIError.endsSession(error) { await session?.invalidate() } + throw error + } + let device = Device(dto: dto) if let index = devices.firstIndex(where: { $0.id == device.id }) { devices[index] = device } else { @@ -549,6 +560,9 @@ extension AppModel { /// focus — then goes to fetch its contents in the background. private func captureContext() { suggestion = nil + // The last summon's suggestion is gone, and so are the Photos exports + // behind it; anything already attached was read into memory. + AppContext.discardExports() guard let source = AppContext.frontmost(), source.bundleID != Bundle.main.bundleIdentifier else { return } diff --git a/Sources/InkletMac/Models/AttachmentRules.swift b/Sources/InkletMac/Models/AttachmentRules.swift new file mode 100644 index 0000000..1825633 --- /dev/null +++ b/Sources/InkletMac/Models/AttachmentRules.swift @@ -0,0 +1,63 @@ +import Foundation +import UniformTypeIdentifiers + +/// What one send may carry, from the Content contract: at most 50 Assets, and +/// binary ones no larger than 10 MiB and of a fixed set of types +/// (inklet-backend `internal/sdk/assets.go`, inklet-sdk `src/assets.ts`). +/// Checked when a file is attached, so the composer can name the file that is +/// the problem before anything uploads, instead of relaying a 400 or 413 after. +enum AttachmentRules { + static let maxBytes = 10 * 1024 * 1024 + /// The note travels as an Asset of its own, so attachments get one fewer. + static let maxAttachments = 50 - 1 + + /// The backend's allow-list, exactly. Anything else is refused there as + /// `invalid_asset`. + static let acceptedTypes: Set = [ + "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", + "application/pdf", "text/plain", "text/markdown", "application/json", + ] + + static let tooMany = "One send carries up to \(maxAttachments) attachments." + + /// Why this file can't go, in the words the composer shows; nil if it can. + static func problem(filename: String, contentType: String, byteCount: Int) -> String? { + if !acceptedTypes.contains(contentType) { + return "\(filename) can't be sent. inklet takes PNG, JPEG, GIF, WebP and SVG images, PDFs, and text, Markdown or JSON files." + } + if byteCount == 0 { return "\(filename) is empty." } + if byteCount > maxBytes { return "\(filename) is larger than 10 MB, the most inklet takes for one file." } + return nil + } + + static func contentType(for url: URL) -> String { + UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + } + + enum Loaded: Sendable { + case file(filename: String, contentType: String, data: Data) + case refused(String) + } + + /// Reads a file off the main actor — a large file, or one on a slow or + /// network volume, used to stall the composer mid-keystroke — and checks + /// its type and size before reading a byte of it. + static func load(_ url: URL) async -> Loaded { + await Task.detached(priority: .userInitiated) { read(url) }.value + } + + private static func read(_ url: URL) -> Loaded { + let filename = url.lastPathComponent + let type = contentType(for: url) + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize + if let size, let problem = problem(filename: filename, contentType: type, byteCount: size) { + return .refused(problem) + } + guard let data = try? Data(contentsOf: url) else { return .refused("Couldn't read \(filename)") } + // The size on disk was a promise; the bytes read are what uploads. + if let problem = problem(filename: filename, contentType: type, byteCount: data.count) { + return .refused(problem) + } + return .file(filename: filename, contentType: type, data: data) + } +} diff --git a/Sources/InkletMac/Models/HistoryModel.swift b/Sources/InkletMac/Models/HistoryModel.swift index c52603f..6c272f4 100644 --- a/Sources/InkletMac/Models/HistoryModel.swift +++ b/Sources/InkletMac/Models/HistoryModel.swift @@ -69,6 +69,11 @@ final class HistoryModel { /// The run whose timeline is being polled right now, if any. private(set) var watching: String? + /// Set by `AppModel`. A 401 that survived the refresh ends the session in + /// the one place the rest of the app does, rather than as an error line on + /// this page with the dead session left in place. + var onSessionExpired: (@MainActor () async -> Void)? + /// A new account: nothing from the last one may show. func reset() { generation += 1 @@ -99,6 +104,7 @@ final class HistoryModel { extras[id] = try await InkletAPI.shared.analysis(id: id) } catch { guard !(error is CancellationError) else { return } + if await endsSession(error) { return } openError = Self.message(for: error) } } @@ -131,6 +137,7 @@ final class HistoryModel { hasLoaded = true } catch { guard generation == expected, !(error is CancellationError) else { return } + if await endsSession(error) { return } self.error = Self.message(for: error) } } @@ -150,6 +157,7 @@ final class HistoryModel { hasMore = (page.hasMore ?? false) && page.nextCursor != nil } catch { guard generation == expected, !(error is CancellationError) else { return } + if await endsSession(error) { return } self.error = Self.message(for: error) } } @@ -239,6 +247,8 @@ final class HistoryModel { return } catch { if Task.isCancelled { return } + // Polling a dead session every two seconds helps no one. + if await endsSession(error) { return } timelineErrors[id] = Self.message(for: error) } do { @@ -249,6 +259,13 @@ final class HistoryModel { } } + /// True when the error was the session ending, now handed on. + private func endsSession(_ error: Error) async -> Bool { + guard APIError.endsSession(error) else { return false } + await onSessionExpired?() + return true + } + private static func message(for error: Error) -> String { (error as? APIError)?.errorDescription ?? error.localizedDescription } diff --git a/Sources/InkletMac/Services/AppContext.swift b/Sources/InkletMac/Services/AppContext.swift index fa304c9..1696ebf 100644 --- a/Sources/InkletMac/Services/AppContext.swift +++ b/Sources/InkletMac/Services/AppContext.swift @@ -80,8 +80,10 @@ private extension URL { /// 1. It has to run *before* the app activates. Once inklet is frontmost the /// previous app is gone, so the bundle id and pid are captured on the hot-key /// thread and the slow, permission-gated work happens afterwards. -/// 2. `NSAppleScript` blocks, and the first call per target app raises an -/// automation prompt. Both belong off the main thread. +/// 2. A script blocks until the target app answers, and the first one per +/// target raises an automation prompt. Neither may hold up the main thread, +/// and `NSAppleScript` may only be used there, so scripts run in an +/// `osascript` child process instead; see `run`. enum AppContext { struct Source: Sendable { var bundleID: String @@ -196,7 +198,7 @@ enum AppContext { return (URL of theTab) & "\\n" & (\(browser.titleProperty) of theTab) end tell """ - guard let output = await run(source) else { return nil } + guard let output = await run(source, in: bundleID) else { return nil } let parts = output.components(separatedBy: "\n") guard let raw = parts.first, !raw.isEmpty, @@ -219,7 +221,7 @@ enum AppContext { return out end tell """ - guard let output = await run(source) else { return nil } + guard let output = await run(source, in: "com.apple.finder") else { return nil } let urls = paths(from: output) return urls.isEmpty ? nil : .files(urls) } @@ -231,17 +233,17 @@ enum AppContext { return path of front document end tell """ - guard let output = await run(source) else { return nil } + guard let output = await run(source, in: "com.apple.Preview") else { return nil } let urls = paths(from: output) return urls.isEmpty ? nil : .files(urls) } /// Photos keeps its library in an opaque bundle, so the selection has to be - /// exported before it's a file anyone else can read. Exports land in a fresh - /// temp directory so listing it afterwards is unambiguous. + /// exported before it's a file anyone else can read. Each export lands in a + /// fresh directory so listing it afterwards is unambiguous, and all of them + /// under `exportsDirectory` so they can be thrown away together. private static func photosSelection() async -> Payload? { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("inklet-photos-\(UUID().uuidString)", isDirectory: true) + let directory = exportsDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) guard (try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)) != nil else { return nil } @@ -255,7 +257,7 @@ enum AppContext { """ // Exporting a burst or a long video takes real time, so this one gets a // longer leash than a property read. - guard await run(source, timeout: .seconds(15)) == "ok" else { + guard await run(source, in: "com.apple.Photos", timeout: .seconds(15)) == "ok" else { try? FileManager.default.removeItem(at: directory) return nil } @@ -270,6 +272,28 @@ enum AppContext { return .files(exported.sorted { $0.lastPathComponent < $1.lastPathComponent }) } + /// Copies of the user's photos, so they are not left lying in the + /// temporary directory: emptied at launch, when a new summon replaces the + /// suggestion, after a send, and on sign-out. By the time any of those + /// happens an accepted export has been read into memory. + static let exportsDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("inklet-photos", isDirectory: true) + + static func discardExports() { + try? FileManager.default.removeItem(at: exportsDirectory) + } + + /// At launch, when nothing can be using an export yet. Also sweeps the + /// per-summon directories earlier builds left beside it. + static func discardStaleExports() { + discardExports() + let temporary = FileManager.default.temporaryDirectory + for url in (try? FileManager.default.contentsOfDirectory(at: temporary, includingPropertiesForKeys: nil)) ?? [] + where url.lastPathComponent.hasPrefix("inklet-photos-") { + try? FileManager.default.removeItem(at: url) + } + } + // MARK: - Plumbing private static func paths(from output: String) -> [URL] { @@ -285,25 +309,128 @@ enum AppContext { /// leaves this awaiting forever, and the suggestion would arrive minutes /// later attached to a composer the user has long since moved on from. /// - /// `NSAppleScript` can't be cancelled, so the losing task is abandoned rather - /// than stopped; what matters is that the caller stops waiting. - private static func run(_ source: String, timeout: Duration = .seconds(2)) async -> String? { - await withTaskGroup(of: String?.self) { group in - group.addTask(priority: .userInitiated) { - var error: NSDictionary? - guard let script = NSAppleScript(source: source) else { return nil } - let result = script.executeAndReturnError(&error) - guard error == nil, let value = result.stringValue, !value.isEmpty else { return nil } - return value.trimmingCharacters(in: .whitespacesAndNewlines) + /// The caller gets nil at the deadline whatever the script is doing, and + /// the script is stopped then too. (A task group can't do this: it waits + /// for every child before returning, so the old deadline never ended + /// anything.) + /// + /// `NSAppleScript` would have to run on the main thread (Threading + /// Programming Guide, "Main Thread Only Classes") and can't be cancelled, + /// so a slow Photos export would freeze the UI for the whole leash. An + /// `osascript` child runs anywhere and can be killed. Its Apple Events are + /// attributed to this app, the process responsible for it, so they are + /// covered by the same automation consent. + /// + /// Consent is settled first, on a background thread: whichever thread sends + /// the first event to an app waits there until the user answers the prompt, + /// and a script killed at the deadline would take the question with it. + private static func run(_ source: String, in bundleID: String, timeout: Duration = .seconds(2)) async -> String? { + let script = ScriptProcess(source) + return await withCheckedContinuation { continuation in + let answer = FirstAnswer(continuation) + Task.detached(priority: .userInitiated) { + // Once the deadline has passed there is no one to run it for. + guard await mayAutomate(bundleID), !answer.isSettled else { return answer.settle(nil) } + answer.settle(await script.run()) } - group.addTask { + Task.detached { try? await Task.sleep(for: timeout) - return nil + answer.settle(nil) + script.stop() + } + } + } + + /// Whether this app may send Apple Events to the target, asking the user + /// if they haven't been asked. On a dispatch queue rather than Swift's + /// cooperative pool, because the call doesn't return while the prompt is up. + /// Once answered it is instant. + private static func mayAutomate(_ bundleID: String) async -> Bool { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + var target = AEAddressDesc() + let id = Array(bundleID.utf8) + guard AECreateDesc(typeApplicationBundleID, id, id.count, &target) == noErr else { + continuation.resume(returning: false) + return + } + defer { AEDisposeDesc(&target) } + // A concrete event, the get-data every one of these scripts + // starts with; consent is per app, so it covers the rest. + let status = AEDeterminePermissionToAutomateTarget(&target, kAECoreSuite, kAEGetData, true) + continuation.resume(returning: status == noErr) + } + } + } +} + +/// One `osascript -e` run. `stop` is safe from any thread and at any point: +/// before the launch it means the script never starts, after it the process is +/// terminated and `run` answers nil. +private final class ScriptProcess: @unchecked Sendable { + private let lock = NSLock() + private let process = Process() + private var stopped = false + + init(_ source: String) { + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", source] + process.standardInput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + } + + /// Blocking reads, so on a dispatch queue rather than Swift's cooperative + /// pool. The output is drained before waiting for the exit: a selection + /// long enough to fill the pipe would otherwise stall the child until the + /// deadline killed it. + func run() async -> String? { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [self] in + let output = Pipe() + process.standardOutput = output + let launched = lock.withLock { () -> Bool in + guard !stopped else { return false } + return (try? process.run()) != nil + } + guard launched else { return continuation.resume(returning: nil) } + + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationReason == .exit, process.terminationStatus == 0, + let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty else { return continuation.resume(returning: nil) } + continuation.resume(returning: text) } + } + } + + func stop() { + lock.withLock { + stopped = true + if process.isRunning { process.terminate() } + } + } +} + +/// Resumes its continuation with the first answer and ignores the rest: the +/// script's result or the deadline's nil, whichever lands first. +private final class FirstAnswer: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + var isSettled: Bool { + lock.withLock { continuation == nil } + } - let first = await group.next() ?? nil - group.cancelAll() - return first + func settle(_ value: String?) { + let pending = lock.withLock { () -> CheckedContinuation? in + defer { continuation = nil } + return continuation } + pending?.resume(returning: value) } } diff --git a/Sources/InkletMac/Services/InkletAPI.swift b/Sources/InkletMac/Services/InkletAPI.swift index 8a54ab3..74aac17 100644 --- a/Sources/InkletMac/Services/InkletAPI.swift +++ b/Sources/InkletMac/Services/InkletAPI.swift @@ -25,6 +25,26 @@ enum APIError: LocalizedError, Sendable { case .cancelled: nil } } + + /// The server has refused this session for good: a 401 that survived a + /// refresh, a refresh token it no longer knows, or no session at all. + /// Everything else — offline, a timeout, a 5xx, a captive portal's HTML — + /// says nothing about the tokens and must not cost the user their session. + static func endsSession(_ error: Error) -> Bool { + switch error as? APIError { + case .sessionExpired?, .notAuthenticated?: true + default: false + } + } +} + +/// What checking the stored session at launch came to. +enum RestoreOutcome: Sendable { + case signedIn(UserDTO) + /// Nothing was stored, or the server refused it. + case signedOut + /// The server couldn't be asked. The tokens are kept for the next try. + case unreachable } /// Why `POST /api/devices/quote0` refused, in the words the setup form shows. @@ -74,22 +94,48 @@ enum Quote0BindError: LocalizedError, Equatable, Sendable { actor InkletAPI { static let shared = InkletAPI() + /// One HTTP round trip. `URLSession` in the app; a scripted server in the + /// tests that pin down when a stored session may be thrown away. + typealias Transport = @Sendable (URLRequest) async throws -> (Data, URLResponse) + + /// Where the tokens outlive the process: `TokenStore` in the app, memory in + /// tests — which must never touch the session of whoever runs them. + struct TokenVault: Sendable { + var load: @Sendable () -> AuthTokens? + var save: @Sendable (AuthTokens) -> Void + var clear: @Sendable () -> Void + + static let system = TokenVault(load: TokenStore.load, save: TokenStore.save, clear: TokenStore.clear) + } + private let authBase = URL(string: "https://auth.iminklet.com")! - private let apiBase = URL(string: "https://dev.iminklet.com")! + private let apiBase = InkletServer.apiBase - private let session: URLSession + private let transport: Transport + private let vault: TokenVault private var tokens: AuthTokens? private var didLoadStoredTokens = false private var refreshInFlight: Task? - init() { + init(transport: Transport? = nil, vault: TokenVault = .system) { + self.vault = vault + if let transport { + self.transport = transport + return + } let config = URLSessionConfiguration.default - config.waitsForConnectivity = true - config.timeoutIntervalForRequest = 30 + // Every request here has someone waiting on it — the launch splash, + // a page, the composer. Waiting for connectivity (with the default + // seven-day resource timeout) left the splash spinning forever on a + // Mac with no network; failing fast is what lets the UI say so. + config.waitsForConnectivity = false + config.timeoutIntervalForRequest = 20 + config.timeoutIntervalForResource = 120 // Presigned S3 URLs are single-use and time-boxed; a cached 200 for one // would be served after the signature expired. config.requestCachePolicy = .reloadIgnoringLocalCacheData - session = URLSession(configuration: config) + let session = URLSession(configuration: config) + self.transport = { request in try await session.data(for: request) } } /// Deliberately not done in `init`. This type is a `static let`, so its @@ -100,7 +146,7 @@ actor InkletAPI { private func loadStoredTokensIfNeeded() { guard !didLoadStoredTokens else { return } didLoadStoredTokens = true - tokens = TokenStore.load() + tokens = vault.load() } var hasCredentials: Bool { @@ -113,7 +159,7 @@ actor InkletAPI { func adopt(_ newTokens: AuthTokens) { didLoadStoredTokens = true tokens = newTokens - TokenStore.save(newTokens) + vault.save(newTokens) } func signOut() async { @@ -123,12 +169,12 @@ actor InkletAPI { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(tokens.accessToken)", forHTTPHeaderField: "Authorization") request.httpBody = try? JSONEncoder().encode(["refreshToken": tokens.refreshToken]) - _ = try? await session.data(for: request) + _ = try? await transport(request) } tokens = nil refreshInFlight?.cancel() refreshInFlight = nil - TokenStore.clear() + vault.clear() } func login(identifier: String, password: String) async throws -> UserDTO { @@ -139,21 +185,20 @@ actor InkletAPI { return response.user } - /// Fetches the profile, refreshing once if the stored access token is stale. - /// Returns nil when there is no usable session at all. - func restore() async -> UserDTO? { + /// Fetches the profile with the stored session; `me()` already refreshes + /// once on a 401. Only the server refusing the session signs out (which + /// also revokes it server-side). No network, a timeout or a 5xx says + /// nothing about the tokens: they are kept and the UI offers a retry. + func restore() async -> RestoreOutcome { loadStoredTokensIfNeeded() - guard tokens != nil else { return nil } - if let user = try? await me() { return user } - guard (try? await refreshTokens()) != nil else { - await signOut() - return nil - } - guard let user = try? await me() else { + guard tokens != nil else { return .signedOut } + do { + return .signedIn(try await me()) + } catch { + guard APIError.endsSession(error) else { return .unreachable } await signOut() - return nil + return .signedOut } - return user } func me() async throws -> UserDTO { @@ -273,8 +318,10 @@ actor InkletAPI { /// Downloads a presigned asset. Not routed through the authed helpers — S3 /// rejects requests that carry an unexpected Authorization header. func fetchData(from url: URL) async throws -> Data { + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData do { - let (data, response) = try await session.data(from: url) + let (data, response) = try await transport(request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200...299).contains(status) else { throw APIError.http(status, nil) } return data @@ -399,7 +446,7 @@ actor InkletAPI { } do { - let (data, response) = try await session.data(for: request) + let (data, response) = try await transport(request) let http = response as? HTTPURLResponse if path.hasPrefix("api/app/v1/"), let http { if (200...299).contains(http.statusCode), let renewed = http.value(forHTTPHeaderField: "X-Renewed-Token"), @@ -429,7 +476,7 @@ actor InkletAPI { if let refreshInFlight { return try await refreshInFlight.value } guard let current = tokens else { throw APIError.notAuthenticated } - let task = Task { [session, authBase] in + let task = Task { [transport, authBase] in var request = URLRequest(url: authBase.appending(path: "auth/refresh")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -438,16 +485,24 @@ actor InkletAPI { let data: Data let response: URLResponse do { - (data, response) = try await session.data(for: request) + (data, response) = try await transport(request) } catch { throw APIError.network } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard (200...299).contains(status), - let fresh = try? JSONDecoder().decode(AuthTokens.self, from: data) else { + switch status { + case 200...299: + // A 200 that isn't tokens is a proxy or captive portal + // answering for the server, not the server refusing us. + guard let fresh = try? JSONDecoder().decode(AuthTokens.self, from: data) else { throw APIError.decoding } + return fresh + case 400, 401: + // The backend's answer for a refresh token it doesn't know or + // that has expired (400: none sent). Nothing will fix it. throw APIError.sessionExpired + default: + throw APIError.http(status, nil) } - return fresh } refreshInFlight = task @@ -468,7 +523,7 @@ actor InkletAPI { let data: Data let response: URLResponse do { - (data, response) = try await session.data(for: request) + (data, response) = try await transport(request) } catch { throw APIError.network } diff --git a/Sources/InkletMac/Services/SelectionContext.swift b/Sources/InkletMac/Services/SelectionContext.swift index 928956e..6f165d9 100644 --- a/Sources/InkletMac/Services/SelectionContext.swift +++ b/Sources/InkletMac/Services/SelectionContext.swift @@ -32,6 +32,15 @@ enum SelectionContext { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) } + /// Privacy & Security → Accessibility, where the switch lives once the + /// prompt has been dismissed. + @MainActor + static func openSystemSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + static func selectedText(pid: pid_t) async -> String? { guard await MainActor.run(body: { AXIsProcessTrusted() }) else { return nil } diff --git a/Sources/InkletMac/Services/ServicesProvider.swift b/Sources/InkletMac/Services/ServicesProvider.swift index ea97b16..b9519ac 100644 --- a/Sources/InkletMac/Services/ServicesProvider.swift +++ b/Sources/InkletMac/Services/ServicesProvider.swift @@ -9,17 +9,38 @@ import AppKit /// through the pasteboard instead of us going to fetch it. /// /// The declaration lives in Info.plist under `NSServices`; this is the receiver. +/// +/// The menu item is in every app whether or not anyone is signed in — the +/// declaration is static — so the receiver is installed for the life of the +/// app and answers a signed-out request the way a widget link does: it keeps +/// the payload, puts up the window where sign-in happens, and hands the +/// payload to the composer once someone is signed in. @MainActor final class ServicesProvider: NSObject { + /// AppKit keeps `servicesProvider` weakly. This is the reference that keeps + /// it alive for as long as the app runs, not as long as some window does. + private(set) static var installed: ServicesProvider? + private let model: AppModel + private let session: Session + private let openMainWindow: () -> Void + /// What arrived while nobody was signed in, or before the stored session + /// had been checked on a cold launch from the Services menu. + private var pending: Capture? - init(model: AppModel) { + private init(model: AppModel, session: Session, openMainWindow: @escaping () -> Void) { self.model = model + self.session = session + self.openMainWindow = openMainWindow super.init() } - func install() { - NSApp.servicesProvider = self + /// Once per launch; later calls find it already in place. + static func install(model: AppModel, session: Session, openMainWindow: @escaping () -> Void) { + guard installed == nil else { return } + let provider = ServicesProvider(model: model, session: session, openMainWindow: openMainWindow) + installed = provider + NSApp.servicesProvider = provider // Tells the system to re-read our Info.plist. Without it a freshly built // app doesn't appear in other apps' Services menus until logout. NSUpdateDynamicServices() @@ -33,6 +54,19 @@ final class ServicesProvider: NSObject { error.pointee = "Nothing inklet can send." as NSString return } + guard session.user != nil else { + pending = context + openMainWindow() + NSApp.activate() + return + } + model.presentComposer(with: context) + } + + /// Someone is signed in now: the composer gets what was waiting for them. + func deliverPending() { + guard let context = pending else { return } + pending = nil model.presentComposer(with: context) } diff --git a/Sources/InkletMac/Services/Session.swift b/Sources/InkletMac/Services/Session.swift index eef2f75..d1f1ce8 100644 --- a/Sources/InkletMac/Services/Session.swift +++ b/Sources/InkletMac/Services/Session.swift @@ -1,5 +1,6 @@ import SwiftUI import AuthenticationServices +import Network import Observation /// Who is signed in, and the three ways to change that. @@ -8,12 +9,15 @@ import Observation final class Session { enum State: Equatable { case restoring + /// A stored session exists but the server couldn't be asked about it. + /// Not signed out: the tokens are kept and the check can be retried. + case unreachable case signedOut case signedIn(UserDTO) static func == (lhs: State, rhs: State) -> Bool { switch (lhs, rhs) { - case (.restoring, .restoring), (.signedOut, .signedOut): true + case (.restoring, .restoring), (.unreachable, .unreachable), (.signedOut, .signedOut): true case (.signedIn(let a), .signedIn(let b)): a.id == b.id default: false } @@ -30,8 +34,48 @@ final class Session { } func restore() async { - let user = await InkletAPI.shared.restore() - state = user.map(State.signedIn) ?? .signedOut + settle(await InkletAPI.shared.restore()) + } + + /// Checks the stored session again from the unreachable screen. That + /// screen stays up while this runs, so a second failure doesn't flash the + /// splash in between. + func retry() async { + guard state == .unreachable, !isWorking else { return } + isWorking = true + defer { isWorking = false } + settle(await InkletAPI.shared.restore()) + } + + /// Retries each time the network comes back, for as long as the calling + /// task lives — the unreachable screen's. The usual way to land there is a + /// Mac that launched at login before Wi-Fi joined, and that should sort + /// itself out without a click. + func retryWhenOnline() async { + var wasOnline: Bool? + for await online in Self.connectivity() { + // The first report is how things stand now, not a change; only a + // path that was down and came back is worth another request. + if online, wasOnline == false { await retry() } + wasOnline = online + } + } + + private static func connectivity() -> AsyncStream { + AsyncStream { continuation in + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { continuation.yield($0.status == .satisfied) } + continuation.onTermination = { _ in monitor.cancel() } + monitor.start(queue: DispatchQueue(label: "com.iminklet.mac.network-path")) + } + } + + private func settle(_ outcome: RestoreOutcome) { + switch outcome { + case .signedIn(let user): state = .signedIn(user) + case .signedOut: state = .signedOut + case .unreachable: state = .unreachable + } } func signIn(identifier: String, password: String) async { @@ -88,19 +132,19 @@ final class Session { do { let callback = try await Self.runWebAuth(url: provider.authorizationURL) - let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [] - func value(_ name: String) -> String? { items.first { $0.name == name }?.value } - - if let failure = value("error") { + let tokens: AuthTokens + switch WebAuthCallback(callback) { + case .tokens(let received): + tokens = received + case .failure(let failure): error = failure == "oauth_failed" ? "\(provider.displayName) sign-in failed" : failure return - } - guard let access = value("accessToken"), let refresh = value("refreshToken") else { + case nil: error = "\(provider.displayName) sign-in didn't return a session" return } - await InkletAPI.shared.adopt(AuthTokens(accessToken: access, refreshToken: refresh)) + await InkletAPI.shared.adopt(tokens) let user = try await InkletAPI.shared.me() state = .signedIn(user) } catch is CancellationError { @@ -130,6 +174,42 @@ final class Session { } } +/// What the portal's desktop callback page hands back: +/// `inklet://auth/callback?accessToken=…&refreshToken=…`, or `?error=…`. +/// +/// `ASWebAuthenticationSession` completes on any `inklet:` URL, so the host +/// and path are checked exactly before anything in the query is believed — +/// tokens that arrive at some other `inklet:` address are not a sign-in. +/// There is no `state` to compare: the backend's desktop flow neither takes +/// nor echoes one (its OAuth `state` only names the client type). +enum WebAuthCallback: Equatable { + case tokens(AuthTokens) + case failure(String) + + /// Nil for anything that isn't the callback, or is the callback without + /// both tokens or an error. + init?(_ url: URL) { + guard url.scheme?.lowercased() == "inklet", url.host()?.lowercased() == "auth", + url.path() == "/callback", + url.user() == nil, url.password() == nil, url.port == nil, url.fragment() == nil, + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil } + func value(_ name: String) -> String? { + let matches = items.filter { $0.name == name } + // A repeated key is ambiguous; neither copy is taken. + guard matches.count == 1, let value = matches[0].value, !value.isEmpty else { return nil } + return value + } + + if let failure = value("error") { + self = .failure(failure) + } else if let access = value("accessToken"), let refresh = value("refreshToken") { + self = .tokens(AuthTokens(accessToken: access, refreshToken: refresh)) + } else { + return nil + } + } +} + /// Holds the `ASWebAuthenticationSession` and its context provider alive for the /// length of the flow — the session keeps only a weak reference to the provider, /// and nothing else would retain the session itself once `run` suspends. diff --git a/Sources/InkletMac/Services/SystemSettings.swift b/Sources/InkletMac/Services/SystemSettings.swift index 85649e4..d136fd2 100644 --- a/Sources/InkletMac/Services/SystemSettings.swift +++ b/Sources/InkletMac/Services/SystemSettings.swift @@ -215,11 +215,16 @@ final class WeatherService: NSObject, CLLocationManagerDelegate { } /// Refreshes when the chip is enabled and the last fix is older than 30 minutes. + /// Never the thing that asks for location: Home calls this on every visit, + /// and the prompt belongs to the moment the user turns weather on. func refreshIfStale() { if let current, Date().timeIntervalSince(current.fetchedAt) < 30 * 60 { return } + guard manager.authorizationStatus != .notDetermined else { return } refresh() } + /// Asks for location access if it hasn't been asked for yet — so only from + /// turning the setting on, or a chip that can't exist without access. func refresh() { guard !isLoading else { return } problem = nil diff --git a/Sources/InkletMac/Services/TokenStore.swift b/Sources/InkletMac/Services/TokenStore.swift index 82a9931..aa1c2f2 100644 --- a/Sources/InkletMac/Services/TokenStore.swift +++ b/Sources/InkletMac/Services/TokenStore.swift @@ -1,4 +1,5 @@ import Foundation +import os import Security struct AuthTokens: Codable, Sendable, Equatable { @@ -8,15 +9,18 @@ struct AuthTokens: Codable, Sendable, Equatable { /// Where the session credentials live between launches. /// -/// Developer ID-signed release builds use Keychain. Ad-hoc local builds use a -/// 0600 fallback file because their identity changes on every rebuild and would -/// otherwise trigger a modal Keychain authorization prompt at launch. The first -/// successful release-build token write removes that fallback file. +/// Developer ID-signed release builds use the Keychain and nothing else — the +/// privacy policy says so. If a Keychain write fails, the session lives only +/// in `InkletAPI`'s memory for this launch and the next launch asks for a +/// sign-in; the failure is logged, never papered over with a file. +/// +/// Ad-hoc local builds use a 0600 file instead, because their identity changes +/// on every rebuild and would otherwise trigger a modal Keychain authorization +/// prompt at launch. enum TokenStore { // CI defines INKLET_RELEASE only for the Developer ID-signed build. Local - // ad-hoc builds deliberately keep using the 0600 fallback file so every - // rebuild does not look like a new app asking to access an older keychain - // item. The public build always stores refresh credentials in Keychain. + // ad-hoc builds deliberately keep using the 0600 file so every rebuild does + // not look like a new app asking to access an older keychain item. #if INKLET_RELEASE private static let usesKeychain = true #else @@ -25,40 +29,68 @@ enum TokenStore { private static let service = "com.iminklet.mac" private static let account = "session" + /// Statuses only. Nothing here ever logs a token. + private static let log = Logger(subsystem: "com.iminklet.mac", category: "session") - private static var fileURL: URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + private static var directory: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("inklet", isDirectory: true) - try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - return base.appendingPathComponent("session.json") } + /// The ad-hoc build's store — and, in a release build, only ever the + /// leftover that release builds before this one wrote when the Keychain + /// refused a write. + private static var fileURL: URL { directory.appendingPathComponent("session.json") } + static func save(_ tokens: AuthTokens) { guard let data = try? JSONEncoder().encode(tokens) else { return } - if usesKeychain, keychainSave(data) { - try? FileManager.default.removeItem(at: fileURL) + guard usesKeychain else { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) return } - try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + let status = keychainSave(data) + if status != errSecSuccess { + log.error("Keychain refused the session (OSStatus \(status, privacy: .public)); it is kept in memory for this launch only") + } + // Whatever the Keychain said, a release build leaves no plaintext copy. + try? FileManager.default.removeItem(at: fileURL) } /// Blocking. Never call this from the main thread — see `usesKeychain`. /// - /// No migration path from the Keychain-backed builds on purpose: reading that - /// item is what raises the authorization dialog, and there is no way to try it - /// without risking a launch that sits on a spinner waiting for a click. One - /// re-login is cheaper than that, every time. + /// Ad-hoc builds have no migration path from the Keychain on purpose: + /// reading an item another build wrote is what raises the authorization + /// dialog, and there is no way to try it without risking a launch that sits + /// on a spinner waiting for a click. One re-login is cheaper than that. static func load() -> AuthTokens? { - if usesKeychain, - let data = keychainLoad(), - let tokens = try? JSONDecoder().decode(AuthTokens.self, from: data) { + guard usesKeychain else { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? JSONDecoder().decode(AuthTokens.self, from: data) + } + if let data = keychainLoad(), let tokens = try? JSONDecoder().decode(AuthTokens.self, from: data) { + try? FileManager.default.removeItem(at: fileURL) return tokens } + return migrateLegacyFile() + } + + /// Earlier release builds wrote the session to a plaintext file when the + /// Keychain refused it. Such a session moves into the Keychain, and the + /// file goes whether or not the move worked: if it didn't, the session + /// still serves this launch from memory, like any failed write. + private static func migrateLegacyFile() -> AuthTokens? { guard let data = try? Data(contentsOf: fileURL) else { return nil } - return try? JSONDecoder().decode(AuthTokens.self, from: data) + guard let tokens = try? JSONDecoder().decode(AuthTokens.self, from: data) else { + try? FileManager.default.removeItem(at: fileURL) + return nil + } + log.notice("Moving a session file left by an earlier release into the Keychain") + save(tokens) + return tokens } static func clear() { @@ -78,12 +110,12 @@ enum TokenStore { ] } - private static func keychainSave(_ data: Data) -> Bool { + private static func keychainSave(_ data: Data) -> OSStatus { SecItemDelete(baseQuery() as CFDictionary) var query = baseQuery() query[kSecValueData as String] = data query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + return SecItemAdd(query as CFDictionary, nil) } private static func keychainLoad() -> Data? { @@ -92,7 +124,13 @@ enum TokenStore { query[kSecMatchLimit as String] = kSecMatchLimitOne var result: AnyObject? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil } + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + if status != errSecItemNotFound { + log.error("Keychain read failed (OSStatus \(status, privacy: .public))") + } + return nil + } return result as? Data } } diff --git a/Sources/InkletMac/Views/ComposerView.swift b/Sources/InkletMac/Views/ComposerView.swift index d2bf357..1b106db 100644 --- a/Sources/InkletMac/Views/ComposerView.swift +++ b/Sources/InkletMac/Views/ComposerView.swift @@ -1,6 +1,5 @@ import InkletPresentationKit import SwiftUI -import UniformTypeIdentifiers /// Contents of the HUD panel. Lives in `ComposerPanelController`'s window rather /// than a popover, so the file chooser and app switches don't dismiss it. @@ -24,6 +23,9 @@ struct ComposerView: View { @State private var generationID = UUID() @State private var virtualBaseRevision: Int64? @State private var attachments: [Attachment] = [] + /// Batches of files still being read. Sending waits for them, or a send + /// could go out without the file the user just picked. + @State private var pendingReads = 0 @State private var isSending = false @State private var progress: String? @State private var error: String? @@ -130,14 +132,14 @@ struct ComposerView: View { if let value = capture.text { text = value } if let link = capture.link { - attachments.append(Attachment( + add(Attachment( filename: link.title ?? (URL(string: link.url)?.host() ?? link.url), contentType: "text/uri-list", data: Data(), link: link.url)) } - capture.files.forEach { attach(url: $0) } + attach(capture.files) model.suggestion = nil } @@ -359,7 +361,7 @@ struct ComposerView: View { .disabled(isSending) } - private var canSend: Bool { hasContent && !isSending && blocker == nil } + private var canSend: Bool { hasContent && !isSending && pendingReads == 0 && blocker == nil } /// The face names the action; the menu lists the rest. Pressing the face /// sends, and so does ⌘↩. Each item carries ⌥1…⌥4, which work with the @@ -445,10 +447,10 @@ struct ComposerView: View { private func commitLink() { let trimmed = linkDraft.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: trimmed), url.scheme != nil else { return } - attachments.append(Attachment(filename: url.host() ?? trimmed, - contentType: "text/uri-list", - data: Data(), - link: trimmed)) + add(Attachment(filename: url.host() ?? trimmed, + contentType: "text/uri-list", + data: Data(), + link: trimmed)) linkDraft = "" isAddingLink = false } @@ -462,23 +464,45 @@ struct ComposerView: View { picker.message = "Choose files to send to inklet" guard let host = NSApp.keyWindow else { - if picker.runModal() == .OK { picker.urls.forEach { attach(url: $0) } } + if picker.runModal() == .OK { attach(picker.urls) } return } picker.beginSheetModal(for: host) { response in guard response == .OK else { return } - picker.urls.forEach { attach(url: $0) } + attach(picker.urls) } } - private func attach(url: URL) { - guard let data = try? Data(contentsOf: url) else { - error = "Couldn't read \(url.lastPathComponent)" + /// Every attachment comes through here, so the Content's Asset ceiling + /// holds however it arrived. + private func add(_ attachment: Attachment) { + guard attachments.count < AttachmentRules.maxAttachments else { + error = AttachmentRules.tooMany return } - let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType - ?? "application/octet-stream" - attachments.append(Attachment(filename: url.lastPathComponent, contentType: type, data: data)) + attachments.append(attachment) + } + + /// Reads off the main actor, in the order given; a refused file says why + /// and the rest still come in. + private func attach(_ urls: [URL]) { + guard !urls.isEmpty else { return } + pendingReads += 1 + Task { + defer { pendingReads -= 1 } + for url in urls { + guard attachments.count < AttachmentRules.maxAttachments else { + error = AttachmentRules.tooMany + return + } + switch await AttachmentRules.load(url) { + case .file(let filename, let contentType, let data): + add(Attachment(filename: filename, contentType: contentType, data: data)) + case .refused(let message): + error = message + } + } + } } /// Images come in as bytes, file promises as URLs, everything else as text. @@ -486,12 +510,12 @@ struct ComposerView: View { let pasteboard = NSPasteboard.general if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty { - for url in urls where url.isFileURL { attach(url: url) } + attach(urls.filter(\.isFileURL)) for url in urls where !url.isFileURL { - attachments.append(Attachment(filename: url.host() ?? url.absoluteString, - contentType: "text/uri-list", - data: Data(), - link: url.absoluteString)) + add(Attachment(filename: url.host() ?? url.absoluteString, + contentType: "text/uri-list", + data: Data(), + link: url.absoluteString)) } return } @@ -499,7 +523,12 @@ struct ComposerView: View { if let image = NSImage(pasteboard: pasteboard), let tiff = image.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) { - attachments.append(Attachment(filename: "Pasted image.png", contentType: "image/png", data: png)) + let filename = "Pasted image.png" + if let problem = AttachmentRules.problem(filename: filename, contentType: "image/png", byteCount: png.count) { + error = problem + } else { + add(Attachment(filename: filename, contentType: "image/png", data: png)) + } return } @@ -509,7 +538,7 @@ struct ComposerView: View { } private func send() { - guard hasContent, blocker == nil, !isSending else { return } + guard hasContent, blocker == nil, !isSending, pendingReads == 0 else { return } isSending = true error = nil progress = action == .upload ? "Saving…" : "Uploading…" @@ -539,6 +568,8 @@ struct ComposerView: View { generationID = UUID() text = "" attachments = [] + // Whatever Photos exported for this send is uploaded now. + AppContext.discardExports() try? await Task.sleep(for: .milliseconds(650)) ComposerPanelController.shared.hide() } catch { diff --git a/Sources/InkletMac/Views/HomeView.swift b/Sources/InkletMac/Views/HomeView.swift index 50be8b8..c5246ec 100644 --- a/Sources/InkletMac/Views/HomeView.swift +++ b/Sources/InkletMac/Views/HomeView.swift @@ -32,7 +32,9 @@ struct HomeView: View { .navigationTitle("Home") } - @AppStorage(SystemSettings.showWeatherKey) private var showWeather = true + /// Off until the user turns it on in Settings, which is where location + /// access is asked for. A stored choice from an earlier build still stands. + @AppStorage(SystemSettings.showWeatherKey) private var showWeather = false private let weather = WeatherService.shared private var greeting: some View { diff --git a/Sources/InkletMac/Views/LoginView.swift b/Sources/InkletMac/Views/LoginView.swift index edaf718..1d0bd7c 100644 --- a/Sources/InkletMac/Views/LoginView.swift +++ b/Sources/InkletMac/Views/LoginView.swift @@ -259,6 +259,55 @@ struct SplashView: View { } } +/// The stored session couldn't be checked: offline, a timeout, the server +/// having a bad moment. The session is kept, so this offers a retry rather +/// than the sign-in form, and tries again by itself when the network comes +/// back. Sign Out is there for the one case a retry can't fix — wanting a +/// different account while this one can't be reached. +struct UnreachableView: View { + @Environment(Session.self) private var session + + var body: some View { + VStack(spacing: 16) { + Wordmark(size: 30) + VStack(spacing: 4) { + Text("Can't reach inklet") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Ink.text) + Text("You're still signed in. Check your connection and try again.") + .font(.system(size: 12)) + .foregroundStyle(Ink.muted) + } + .multilineTextAlignment(.center) + + Button { + Task { await session.retry() } + } label: { + Text(session.isWorking ? "Trying…" : "Try Again") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Ink.bg) + .frame(width: 160) + .padding(.vertical, 9) + .background(Ink.text.opacity(session.isWorking ? 0.35 : 1), + in: .rect(cornerRadius: Ink.controlCorner)) + } + .buttonStyle(.plain) + .disabled(session.isWorking) + .keyboardShortcut(.defaultAction) + + Button("Sign Out") { Task { await session.signOut() } } + .buttonStyle(.plain) + .font(.system(size: 12)) + .foregroundStyle(Ink.muted) + .disabled(session.isWorking) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Ink.bg.ignoresSafeArea()) + .background(WindowStyler().frame(width: 0, height: 0)) + .task { await session.retryWhenOnline() } + } +} + /// One identity-provider button, sized to the stricter of the two brand /// guidelines (Google: 40pt tall, 12pt side padding, 10pt between logo and diff --git a/Sources/InkletMac/Views/SettingsView.swift b/Sources/InkletMac/Views/SettingsView.swift index 56aef96..a85d6f7 100644 --- a/Sources/InkletMac/Views/SettingsView.swift +++ b/Sources/InkletMac/Views/SettingsView.swift @@ -29,10 +29,16 @@ struct SettingsView: View { private struct GeneralSettings: View { @AppStorage(SystemSettings.showInDockKey) private var showInDock = true - @AppStorage(SystemSettings.showWeatherKey) private var showWeather = true + @AppStorage(SystemSettings.showWeatherKey) private var showWeather = false @ObservedObject private var updater = AppUpdater.shared private let loginItem = LaunchAtLogin.shared private let weather = WeatherService.shared + /// The system posts nothing when Accessibility access changes, so this is + /// read again whenever the app comes back to the front — which is how a + /// user returning from System Settings arrives. + @State private var readsSelection = SelectionContext.isTrusted + /// The system prompt shows once; after that the switch is only in System Settings. + @State private var askedForAccessibility = false private var lastCheckDescription: String { guard updater.isAvailable else { return "Run a packaged build to check for updates" } @@ -55,6 +61,12 @@ private struct GeneralSettings: View { return "Uses your approximate location, via Open-Meteo" } + private var selectionSubtitle: String { + readsSelection + ? "The composer offers the text you've highlighted" + : "Needs Accessibility access in System Settings to offer highlighted text" + } + var body: some View { SettingsPage(tab: .general) { SettingsGroup("Startup") { @@ -110,6 +122,24 @@ private struct GeneralSettings: View { subtitle: "Opens the composer from any app · ⌫ restores the default") { ShortcutRecorder() } + SettingRow(title: "Highlighted text", subtitle: selectionSubtitle) { + if !readsSelection { + if askedForAccessibility { + Button("Open Settings") { SelectionContext.openSystemSettings() } + .controlSize(.small) + } else { + Button("Allow…") { + SelectionContext.requestPermission() + askedForAccessibility = true + } + .controlSize(.small) + } + } + } + .onAppear { readsSelection = SelectionContext.isTrusted } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + readsSelection = SelectionContext.isTrusted + } SettingRow(title: "Weather on Home", subtitle: weatherSubtitle, showsDivider: false) { HStack(spacing: 8) { if showWeather, weather.isDenied { diff --git a/Sources/InkletPresentationKit/InkletServer.swift b/Sources/InkletPresentationKit/InkletServer.swift new file mode 100644 index 0000000..fa70456 --- /dev/null +++ b/Sources/InkletPresentationKit/InkletServer.swift @@ -0,0 +1,26 @@ +import Foundation + +/// The inklet API host, for the app and the widget alike. +/// +/// A build can point both at another host: `Scripts/build-app.sh` and +/// `Scripts/build-widgets.sh` copy `INKLET_API_BASE_URL` into each bundle's +/// Info.plist as `InkletAPIBaseURL`. Unset, empty, or not an https URL, it is +/// the default below — which is also what tests and `swift run` get, having +/// no such key. +nonisolated public enum InkletServer { + public static let defaultAPIBase = URL(string: "https://dev.iminklet.com")! + + public static let apiBase = apiBase(from: Bundle.main.object(forInfoDictionaryKey: "InkletAPIBaseURL") as? String) + + /// Account tokens ride on every request to this host, so only https is + /// taken. A trailing slash is dropped so paths join the same way whatever + /// the build wrote. + static func apiBase(from raw: String?) -> URL { + guard var text = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return defaultAPIBase } + while text.hasSuffix("/") { text.removeLast() } + guard let url = URL(string: text), url.scheme?.lowercased() == "https", + let host = url.host(), !host.isEmpty, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { return defaultAPIBase } + return url + } +} diff --git a/Sources/InkletPresentationKit/VirtualDisplayCore.swift b/Sources/InkletPresentationKit/VirtualDisplayCore.swift index 8b2ab25..2644a28 100644 --- a/Sources/InkletPresentationKit/VirtualDisplayCore.swift +++ b/Sources/InkletPresentationKit/VirtualDisplayCore.swift @@ -221,7 +221,7 @@ nonisolated public enum VirtualDisplayReader { public static func refresh(id: UUID, store: VirtualDisplayStore = .init()) async { guard let catalog = try? store.catalog(), let record = catalog.items.first(where: { $0.display.id == id }), let access = record.access else { return } - var request = URLRequest(url: URL(string: "https://dev.iminklet.com/api/virtual-display-widget/\(id.uuidString.lowercased())")!) + var request = URLRequest(url: InkletServer.apiBase.appending(path: "api/virtual-display-widget/\(id.uuidString.lowercased())")) request.timeoutInterval = 15 request.cachePolicy = .reloadIgnoringLocalCacheData request.setValue("Bearer \(access.token)", forHTTPHeaderField: "Authorization") diff --git a/Tests/InkletMacTests/AttachmentRulesTests.swift b/Tests/InkletMacTests/AttachmentRulesTests.swift new file mode 100644 index 0000000..4c7f9da --- /dev/null +++ b/Tests/InkletMacTests/AttachmentRulesTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import InkletMac + +// The composer refuses what the Content contract would: more than 50 Assets, +// a binary over 10 MiB or empty, or a type outside the backend's allow-list. + +@Test func theLimitsAreTheContracts() { + #expect(AttachmentRules.maxBytes == 10_485_760) + // The note is the 50th Asset. + #expect(AttachmentRules.maxAttachments == 49) +} + +@Test func contractTypesWithinTheLimitPass() { + for type in ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", + "application/pdf", "text/plain", "text/markdown", "application/json"] { + #expect(AttachmentRules.problem(filename: "f", contentType: type, byteCount: 1) == nil, "\(type)") + } + #expect(AttachmentRules.problem(filename: "f.png", contentType: "image/png", byteCount: AttachmentRules.maxBytes) == nil) +} + +@Test func eachRefusalNamesTheFile() { + let heic = AttachmentRules.problem(filename: "IMG_0001.heic", contentType: "image/heic", byteCount: 1) + #expect(heic?.hasPrefix("IMG_0001.heic can't be sent") == true) + let large = AttachmentRules.problem(filename: "scan.pdf", contentType: "application/pdf", byteCount: AttachmentRules.maxBytes + 1) + #expect(large?.hasPrefix("scan.pdf is larger than 10 MB") == true) + #expect(AttachmentRules.problem(filename: "note.txt", contentType: "text/plain", byteCount: 0) == "note.txt is empty.") +} + +@Test func typesComeFromTheExtension() { + #expect(AttachmentRules.contentType(for: URL(fileURLWithPath: "/tmp/a.md")) == "text/markdown") + #expect(AttachmentRules.contentType(for: URL(fileURLWithPath: "/tmp/a.JPG")) == "image/jpeg") + #expect(AttachmentRules.contentType(for: URL(fileURLWithPath: "/tmp/a.unknownext")) == "application/octet-stream") +} + +@Test func loadingChecksBeforeReading() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let small = root.appendingPathComponent("small.png") + try Data([0x89, 0x50, 0x4E, 0x47]).write(to: small) + guard case .file(let filename, let contentType, let data) = await AttachmentRules.load(small) else { + Issue.record("Expected small.png to load") + return + } + #expect(filename == "small.png") + #expect(contentType == "image/png") + #expect(data.count == 4) + + // Sparse: 10 MiB and a byte on disk without writing them. + let large = root.appendingPathComponent("large.pdf") + FileManager.default.createFile(atPath: large.path, contents: nil) + let handle = try FileHandle(forWritingTo: large) + try handle.truncate(atOffset: UInt64(AttachmentRules.maxBytes + 1)) + try handle.close() + guard case .refused(let tooLarge) = await AttachmentRules.load(large) else { + Issue.record("Expected large.pdf to be refused") + return + } + #expect(tooLarge.hasPrefix("large.pdf is larger than 10 MB")) + + let archive = root.appendingPathComponent("bundle.zip") + try Data([1]).write(to: archive) + guard case .refused(let wrongType) = await AttachmentRules.load(archive) else { + Issue.record("Expected bundle.zip to be refused") + return + } + #expect(wrongType.hasPrefix("bundle.zip can't be sent")) + + guard case .refused(let missing) = await AttachmentRules.load(root.appendingPathComponent("gone.txt")) else { + Issue.record("Expected a missing file to be refused") + return + } + #expect(missing == "Couldn't read gone.txt") +} diff --git a/Tests/InkletMacTests/SessionRestoreTests.swift b/Tests/InkletMacTests/SessionRestoreTests.swift new file mode 100644 index 0000000..a012d40 --- /dev/null +++ b/Tests/InkletMacTests/SessionRestoreTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +@testable import InkletMac + +// Launch checks the stored session with `auth/me`, refreshing once on a 401. +// Only the server refusing the session may sign the user out; being offline, +// a timeout, a 5xx or a captive portal's page must keep the tokens for a retry. + +/// A scripted auth server. Each path answers from its own queue, in order; a +/// request nobody scripted — a logout the test didn't expect — is a failure. +final class ScriptedAuthServer: @unchecked Sendable { + enum Reply: Sendable { + case status(Int, String) + case offline + } + + private let lock = NSLock() + private var replies: [String: [Reply]] + private var log: [(path: String, authorization: String?)] = [] + + init(_ replies: [String: [Reply]]) { + self.replies = replies + } + + var paths: [String] { lock.withLock { log.map(\.path) } } + func authorizations(for path: String) -> [String?] { + lock.withLock { log.filter { $0.path == path }.map(\.authorization) } + } + + func respond(_ request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url! + let path = url.path() + let reply = lock.withLock { () -> Reply? in + log.append((path, request.value(forHTTPHeaderField: "Authorization"))) + guard var queue = replies[path], !queue.isEmpty else { return nil } + let next = queue.removeFirst() + replies[path] = queue + return next + } + switch reply { + case .status(let code, let body)?: + return (Data(body.utf8), HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil)!) + case .offline?: + throw URLError(.notConnectedToInternet) + case nil: + Issue.record("Unexpected request to \(path)") + throw URLError(.badServerResponse) + } + } +} + +/// Stands in for the Keychain, which a test run must never touch. +final class MemoryTokenVault: @unchecked Sendable { + private let lock = NSLock() + private var stored: AuthTokens? + private var didClear = false + + init(_ tokens: AuthTokens?) { + stored = tokens + } + + var tokens: AuthTokens? { lock.withLock { stored } } + var cleared: Bool { lock.withLock { didClear } } + + var vault: InkletAPI.TokenVault { + InkletAPI.TokenVault( + load: { self.tokens }, + save: { tokens in self.lock.withLock { self.stored = tokens } }, + clear: { self.lock.withLock { self.stored = nil; self.didClear = true } }) + } +} + +private let stored = AuthTokens(accessToken: "access-1", refreshToken: "refresh-1") +private let user = #"{"id":"u1","email":"kz@example.com","username":"kz"}"# +private let refreshed = #"{"accessToken":"access-2","refreshToken":"refresh-2"}"# + +private func restore(_ server: ScriptedAuthServer, _ vault: MemoryTokenVault) async -> RestoreOutcome { + let api = InkletAPI(transport: { try server.respond($0) }, vault: vault.vault) + return await api.restore() +} + +private func isUnreachable(_ outcome: RestoreOutcome) -> Bool { + if case .unreachable = outcome { return true } + return false +} + +private func isSignedOut(_ outcome: RestoreOutcome) -> Bool { + if case .signedOut = outcome { return true } + return false +} + +@Test func aSessionTheServerAcceptsSignsIn() async { + let server = ScriptedAuthServer(["/auth/me": [.status(200, user)]]) + let vault = MemoryTokenVault(stored) + guard case .signedIn(let signedIn) = await restore(server, vault) else { + Issue.record("Expected a signed-in outcome") + return + } + #expect(signedIn.id == "u1") + #expect(vault.tokens == stored) + #expect(server.authorizations(for: "/auth/me") == ["Bearer access-1"]) +} + +@Test func nothingStoredIsSignedOutWithoutAsking() async { + let server = ScriptedAuthServer([:]) + let vault = MemoryTokenVault(nil) + #expect(isSignedOut(await restore(server, vault))) + #expect(server.paths.isEmpty) +} + +@Test func noNetworkAtLaunchKeepsTheSession() async { + let server = ScriptedAuthServer(["/auth/me": [.offline]]) + let vault = MemoryTokenVault(stored) + #expect(isUnreachable(await restore(server, vault))) + #expect(vault.tokens == stored) + #expect(!vault.cleared) + // No logout: the server-side session must not be revoked either. + #expect(server.paths == ["/auth/me"]) +} + +@Test(arguments: [503, 500, 429, 404]) +func aServerThatCannotAnswerKeepsTheSession(status: Int) async { + let server = ScriptedAuthServer(["/auth/me": [.status(status, #"{"error":"unavailable"}"#)]]) + let vault = MemoryTokenVault(stored) + #expect(isUnreachable(await restore(server, vault))) + #expect(vault.tokens == stored) + #expect(server.paths == ["/auth/me"]) +} + +@Test func aCaptivePortalAnsweringForTheServerKeepsTheSession() async { + let server = ScriptedAuthServer(["/auth/me": [.status(200, "Sign in to Wi-Fi")]]) + let vault = MemoryTokenVault(stored) + #expect(isUnreachable(await restore(server, vault))) + #expect(vault.tokens == stored) +} + +@Test(arguments: [ScriptedAuthServer.Reply.offline, .status(500, #"{"error":"refresh failed"}"#), + .status(502, "Bad Gateway"), .status(200, "captive")]) +func aRefreshThatIsNotARefusalKeepsTheSession(refresh: ScriptedAuthServer.Reply) async { + let server = ScriptedAuthServer(["/auth/me": [.status(401, #"{"error":"unauthorized"}"#)], "/auth/refresh": [refresh]]) + let vault = MemoryTokenVault(stored) + #expect(isUnreachable(await restore(server, vault))) + #expect(vault.tokens == stored) + #expect(!vault.cleared) + #expect(server.paths == ["/auth/me", "/auth/refresh"]) +} + +@Test(arguments: [401, 400]) +func aRefusedRefreshSignsOut(status: Int) async { + let server = ScriptedAuthServer([ + "/auth/me": [.status(401, #"{"error":"unauthorized"}"#)], + "/auth/refresh": [.status(status, #"{"error":"session not found"}"#)], + "/auth/logout": [.status(401, #"{"error":"unauthorized"}"#)], + ]) + let vault = MemoryTokenVault(stored) + #expect(isSignedOut(await restore(server, vault))) + #expect(vault.tokens == nil) + #expect(vault.cleared) + #expect(server.paths == ["/auth/me", "/auth/refresh", "/auth/logout"]) +} + +@Test func aStaleAccessTokenIsRefreshedAndTheNewSessionKept() async { + let server = ScriptedAuthServer([ + "/auth/me": [.status(401, #"{"error":"unauthorized"}"#), .status(200, user)], + "/auth/refresh": [.status(200, refreshed)], + ]) + let vault = MemoryTokenVault(stored) + guard case .signedIn = await restore(server, vault) else { + Issue.record("Expected a signed-in outcome") + return + } + #expect(vault.tokens == AuthTokens(accessToken: "access-2", refreshToken: "refresh-2")) + #expect(server.authorizations(for: "/auth/me") == ["Bearer access-1", "Bearer access-2"]) +} + +@Test func aFreshTokenTheServerStillRefusesSignsOut() async { + let server = ScriptedAuthServer([ + "/auth/me": [.status(401, #"{"error":"unauthorized"}"#), .status(401, #"{"error":"unauthorized"}"#)], + "/auth/refresh": [.status(200, refreshed)], + "/auth/logout": [.status(204, "")], + ]) + let vault = MemoryTokenVault(stored) + #expect(isSignedOut(await restore(server, vault))) + #expect(vault.cleared) +} + +@Test func onlyARefusalEndsASession() { + #expect(APIError.endsSession(APIError.sessionExpired)) + #expect(APIError.endsSession(APIError.notAuthenticated)) + for error: Error in [APIError.network, APIError.http(503, nil), APIError.decoding, APIError.cancelled, + APIError.noPush, URLError(.timedOut), CancellationError()] { + #expect(!APIError.endsSession(error)) + } +} diff --git a/Tests/InkletMacTests/WebAuthCallbackTests.swift b/Tests/InkletMacTests/WebAuthCallbackTests.swift new file mode 100644 index 0000000..df61d29 --- /dev/null +++ b/Tests/InkletMacTests/WebAuthCallbackTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import InkletMac + +// `ASWebAuthenticationSession` hands back any `inklet:` URL. Tokens count only +// when they arrive at exactly the portal's callback address. + +private func parse(_ raw: String) -> WebAuthCallback? { + WebAuthCallback(URL(string: raw)!) +} + +@Test func thePortalsCallbackCarriesTheSession() { + #expect(parse("inklet://auth/callback?accessToken=a.b.c&refreshToken=r-1") + == .tokens(AuthTokens(accessToken: "a.b.c", refreshToken: "r-1"))) + // Scheme and host are case-insensitive; the order of the query is not a rule. + #expect(parse("INKLET://Auth/callback?refreshToken=r-1&accessToken=a.b.c") + == .tokens(AuthTokens(accessToken: "a.b.c", refreshToken: "r-1"))) +} + +@Test func aFailureIsReportedAsOne() { + #expect(parse("inklet://auth/callback?error=oauth_failed") == .failure("oauth_failed")) + // An error wins over tokens that came with it. + #expect(parse("inklet://auth/callback?error=oauth_failed&accessToken=a&refreshToken=r") == .failure("oauth_failed")) +} + +@Test func tokensAnywhereElseAreNotASignIn() { + for raw in [ + "inklet://evil/callback?accessToken=a&refreshToken=r", + "inklet://auth/other?accessToken=a&refreshToken=r", + "inklet://auth/callback/extra?accessToken=a&refreshToken=r", + "inklet://auth/Callback?accessToken=a&refreshToken=r", + "inklet://auth?accessToken=a&refreshToken=r", + "inklet-mac://auth/callback?accessToken=a&refreshToken=r", + "https://auth/callback?accessToken=a&refreshToken=r", + "inklet://user@auth/callback?accessToken=a&refreshToken=r", + "inklet://auth:8080/callback?accessToken=a&refreshToken=r", + "inklet://auth/callback?accessToken=a&refreshToken=r#fragment", + ] { + #expect(parse(raw) == nil, "\(raw)") + } +} + +@Test func aCallbackWithoutAWholeSessionIsNothing() { + for raw in [ + "inklet://auth/callback", + "inklet://auth/callback?accessToken=a", + "inklet://auth/callback?refreshToken=r", + "inklet://auth/callback?accessToken=&refreshToken=r", + "inklet://auth/callback?accessToken=a&accessToken=b&refreshToken=r", + ] { + #expect(parse(raw) == nil, "\(raw)") + } +} diff --git a/Tests/InkletPresentationKitTests/InkletServerTests.swift b/Tests/InkletPresentationKitTests/InkletServerTests.swift new file mode 100644 index 0000000..ca0a7d2 --- /dev/null +++ b/Tests/InkletPresentationKitTests/InkletServerTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import InkletPresentationKit + +// One API host for the app and the widget, overridable per build through the +// Info.plist; anything but a plain https URL falls back to the default. + +@Test func withoutAnOverrideTheDefaultHostIsUsed() { + #expect(InkletServer.defaultAPIBase.absoluteString == "https://dev.iminklet.com") + // A test process has no InkletAPIBaseURL key. + #expect(InkletServer.apiBase == InkletServer.defaultAPIBase) + for raw in [nil, "", " "] as [String?] { + #expect(InkletServer.apiBase(from: raw) == InkletServer.defaultAPIBase) + } +} + +@Test func anHTTPSOverrideIsTakenAndJoinsPathsTheSameWay() { + #expect(InkletServer.apiBase(from: "https://staging.example.com/").absoluteString == "https://staging.example.com") + let prefixed = InkletServer.apiBase(from: "https://example.com/inklet") + #expect(prefixed.appending(path: "api/virtual-display-widget/abc").absoluteString + == "https://example.com/inklet/api/virtual-display-widget/abc") + #expect(URL(string: prefixed.absoluteString + "/" + "api/devices")?.absoluteString == "https://example.com/inklet/api/devices") +} + +@Test func anythingButPlainHTTPSFallsBack() { + for raw in ["http://staging.example.com", "https://user:secret@example.com", "https://example.com?x=1", + "https://example.com#top", "staging.example.com", "not a url", "https://"] { + #expect(InkletServer.apiBase(from: raw) == InkletServer.defaultAPIBase, "\(raw)") + } +} diff --git a/WidgetExtension/Info.plist b/WidgetExtension/Info.plist index ea4be42..b26a23e 100644 --- a/WidgetExtension/Info.plist +++ b/WidgetExtension/Info.plist @@ -6,6 +6,8 @@ $(INKLET_APP_GROUP) InkletWidgetStorageMode $(INKLET_WIDGET_STORAGE_MODE) + InkletAPIBaseURL + $(INKLET_API_BASE_URL) CFBundleDisplayNameinklet NSExtension diff --git a/WidgetExtension/InkletMac.entitlements b/WidgetExtension/InkletMac.entitlements deleted file mode 100644 index 486d1ca..0000000 --- a/WidgetExtension/InkletMac.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.iminklet.portal - - - From 2026c1b287a43abcebc6e8d08e63a9e4ce9eceb9 Mon Sep 17 00:00:00 2001 From: Kevin Zhong Date: Tue, 22 Sep 2026 18:14:16 -0500 Subject: [PATCH 2/3] build(release): build the verified tag, test before signing, stable tags from main - A first job checks the tag's format and refuses a stable tag whose commit is not on origin/main, before any release is created. Pre-release tags may still come from a branch. - The build checks out exactly the commit that job verified. A manual run takes only the tag and checks its Release exists, instead of building main HEAD under a stale default version. - swift test runs before the signing certificate is imported. --- .github/workflows/release.yml | 69 ++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9018f65..36a93c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,20 +6,56 @@ on: - "v*" workflow_dispatch: inputs: - version: - description: inklet Portal for macOS version - required: true - default: "0.2.0" + # No default: a stale one once rebuilt main under an old version's name. + # The version comes from the tag, and the build from the tag's commit. release_tag: - description: Existing GitHub Release that receives the macOS assets + description: Tag of the existing GitHub Release to rebuild (e.g. v0.2.4-alpha.4) required: true - default: "v0.2.0-alpha.1" permissions: contents: write jobs: + # Settles what is being released before anything is created, deleted or + # built: the tag, its version, and the exact commit every later job uses. + resolve-tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + version: ${{ steps.tag.outputs.version }} + sha: ${{ steps.tag.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }} + + - name: Check the tag + id: tag + env: + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::$TAG is not a release tag (vMAJOR.MINOR.PATCH, optionally -alpha.N or -beta.N)" + exit 1 + fi + # Stable releases reach every user, so they ship only what main has. + # Pre-releases may come from any branch; alphas ship from feature branches. + if [[ "$TAG" != *-* ]] && ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::$TAG is a stable tag, but $(git rev-parse --short HEAD) is not on main" + exit 1 + fi + # A rebuild has nowhere to put its assets without the Release. + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + create-release: + needs: resolve-tag if: github.event_name == 'push' runs-on: ubuntu-latest steps: @@ -44,25 +80,30 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-mac: - needs: create-release + needs: [resolve-tag, create-release] if: >- always() && + needs.resolve-tag.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.create-release.result == 'success') runs-on: macos-26 steps: + # The commit resolve-tag checked, not whatever the branch or tag points + # at by now: the build must be exactly the release it is labelled as. - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-tag.outputs.sha }} - name: Resolve release metadata env: - INPUT_VERSION: ${{ inputs.version }} - INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + TAG: ${{ needs.resolve-tag.outputs.tag }} + VERSION: ${{ needs.resolve-tag.outputs.version }} run: | - VERSION="$INPUT_VERSION" - RELEASE_TAG="$INPUT_RELEASE_TAG" - if [[ -z "$VERSION" ]]; then VERSION="${GITHUB_REF_NAME#v}"; fi - if [[ -z "$RELEASE_TAG" ]]; then RELEASE_TAG="$GITHUB_REF_NAME"; fi echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" - echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV" + echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" + + # Before the signing certificate is on the runner. Same step as ci.yml. + - name: Test native shared data and widget routing + run: xcrun swift test - name: Import Developer ID certificate env: From 318ba21d234a699b6c4f3bb89c3c40f24a855870 Mon Sep 17 00:00:00 2001 From: Kevin Zhong Date: Tue, 22 Sep 2026 18:14:16 -0500 Subject: [PATCH 3/3] docs: take the backend host, login steps, and local paths out of the docs The repository is public. The acceptance notes named the server's IP, its daemon user, how to log in, and an internal tracker; the audit linked absolute paths on one machine. Say what they meant without them. --- docs/acceptance-2026-09-11.md | 29 ++++++++++--------- docs/macos-implementation-audit-2026-09-05.md | 24 +++++++-------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/docs/acceptance-2026-09-11.md b/docs/acceptance-2026-09-11.md index 89cbc7b..66f1ce9 100644 --- a/docs/acceptance-2026-09-11.md +++ b/docs/acceptance-2026-09-11.md @@ -2,13 +2,12 @@ ## Installed artifacts -- Native macOS app: `/Users/clck/Applications/inklet.app`. +- Native macOS app: installed in the tester's `~/Applications/inklet.app`. - Developer ID signed arm64 Release bundle with embedded `InkletWidgets.appex`. - Shared App Group: `5X22PG79XX.com.iminklet.mac`. -- Previous native app backup: `/private/tmp/inklet-ready-backups/inklet-before-update.app`. -- Release output: `/private/tmp/inklet-ready-release/inklet.app`. +- The previously installed app was kept as a backup; the Release output was built into a local scratch directory. - iPhone 17 Pro and iPad Pro 13-inch (M5) simulators have the latest iOS app/widget installed. -- iOS build: `/private/tmp/inklet-ready-ios/Build/Products/Debug-iphonesimulator/portal.app`. +- iOS build: a Debug simulator build of `portal.app` in a local scratch directory. ## Verified @@ -31,24 +30,26 @@ and handles the unavailable backend without crashing. No lock-screen or credenti protection was bypassed. A live acceptance harness using the actual native controller and Widget reader is -prepared at `/private/tmp/inklet-live-qa`. It compiles, but its network acceptance test +prepared locally. It compiles, but its network acceptance test has not run; the profile-only probe reported no loadable credentials. Run only after service deployment and legitimate account access are available. ## Release and current blockers - Final backend head `3bc28a9` received a new approval and was merged as `3aeb9252eadfe23e566446a1d575d7e454474936`. -- Backend `v0.26.0` Build & Release succeeded: https://github.com/inklethq/backend/actions/runs/34579915128 -- Worker `v0.9.0` Build & Release succeeded: https://github.com/inklethq/worker/actions/runs/34579920601 +- Backend `v0.26.0` Build & Release succeeded. +- Worker `v0.9.0` Build & Release succeeded. - Final installed macOS App and Widget build number: `2026091102`. -- Light/dark and extra-large offline SwiftUI renders were inspected at `/private/tmp/inklet-ready-widget-previews`. -- Deployment task: INK-158, https://agents.123a.club/inklet/issues/6794efd0-291c-4d91-958f-f9c43ddd980d -- The production agent's Claude OAuth remains expired. The backend team found no existing authorized SSH/SSM/Docker context on macmini. Chrome AWS Console also requires IAM sign-in; the sign-in tab is retained for the owner. +- Light/dark and extra-large offline SwiftUI renders were inspected locally. +- Deployment task: INK-158 in the internal tracker. +- Deployment was blocked on operator access to the production environment; no + authorized access path was available to the team at the time. - Last unauthenticated dev probes still returned 404 for both `/api/app/v1/presentations` and `/api/virtual-displays`. No deployment or live main-chain acceptance is claimed. -AWS work is paused at the user's explicit request because they do not have access. -The following recovery steps are retained for a future authorized resumption. -After legitimate AWS login: connect to EC2 `54.158.246.40`, use the Multica daemon's system user, run `claude`, then `/login`, and complete the normal Anthropic organization authorization. Do not send tokens or authorization codes through chat. The backend team can then resume the fixed-image deployment and smoke tests. +Deployment work was paused at the user's explicit request because they did not have +access. Recovery steps for an authorized resumption are kept with the operators, not +in this repository; once access is restored the backend team can resume the +fixed-image deployment and smoke tests. A proposed 15-minute unattended continuation automation was rejected by automatic approval review. It was NOT created. A separate explicit authorization question is pending; current task authorization has not been treated as approval to bypass that rejection. @@ -84,7 +85,7 @@ Independently verified from this Mac: - Independent HTTP fetch using only that display's existing Widget credential returned 200 with valid PNG (36,295 bytes), revision 1; ETag revalidation returned 304. No credentials were printed or transmitted to the backend agent. -- Retrieved frame: `/private/tmp/inklet-live-widget-frame.png`. +- The retrieved frame was saved locally and inspected. - My inklet is retained for the user. Findings were sent to the backend agent for INK-158. Still pending: AI auto with Pro entitlement, actual macOS desktop widget gallery diff --git a/docs/macos-implementation-audit-2026-09-05.md b/docs/macos-implementation-audit-2026-09-05.md index bd0d73d..5a96fc3 100644 --- a/docs/macos-implementation-audit-2026-09-05.md +++ b/docs/macos-implementation-audit-2026-09-05.md @@ -2,7 +2,7 @@ 审计日期:2026-09-05。对象:`inklet-app/macos` 的当前工作区,包括尚未提交的 Presentation / Widget 改动。 -> 同日后续实现:用户随后要求完成 Widget。已新增 small Quick Send、medium Activity、large Virtual Display、独立 Xcode 扩展工程、嵌入打包、深链导航、账户隔离缓存及 App 内 Virtual Display 页。P0-2 的源码/打包缺口已经处理,P0-3 的最新画面查看入口已补;下面保留最初审计快照。大号在线生成仍依赖 P0-1 的后端实现,Developer ID 签名后的系统 Widget 与共享容器验收仍需完成。当前构建和测试说明见 [Widget README](/Users/clck/Desktop/Workspace/inklet-app/macos/WidgetExtension/README.md)。 +> 同日后续实现:用户随后要求完成 Widget。已新增 small Quick Send、medium Activity、large Virtual Display、独立 Xcode 扩展工程、嵌入打包、深链导航、账户隔离缓存及 App 内 Virtual Display 页。P0-2 的源码/打包缺口已经处理,P0-3 的最新画面查看入口已补;下面保留最初审计快照。大号在线生成仍依赖 P0-1 的后端实现,Developer ID 签名后的系统 Widget 与共享容器验收仍需完成。当前构建和测试说明见 [Widget README](../WidgetExtension/README.md)。 ## 结论与版本边界 @@ -10,7 +10,7 @@ - App 仓库基线:`d3607f4`,存在未提交修改和未跟踪的 Widget / Presentation 源码。 - 后端仓库基线:`6507e37`,检查时工作区干净。 -- 本机 `/Users/clck/Applications/inklet.app` 仍显示旧的 **Push something**;当前源码显示 **Create a Presentation**。安装版和工作区已有构建的可执行文件 SHA-256 不同,不能将它们视为同一版本。 +- 本机 `~/Applications/inklet.app` 仍显示旧的 **Push something**;当前源码显示 **Create a Presentation**。安装版和工作区已有构建的可执行文件 SHA-256 不同,不能将它们视为同一版本。 - 本次只审查和验证,没有修改产品源码,没有发送内容、解绑设备、退出登录或调整设置开关。 - 已实际查看安装版 Home、General、Notifications;Home 能加载真实设备与内容统计。其他行为以源码和后端契约核对为主,未宣称完成在线发送或真机收屏验收。 @@ -26,7 +26,7 @@ Mac 的 `generatePresentation()` 使用登录会话 JWT 请求 `/api/app/v1/cont 这来自未提交的改动:原先 `AppModel.send()` 调用 `uploadBundle()`;现在切到 `generatePresentation()`,旧方法已经没有 UI 调用者。当前工作区默认发送链路需要补齐后端与客户端契约后才能发布。此结论针对当前检出的后端,不等于已经验证线上部署版本。 -证据:[发送入口](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:271)、[新 API](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/InkletAPI.swift:231)、[后端路由](/Users/clck/Desktop/Workspace/inklet-backend/cmd/server/router.go:198)、[PAT 鉴权](/Users/clck/Desktop/Workspace/inklet-backend/internal/sdk/middleware.go:66)、[后端请求模型](/Users/clck/Desktop/Workspace/inklet-backend/internal/sdk/content.go:28)、[后端 Presentation 模型](/Users/clck/Desktop/Workspace/inklet-backend/internal/sdk/presentation.go:71)。 +证据:[发送入口](../Sources/InkletMac/Models/AppModel.swift#L271)、[新 API](../Sources/InkletMac/Services/InkletAPI.swift#L231)、后端路由(`inklet-backend/cmd/server/router.go:198`)、PAT 鉴权(`inklet-backend/internal/sdk/middleware.go:66`)、后端请求模型(`inklet-backend/internal/sdk/content.go:28`)、后端 Presentation 模型(`inklet-backend/internal/sdk/presentation.go:71`)。 ### P0-2:Widget 有源码,但没有可安装的扩展产物 @@ -36,7 +36,7 @@ Mac 的 `generatePresentation()` 使用登录会话 JWT 请求 `/api/app/v1/cont 此外,Widget 发出 `inklet://presentations/latest`,主 App 没有 URL scheme 声明和相应导航处理,点击 Widget 的链路也未接通。目前仅支持 medium,small / large 未实现;旧稿中的 Quick Send / Activity Widget 也不在这次实现中。 -证据:[扩展待接说明](/Users/clck/Desktop/Workspace/inklet-app/macos/WidgetExtension/README.md:3)、[Package](/Users/clck/Desktop/Workspace/inklet-app/macos/Package.swift:17)、[打包脚本](/Users/clck/Desktop/Workspace/inklet-app/macos/Scripts/build-app.sh:32)、[Widget 深链](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletPresentationWidget/InkletPresentationWidget.swift:56)。 +证据:[扩展待接说明](../WidgetExtension/README.md#L3)、[Package](../Package.swift#L17)、[打包脚本](../Scripts/build-app.sh#L32)、[Widget 深链](../Sources/InkletPresentationWidget/InkletPresentationWidget.swift#L56)。 ### P0-3:新生成内容缺少 App 内的查看与管理闭环 @@ -44,7 +44,7 @@ Mac 的 `generatePresentation()` 使用登录会话 JWT 请求 `/api/app/v1/cont Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独立的 `sdk_contents` 数据模型,没有看到将新生成内容补入旧 raw-items 列表的桥接。因此,新 Content 如何进入 Knowledge 和活动统计仍需实现/明确;不能认为发送后的 `loadKnowledge()` 就能自动完成同步。 -证据:[生成后处理](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:274)、[缓存](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletPresentationKit/PresentationCache.swift:41)、[导航入口](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/RootView.swift:3)、[旧列表读取](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:95)、[SDK Content 存储](/Users/clck/Desktop/Workspace/inklet-backend/internal/sdk/repository.go:168)。 +证据:[生成后处理](../Sources/InkletMac/Models/AppModel.swift#L274)、[缓存](../Sources/InkletPresentationKit/PresentationCache.swift#L41)、[导航入口](../Sources/InkletMac/Views/RootView.swift#L3)、[旧列表读取](../Sources/InkletMac/Models/AppModel.swift#L95)、SDK Content 存储(`inklet-backend/internal/sdk/repository.go:168`)。 ## 二、功能覆盖清单 @@ -84,7 +84,7 @@ Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独 | 关于与帮助 | 部分实现 | 有 Help 外链;没有设计稿的 About 设置页、更新检查、隐私/联系入口集合 | | 本地笔记同步、Spotlight、专门 Raycast 集成、多账号 | 未实现、后期范围 | 旧稿 P2,不建议阻塞核心版本 | -主要证据:[App 场景](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/InkletMacApp.swift:18)、[设置开关](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/SettingsView.swift:23)、[通知设置](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/SettingsView.swift:114)、[登录](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/LoginView.swift:20)、[Manual 限制](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/ComposerView.swift:120)、[粘贴处理](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/ComposerView.swift:384)、[动态配色](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Theme/InkletTheme.swift:9)。 +主要证据:[App 场景](../Sources/InkletMac/InkletMacApp.swift#L18)、[设置开关](../Sources/InkletMac/Views/SettingsView.swift#L23)、[通知设置](../Sources/InkletMac/Views/SettingsView.swift#L114)、[登录](../Sources/InkletMac/Views/LoginView.swift#L20)、[Manual 限制](../Sources/InkletMac/Views/ComposerView.swift#L120)、[粘贴处理](../Sources/InkletMac/Views/ComposerView.swift#L384)、[动态配色](../Sources/InkletMac/Theme/InkletTheme.swift#L9)。 ## 三、有实现但尚未收尾的行为问题 @@ -98,7 +98,7 @@ Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独 - **P1,Services 登录生命周期不完整。** 仅登录成功后 install;冷启动投递时没有待登录 payload 队列。退出时又没有卸载 provider,可能继续打开不能发送的 Composer。 - **P2,键盘功能不完整。** ⌘V 图片转附件只存在工具栏按钮路径;没有设计稿的 ⌘⌫ 清空整份草稿。菜单中的创建快捷键固定 ⌘⇧I,不随用户录制的全局快捷键修改;二者当前允许不同。 -证据:[权限与复制](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/SelectionContext.swift:22)、[异步抓取入口](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:330)、[激活窗口](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/ComposerPanel.swift:19)、[AppleScript 执行](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/AppContext.swift:290)、[Services 接收](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/ServicesProvider.swift:40)、[登录时安装](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/InkletMacApp.swift:80)。 +证据:[权限与复制](../Sources/InkletMac/Services/SelectionContext.swift#L22)、[异步抓取入口](../Sources/InkletMac/Models/AppModel.swift#L330)、[激活窗口](../Sources/InkletMac/Views/ComposerPanel.swift#L19)、[AppleScript 执行](../Sources/InkletMac/Services/AppContext.swift#L290)、[Services 接收](../Sources/InkletMac/Services/ServicesProvider.swift#L40)、[登录时安装](../Sources/InkletMac/InkletMacApp.swift#L80)。 ### 2. 发送可靠性与模式覆盖 @@ -111,7 +111,7 @@ Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独 - **P2,生成成功与可查看成功混为一体。** PNG 下载/缓存失败直接表现为整次发送失败;没有复用已生成 Presentation 的“重新下载/更新 Widget”。缺 PNG 时又允许写入空图缓存并报告成功。 - **P2,模式语义需整理。** 当前 Manual 是单图直推,接近 Hardcode;没有通用的“指定硬件、AI 排版”路径。`syncTarget()` 在通用入口不会把之前的 Manual 重置成 Auto,因此“Create Presentation”也可能保留上一次硬件模式。 -证据:[发送状态](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/ComposerView.swift:410)、[幂等键](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/InkletAPI.swift:261)、[文件读取](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/ComposerView.swift:373)、[链接校验](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/ComposerView.swift:344)、[后端附件规则](/Users/clck/Desktop/Workspace/inklet-backend/internal/sdk/assets.go:9)、[模式同步](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/ComposerView.swift:84)。 +证据:[发送状态](../Sources/InkletMac/Views/ComposerView.swift#L410)、[幂等键](../Sources/InkletMac/Services/InkletAPI.swift#L261)、[文件读取](../Sources/InkletMac/Views/ComposerView.swift#L373)、[链接校验](../Sources/InkletMac/Views/ComposerView.swift#L344)、后端附件规则(`inklet-backend/internal/sdk/assets.go:9`)、[模式同步](../Sources/InkletMac/Views/ComposerView.swift#L84)。 ### 3. 设备管理与 Knowledge @@ -125,7 +125,7 @@ Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独 - **P2,错误反馈不统一。** 侧栏 Show Next 用 `try?`,`reloadDevice()` 也吞掉错误。设备与 Knowledge 并发加载共享一个 `loadError`,设备成功可覆盖另一支的失败提示。 - **P2,配对完成后可能选中错误设备。** Mac 用 `devices.last` 视为新设备,后端设备按创建时间倒序排列,也不是绑定时间顺序;应通过新增 ID 集合识别。 -证据:[队列与标题](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/DeviceDetailView.swift:99)、[实际队列排序](/Users/clck/Desktop/Workspace/inklet-backend/internal/iot/repository.go:140)、[历史加载](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:186)、[Knowledge 加载](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:95)、[标题任务](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:142)、[侧栏操作](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/RootView.swift:98)、[新设备选择](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Views/PairDisplayView.swift:57)。 +证据:[队列与标题](../Sources/InkletMac/Views/DeviceDetailView.swift#L99)、实际队列排序(`inklet-backend/internal/iot/repository.go:140`)、[历史加载](../Sources/InkletMac/Models/AppModel.swift#L186)、[Knowledge 加载](../Sources/InkletMac/Models/AppModel.swift#L95)、[标题任务](../Sources/InkletMac/Models/AppModel.swift#L142)、[侧栏操作](../Sources/InkletMac/Views/RootView.swift#L98)、[新设备选择](../Sources/InkletMac/Views/PairDisplayView.swift#L57)。 ### 4. 会话与本地数据生命周期 @@ -136,7 +136,7 @@ Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独 - **P2,Release 的 Keychain 写失败仍回退到 JSON 文件。** 当前注释称正式版始终使用 Keychain,实际 `save()` 在 Keychain 失败时继续落盘;需要明确正式版失败策略。此次没有读取实际凭据文件。 - **P2,账号套餐不会随常规 Refresh 更新。** 常规刷新只拉设备和 raw-items,account 来自登录/恢复时的 user,网页升级后可能继续显示旧套餐。 -证据:[恢复会话](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/InkletAPI.swift:106)、[重置逻辑](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Models/AppModel.swift:44)、[退出登录](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/Session.swift:88)、[TokenStore fallback](/Users/clck/Desktop/Workspace/inklet-app/macos/Sources/InkletMac/Services/TokenStore.swift:36)。 +证据:[恢复会话](../Sources/InkletMac/Services/InkletAPI.swift#L106)、[重置逻辑](../Sources/InkletMac/Models/AppModel.swift#L44)、[退出登录](../Sources/InkletMac/Services/Session.swift#L88)、[TokenStore fallback](../Sources/InkletMac/Services/TokenStore.swift#L36)。 ## 四、已完成的工程基础与尚缺的验收 @@ -153,7 +153,7 @@ Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独 - 本机安装版的 Home 能读取真实数据,General / Notifications 确认存在上述设置项;没有执行发送、解绑、登录切换或权限申请。 - 未完成本次 universal Release 重建、线上新 API 联调、真实硬件收屏、Widget 添加到桌面、发布证书和公证服务验收。 -证据:[现有测试](/Users/clck/Desktop/Workspace/inklet-app/macos/Tests/InkletPresentationKitTests/PresentationCacheTests.swift:5)、[CI](/Users/clck/Desktop/Workspace/inklet-app/.github/workflows/ci.yml:29)、[发布流程](/Users/clck/Desktop/Workspace/inklet-app/.github/workflows/release.yml:45)。 +证据:[现有测试](../Tests/InkletPresentationKitTests/PresentationCacheTests.swift#L5)、[CI](../.github/workflows/ci.yml#L29)、[发布流程](../.github/workflows/release.yml#L45)。 ## 五、旧设计中不能直接当作欠账的项目 @@ -164,7 +164,7 @@ Home 热力图与 Knowledge 仍读取旧 `/api/raw-items`。当前 SDK 使用独 5. **Manual 展示时长:** 旧稿将接口列为待定,当前没有端到端支持。先确认产品仍需要此语义,再补接口和 UI。 6. **Firefox 上下文:** 当前明确不支持自动读取 tab;其他应用可通过 Services / 手动粘贴提交,需作为支持范围说明而非假定所有浏览器可用。 -证据:[NFC 协议](/Users/clck/Desktop/Workspace/inklet-backend/docs/api/nfc-v2-protocol.md:7)、[旧设计分期](/Users/clck/Desktop/Workspace/inklet-app/macos/design/index.html:1404)、[平台要求](/Users/clck/Desktop/Workspace/inklet-app/macos/Package.swift:6)。 +证据:NFC 协议(`inklet-backend/docs/api/nfc-v2-protocol.md:7`)、[旧设计分期](../design/index.html#L1404)、[平台要求](../Package.swift#L6)。 ## 六、建议实施顺序