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: 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 - - - 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)。 ## 六、建议实施顺序