diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cacc4fe..837c0428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ## [Unreleased] ### Fixed +- Crash recovery no longer opens a second window full of empty workspaces when only some detached terminal sessions can be reattached. The recovery window now contains only live recovered sessions and closes when none recover. +- Closing a window now tears down every timer, observer, task, panel, and workspace it owns, so closed windows cannot keep empty workspaces alive or reappear in a later session snapshot. +- The title-bar hide-sidebar button now always toggles the sidebar belonging to its own window instead of relying on whichever window was last active. +- Provider usage is now available on demand from a sidebar icon and shows every signed-in supported provider, without continuously polling while the popover is closed. +- Browser downloads now keep the completed temporary file available when moving it to the destination fails, so a finalization error cannot silently discard the download. +- Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. +- An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. +- Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. +- Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. - Browser favicons now have strict download and decode limits, so a hostile or broken site cannot consume unbounded memory or restore a stale icon after navigation. @@ -22,6 +31,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - A restart after an update no longer kills every terminal when the new app comes up faster than the background session-holder notices the old one is gone. The app now waits out that window instead of giving up, escrow sockets no longer leak into shell processes (which silently delayed that detection), and a session that falls back anyway keeps its reattach records on disk while its process is still alive instead of deleting them. ### Changed +- The local diagnostics logger now keeps one file handle open between records and reopens it only after rotation, removing repeated directory checks and open/seek/close work from socket and session activity. - Ghostty is updated to the current upstream implementation with complete Kitty graphics animation and placement support. Programa now builds the fork and helper tools with Zig 0.16. - Background work is lighter: unchanged config reloads are ignored, idle output polling stops, duplicate update checks are gone, and update logs rotate at 1 MiB. - Sidebar resizing now uses native macOS pointer capture, so the resize cursor and drag stay stable when crossing terminal or browser content. Command-palette, review-panel, find-field, and workspace telemetry updates also avoid unnecessary whole-window redraws. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 2515f10d..5c9468f9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -316,6 +316,7 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; E57B23C339CE9F1226BC4478 /* ShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C838E51ABC8DBDCC2040D56 /* ShortcutRoutingTests.swift */; }; F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; + A9D4C7E2B16F4830C5A70212 /* MobileBridgeConnectionRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D4C7E2B16F4830C5A70211 /* MobileBridgeConnectionRegistryTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; @@ -736,6 +737,7 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; 6C838E51ABC8DBDCC2040D56 /* ShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRoutingTests.swift; sourceTree = ""; }; F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = ""; }; + A9D4C7E2B16F4830C5A70211 /* MobileBridgeConnectionRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridgeConnectionRegistryTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; @@ -1266,6 +1268,7 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, 6C838E51ABC8DBDCC2040D56 /* ShortcutRoutingTests.swift */, F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, + A9D4C7E2B16F4830C5A70211 /* MobileBridgeConnectionRegistryTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, @@ -1747,6 +1750,7 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, E57B23C339CE9F1226BC4478 /* ShortcutRoutingTests.swift in Sources */, F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */, + A9D4C7E2B16F4830C5A70212 /* MobileBridgeConnectionRegistryTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0e8342ee..98f8693e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -10241,6 +10241,23 @@ } } }, + "settings.phone.devices.revokeFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not remove this device. Its connection remains active. Try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスを削除できませんでした。接続は引き続き有効です。もう一度お試しください。" + } + } + } + }, "settings.phone.devices.title": { "extractionState": "manual", "localizations": { @@ -10926,13 +10943,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Show Claude Quota" + "value": "Show Provider Usage" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Claude使用量を表示" + "value": "プロバイダーの使用量を表示" } } } @@ -10943,13 +10960,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Show remaining Claude Code usage quota in the sidebar footer." + "value": "Show on-demand usage for providers with available usage data in the sidebar footer." } }, "ja": { "stringUnit": { "state": "translated", - "value": "サイドバーのフッターにClaude Codeの残り使用量を表示します。" + "value": "サイドバーのフッターに、利用データがあるプロバイダーの使用量を必要なときだけ表示します。" } } } @@ -12078,6 +12095,312 @@ } } }, + "sidebar.usage.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider Usage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダーの使用量" + } + } + } + }, + "sidebar.usage.duration.days": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@日" + } + } + } + }, + "sidebar.usage.duration.hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@h" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@時間" + } + } + } + }, + "sidebar.usage.duration.minutes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@m" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@分" + } + } + } + }, + "sidebar.usage.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to a supported provider, then refresh." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対応するプロバイダーにサインインしてから更新してください。" + } + } + } + }, + "sidebar.usage.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No usage available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用量を取得できません" + } + } + } + }, + "sidebar.usage.error.claudeInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude usage data is invalid." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claudeの使用量データが無効です。" + } + } + } + }, + "sidebar.usage.error.claudeRead": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude usage could not be read." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claudeの使用量を読み込めませんでした。" + } + } + } + }, + "sidebar.usage.error.codexInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex returned invalid usage data." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexから無効な使用量データが返されました。" + } + } + } + }, + "sidebar.usage.error.codexRead": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex usage could not be read." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexの使用量を読み込めませんでした。" + } + } + } + }, + "sidebar.usage.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading usage…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用量を読み込み中…" + } + } + } + }, + "sidebar.usage.provider.claude": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude" + } + } + } + }, + "sidebar.usage.provider.codex": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + } + } + }, + "sidebar.usage.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, + "sidebar.usage.refreshing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refreshing usage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用量を更新中" + } + } + } + }, + "sidebar.usage.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider Usage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロバイダーの使用量" + } + } + } + }, + "sidebar.usage.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用不可" + } + } + } + }, + "sidebar.usage.window.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@ used, resets in %3$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@、%2$@使用、%3$@後にリセット" + } + } + } + }, "sidebar.remote.help.connected": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ff80410c..74fc75d5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -816,6 +816,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let sidebarState: SidebarState let sidebarSelectionState: SidebarSelectionState weak var window: NSWindow? + weak var observedWindow: NSWindow? + var willCloseObserver: NSObjectProtocol? + var willCloseObserverGeneration: UUID? init( windowId: UUID, @@ -1884,57 +1887,102 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return } - let windowId = createMainWindow() - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), - let firstWorkspace = context.tabManager.tabs.first else { + let candidates = orphans.map { + (sessionId: $0.sessionId, workingDirectory: $0.meta.workingDirectory) + } + guard let result = reconcileOrphanedEscrowedSessions( + candidates: candidates, + attemptRecovery: { candidate, workspace in + guard let freshPanelId = workspace.panels.keys.first, + let paneId = workspace.paneId(forPanelId: freshPanelId) else { + return false + } + + guard workspace.revivePanel( + sessionId: candidate.sessionId, + inPane: paneId, + workingDirectory: candidate.workingDirectory + ) != nil else { + return false + } + + // The revived panel now lives in the same pane as the fresh + // placeholder panel the tab started with -- close the + // placeholder so the tab ends up with exactly one (revived) + // panel, never a leftover empty one. + if !workspace.closePanel(freshPanelId, force: true) { + dilog( + "escrow.reconcile", + "session=\(candidate.sessionId.prefix(8)) outcome=revived reason=placeholder_close_failed panel=\(freshPanelId.uuidString.prefix(8))" + ) + } + if let workingDirectory = candidate.workingDirectory, + !workingDirectory.isEmpty { + workspace.setCustomTitle(workingDirectory) + } + return true + } + ) else { dilog("escrow.reconcile", "found=\(orphans.count) recovered=0 reason=no_window") return } + + dilog("escrow.reconcile", "found=\(orphans.count) recovered=\(result.recoveredCount)") + } + + func reconcileOrphanedEscrowedSessions( + candidates: [(sessionId: String, workingDirectory: String?)], + attemptRecovery: ( + _ candidate: (sessionId: String, workingDirectory: String?), + _ workspace: Workspace + ) -> Bool + ) -> (windowId: UUID, recoveredCount: Int)? { + guard !candidates.isEmpty else { return nil } + + let windowId = createMainWindow() + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let bootstrapWorkspace = context.tabManager.tabs.first else { + return nil + } let tabManager = context.tabManager var recoveredCount = 0 - for (index, orphan) in orphans.enumerated() { - let workspace: Workspace = index == 0 - ? firstWorkspace - : tabManager.addWorkspace( - workingDirectory: orphan.meta.workingDirectory, - select: false, - autoWelcomeIfNeeded: false - ) + var pendingFailedWorkspace: Workspace? + var shouldCloseBootstrapWorkspace = true + + for candidate in candidates { + let workspace = tabManager.addWorkspace( + workingDirectory: candidate.workingDirectory, + select: false, + autoWelcomeIfNeeded: false + ) - guard let freshPanelId = workspace.panels.keys.first, - let paneId = workspace.paneId(forPanelId: freshPanelId) else { - continue + if shouldCloseBootstrapWorkspace { + tabManager.closeWorkspace(bootstrapWorkspace) + shouldCloseBootstrapWorkspace = false } - - guard workspace.revivePanel( - sessionId: orphan.sessionId, - inPane: paneId, - workingDirectory: orphan.meta.workingDirectory - ) != nil else { - continue + if let failedWorkspace = pendingFailedWorkspace { + tabManager.closeWorkspace(failedWorkspace) + pendingFailedWorkspace = nil } - // The revived panel now lives in the same pane as the fresh - // placeholder panel the tab started with -- close the - // placeholder so the tab ends up with exactly one (revived) - // panel, never a leftover empty one. - workspace.closePanel(freshPanelId, force: true) - if let workingDirectory = orphan.meta.workingDirectory, - !workingDirectory.isEmpty { - workspace.setCustomTitle(workingDirectory) + if attemptRecovery(candidate, workspace) { + recoveredCount += 1 + } else { + pendingFailedWorkspace = workspace } - recoveredCount += 1 } - dilog("escrow.reconcile", "found=\(orphans.count) recovered=\(recoveredCount)") - guard recoveredCount > 0 else { // Every revive attempt failed: nothing to show, and leaving an // empty, oddly-titled window open with only blank placeholder // shells would just confuse whoever opens it next. resolvedWindow(for: context)?.close() - return + return (windowId: windowId, recoveredCount: 0) + } + + if let pendingFailedWorkspace { + tabManager.closeWorkspace(pendingFailedWorkspace) } // Set the window title last, after every workspace mutation above -- @@ -1957,6 +2005,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser defaultValue: "Programa found terminal sessions still running from before the last restart and reattached them into a new window." ) ) + + // `createMainWindow()` persisted the bootstrap workspace before + // reconciliation replaced it. Persist the finalized recovery window + // immediately so another abrupt exit cannot restore that stale shell. + _ = saveSessionSnapshot(includeScrollback: false) + + return (windowId: windowId, recoveredCount: recoveredCount) } private func applySessionWindowSnapshot( @@ -2632,6 +2687,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) } + private func removeMainWindowCloseObserver(from context: MainWindowContext) { + if let observer = context.willCloseObserver { + NotificationCenter.default.removeObserver(observer) + } + context.willCloseObserver = nil + context.willCloseObserverGeneration = nil + context.observedWindow = nil + } + + private func installMainWindowCloseObserver( + for context: MainWindowContext, + window: NSWindow + ) { + if context.observedWindow === window, context.willCloseObserver != nil { + return + } + + removeMainWindowCloseObserver(from: context) + let generation = UUID() + context.observedWindow = window + context.willCloseObserverGeneration = generation + context.willCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self, weak window, generation] note in + MainActor.assumeIsolated { + guard let self, + let window, + let closing = note.object as? NSWindow, + closing === window, + let context = self.mainWindowContexts[ObjectIdentifier(window)], + context.willCloseObserverGeneration == generation, + context.observedWindow === window, + context.window === window else { + return + } + self.unregisterMainWindow(window) + } + } + } + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -2655,30 +2752,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #if DEBUG let priorManagerToken = debugManagerToken(self.tabManager) #endif - if let existing = mainWindowContexts[key] { + let context: MainWindowContext + if let existing = mainWindowContexts[key], existing.windowId == windowId { existing.window = window + context = existing } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - existing.window = window reindexMainWindowContextIfNeeded(existing, for: window) + context = existing } else { - mainWindowContexts[key] = MainWindowContext( + if let displaced = mainWindowContexts[key] { + discardOrphanedMainWindowContext(displaced) + } + let newContext = MainWindowContext( windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, window: window ) - NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, - object: window, - queue: .main - ) { [weak self] note in - MainActor.assumeIsolated { - guard let self, let closing = note.object as? NSWindow else { return } - self.unregisterMainWindow(closing) - } - } + mainWindowContexts[key] = newContext + context = newContext } + installMainWindowCloseObserver(for: context, window: window) updateCommandPaletteState(for: windowId) { state in state.isVisible = false state.selectionIndex = 0 @@ -3946,9 +4041,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let desiredKey = ObjectIdentifier(window) if mainWindowContexts[desiredKey] === context { context.window = window + installMainWindowCloseObserver(for: context, window: window) return } + if let displaced = mainWindowContexts[desiredKey], displaced !== context { + discardOrphanedMainWindowContext(displaced) + } + let contextKeys = mainWindowContexts.compactMap { key, value in value === context ? key : nil } @@ -3956,13 +4056,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser mainWindowContexts.removeValue(forKey: key) } - if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { - context.window = window - return - } - mainWindowContexts[desiredKey] = context context.window = window + installMainWindowCloseObserver(for: context, window: window) notifyMainWindowContextsDidChange() } @@ -4002,7 +4098,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { - guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + guard let removed = mainWindowContexts[ObjectIdentifier(window)], + removed.window === window, + removed.observedWindow === window else { + return nil + } let removedKeys = mainWindowContexts.compactMap { key, value in value === removed ? key : nil } @@ -4013,6 +4113,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return removed } + private func teardownMainWindowContext(_ context: MainWindowContext) { + removeMainWindowCloseObserver(from: context) + context.tabManager.teardownForWindowClose(notifyOwner: !isTerminatingApp) + } + private func discardOrphanedMainWindowContext(_ context: MainWindowContext) { let contextKeys = mainWindowContexts.compactMap { key, value in value === context ? key : nil @@ -4043,6 +4148,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser store.clearNotifications(forTabId: tab.id) } } + teardownMainWindowContext(context) } /// Prune every registered main window context whose window has already been @@ -9041,6 +9147,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } + teardownMainWindowContext(removed) + // During app termination we already persisted a full snapshot (with scrollback) // in applicationShouldTerminate/applicationWillTerminate. Saving again here would // overwrite it as windows tear down one-by-one, dropping closed windows and replay. diff --git a/Sources/ClaudeQuotaMonitor.swift b/Sources/ClaudeQuotaMonitor.swift index 4abc7d65..136a15e8 100644 --- a/Sources/ClaudeQuotaMonitor.swift +++ b/Sources/ClaudeQuotaMonitor.swift @@ -1,4 +1,6 @@ -import AppKit +import Combine +import CoreFoundation +import Darwin import Foundation /// One rate-limit window ("5h" or "7d") read from `~/.claude/tmp/rate-limits.json`. @@ -57,107 +59,654 @@ enum ClaudeQuotaSnapshotParser { } } -/// Watches `~/.claude/tmp/rate-limits.json` (written by the user's cc-settings -/// statusline, if installed) and publishes its parsed contents. Read-only, local-only: -/// no network calls, no telemetry. `snapshot` is `nil` whenever the file is absent or -/// unparseable, which is expected and silent for users who don't run cc-settings. +enum ProviderUsageProvider: Int, CaseIterable, Comparable, Sendable { + case claude + case codex + + static func < (lhs: ProviderUsageProvider, rhs: ProviderUsageProvider) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +struct ProviderUsageWindow: Identifiable, Equatable, Sendable { + let id: String + let label: String + let usedPercent: Int + let resetsAt: Date +} + +enum ProviderUsageDurationLabel { + static func format(minutes: Int) -> String { + let value: Int + let format: String + if minutes.isMultiple(of: 1_440) { + value = minutes / 1_440 + format = String(localized: "sidebar.usage.duration.days", defaultValue: "%@d") + } else if minutes.isMultiple(of: 60) { + value = minutes / 60 + format = String(localized: "sidebar.usage.duration.hours", defaultValue: "%@h") + } else { + value = minutes + format = String(localized: "sidebar.usage.duration.minutes", defaultValue: "%@m") + } + return String.localizedStringWithFormat(format, String(value)) + } +} + +struct ProviderUsageSnapshot: Equatable, Sendable { + let provider: ProviderUsageProvider + let windows: [ProviderUsageWindow] +} + +enum ProviderUsageResult: Equatable, Sendable { + case available(ProviderUsageSnapshot) + case unavailable(ProviderUsageProvider) + case failed(ProviderUsageProvider, String) + + var provider: ProviderUsageProvider { + switch self { + case let .available(snapshot): + snapshot.provider + case let .unavailable(provider), let .failed(provider, _): + provider + } + } +} + +protocol ProviderUsageFetching: Sendable { + var provider: ProviderUsageProvider { get } + func fetch() async -> ProviderUsageResult +} + @MainActor -final class ClaudeQuotaMonitor: ObservableObject { - static let shared = ClaudeQuotaMonitor() - - @Published private(set) var snapshot: ClaudeQuotaSnapshot? - - private let filePath: String - private let directoryPath: String - private let watchQueue = DispatchQueue(label: "com.darkroom.programa.claude-quota-watch") - private let fileWatcher: FileWatcher - private var activationObserver: NSObjectProtocol? - - private init() { - let fileURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude/tmp/rate-limits.json") - filePath = fileURL.path - directoryPath = fileURL.deletingLastPathComponent().path - fileWatcher = FileWatcher(queue: watchQueue) - - startWatchingFile() - reloadFromDisk() - - activationObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.ensureWatchingOnActivate() - } - } - - deinit { - fileWatcher.stop() - if let activationObserver { - NotificationCenter.default.removeObserver(activationObserver) - } - } - - /// Tries to watch the file directly; if it doesn't exist yet, falls back to - /// watching its parent directory so we notice it appearing later. No polling - /// timer: everything here is event-driven off `DispatchSource`. - private func startWatchingFile() { - let started = fileWatcher.start( - path: filePath, - eventMask: [.write, .delete, .rename, .extend] - ) { [weak self] flags in - guard let self else { return } - DispatchQueue.main.async { - if flags.contains(.delete) || flags.contains(.rename) { - self.startWatchingDirectory() - return +final class ProviderUsageStore: ObservableObject { + @Published private(set) var results: [ProviderUsageResult] = [] + @Published private(set) var isRefreshing = false + + private let fetchers: [any ProviderUsageFetching] + private var refreshGeneration = 0 + private var refreshTask: Task<[(Int, ProviderUsageResult)], Never>? + + init(fetchers: [any ProviderUsageFetching]) { + self.fetchers = fetchers + } + + convenience init() { + self.init(fetchers: [ClaudeProviderUsageFetcher(), CodexProviderUsageFetcher()]) + } + + func refresh() async { + refreshGeneration &+= 1 + let generation = refreshGeneration + isRefreshing = true + let fetchers = self.fetchers + + refreshTask?.cancel() + let task = Task { + await withTaskGroup( + of: (Int, ProviderUsageResult).self, + returning: [(Int, ProviderUsageResult)].self + ) { group in + for (index, fetcher) in fetchers.enumerated() { + group.addTask { + (index, await fetcher.fetch()) + } + } + + var collected: [(Int, ProviderUsageResult)] = [] + for await result in group { + collected.append(result) + } + return collected + } + } + refreshTask = task + let fetchedResults = await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + + guard generation == refreshGeneration else { return } + refreshTask = nil + guard !task.isCancelled else { + isRefreshing = false + return + } + + var resultByProvider: [ProviderUsageProvider: ProviderUsageResult] = [:] + for (_, result) in fetchedResults.sorted(by: { $0.0 < $1.0 }) { + resultByProvider[result.provider] = result + } + results = ProviderUsageProvider.allCases.compactMap { resultByProvider[$0] } + isRefreshing = false + } + + func cancelRefresh() { + refreshGeneration &+= 1 + refreshTask?.cancel() + refreshTask = nil + isRefreshing = false + } +} + +enum ClaudeUsageSnapshotParser { + static func parse(data: Data) -> ProviderUsageResult { + guard let snapshot = ClaudeQuotaSnapshotParser.parse(data: data) else { + return .failed( + .claude, + String( + localized: "sidebar.usage.error.claudeInvalid", + defaultValue: "Claude usage data is invalid." + ) + ) + } + + return .available( + ProviderUsageSnapshot( + provider: .claude, + windows: [ + ProviderUsageWindow( + id: "claude.five_hour", + label: ProviderUsageDurationLabel.format(minutes: 300), + usedPercent: snapshot.fiveHour.usedPercent, + resetsAt: snapshot.fiveHour.resetsAt + ), + ProviderUsageWindow( + id: "claude.seven_day", + label: ProviderUsageDurationLabel.format(minutes: 10_080), + usedPercent: snapshot.sevenDay.usedPercent, + resetsAt: snapshot.sevenDay.resetsAt + ), + ] + ) + ) + } +} + +struct ClaudeProviderUsageFetcher: ProviderUsageFetching { + let provider = ProviderUsageProvider.claude + + func fetch() async -> ProviderUsageResult { + await Task.detached(priority: .utility) { + let fileURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/tmp/rate-limits.json") + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return .unavailable(.claude) + } + + do { + return ClaudeUsageSnapshotParser.parse(data: try Data(contentsOf: fileURL)) + } catch { + return .failed( + .claude, + String( + localized: "sidebar.usage.error.claudeRead", + defaultValue: "Claude usage could not be read." + ) + ) + } + }.value + } +} + +enum CodexUsageSnapshotParser { + private struct ParsedBucket { + let limitID: String + let limitName: String? + let primary: [String: Any]? + let secondary: [String: Any]? + } + + static func parse(accountData: Data, rateLimitsData: Data) -> ProviderUsageResult { + guard + let accountEnvelope = jsonObject(accountData), + accountEnvelope["error"] == nil, + accountEnvelope.keys.contains("result"), + let accountResult = accountEnvelope["result"] as? [String: Any], + accountResult.keys.contains("account") + else { + return failure() + } + + if accountResult["account"] is NSNull { + return .unavailable(.codex) + } + guard accountResult["account"] is [String: Any] else { + return failure() + } + + guard + let rateEnvelope = jsonObject(rateLimitsData), + rateEnvelope["error"] == nil, + rateEnvelope.keys.contains("result"), + let rateResult = rateEnvelope["result"] as? [String: Any], + rateResult.keys.contains("rateLimits") || rateResult.keys.contains("rateLimitsByLimitId") + else { + return failure() + } + + let byLimit: [String: Any] + if let rawByLimit = rateResult["rateLimitsByLimitId"] { + if rawByLimit is NSNull { + byLimit = [:] + } else if let parsedByLimit = rawByLimit as? [String: Any] { + byLimit = parsedByLimit + } else { + return failure() + } + } else { + byLimit = [:] + } + + var windows: [ProviderUsageWindow] = [] + var aggregateLimitID: String? + + if let rawAggregate = rateResult["rateLimits"], !(rawAggregate is NSNull) { + guard let aggregate = parseBucket(rawAggregate, fallbackLimitID: "codex") else { + return failure() + } + aggregateLimitID = aggregate.limitID + guard append(bucket: aggregate, isAggregate: true, to: &windows) else { + return failure() + } + } else if let mappedAggregate = byLimit["codex"] { + guard let aggregate = parseBucket(mappedAggregate, fallbackLimitID: "codex") else { + return failure() + } + aggregateLimitID = aggregate.limitID + guard append(bucket: aggregate, isAggregate: true, to: &windows) else { + return failure() + } + } + + for limitID in byLimit.keys.sorted() { + guard let rawBucket = byLimit[limitID], + let bucket = parseBucket(rawBucket, fallbackLimitID: limitID) else { + return failure() + } + if limitID == "codex" || bucket.limitID == aggregateLimitID { + continue + } + guard append(bucket: bucket, isAggregate: false, to: &windows) else { + return failure() + } + } + + guard !windows.isEmpty else { + return .unavailable(.codex) + } + return .available(ProviderUsageSnapshot(provider: .codex, windows: windows)) + } + + private static func jsonObject(_ data: Data) -> [String: Any]? { + try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private static func parseBucket(_ raw: Any, fallbackLimitID: String) -> ParsedBucket? { + guard let dictionary = raw as? [String: Any] else { return nil } + let limitID = (dictionary["limitId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedLimitID = limitID.flatMap { $0.isEmpty ? nil : $0 } ?? fallbackLimitID + + let limitName: String? + if let rawName = dictionary["limitName"] { + if rawName is NSNull { + limitName = nil + } else if let name = rawName as? String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + limitName = trimmed.isEmpty ? nil : trimmed + } else { + return nil + } + } else { + limitName = nil + } + + guard + let primary = optionalWindow(dictionary["primary"]), + let secondary = optionalWindow(dictionary["secondary"]) + else { + return nil + } + return ParsedBucket( + limitID: resolvedLimitID, + limitName: limitName, + primary: primary, + secondary: secondary + ) + } + + /// An outer optional distinguishes an absent/null window from a malformed value. + private static func optionalWindow(_ raw: Any?) -> [String: Any]?? { + guard let raw, !(raw is NSNull) else { return .some(nil) } + guard let window = raw as? [String: Any] else { return nil } + return .some(window) + } + + private static func append( + bucket: ParsedBucket, + isAggregate: Bool, + to windows: inout [ProviderUsageWindow] + ) -> Bool { + for (name, rawWindow) in [("primary", bucket.primary), ("secondary", bucket.secondary)] { + guard let rawWindow else { continue } + guard let window = parseWindow( + rawWindow, + id: "\(isAggregate ? "codex" : bucket.limitID).\(name)", + limitName: isAggregate ? nil : (bucket.limitName ?? bucket.limitID) + ) else { + return false + } + if !windows.contains(where: { $0.id == window.id }) { + windows.append(window) + } + } + return true + } + + private static func parseWindow( + _ raw: [String: Any], + id: String, + limitName: String? + ) -> ProviderUsageWindow? { + guard + let usedPercentNumber = finiteNumber(raw["usedPercent"]), + let durationNumber = finiteNumber(raw["windowDurationMins"]), + let resetsAt = finiteNumber(raw["resetsAt"]) + else { + return nil + } + + let durationMinutes = Int(durationNumber.rounded()) + guard durationMinutes > 0, resetsAt >= 0 else { return nil } + let duration = ProviderUsageDurationLabel.format(minutes: durationMinutes) + return ProviderUsageWindow( + id: id, + label: limitName.map { "\($0) · \(duration)" } ?? duration, + usedPercent: min(max(Int(usedPercentNumber.rounded()), 0), 100), + resetsAt: Date(timeIntervalSince1970: resetsAt) + ) + } + + private static func finiteNumber(_ raw: Any?) -> Double? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let value = number.doubleValue + return value.isFinite ? value : nil + } + + private static func failure() -> ProviderUsageResult { + .failed( + .codex, + String( + localized: "sidebar.usage.error.codexInvalid", + defaultValue: "Codex returned invalid usage data." + ) + ) + } +} + +struct CodexProviderUsageFetcher: ProviderUsageFetching { + let provider = ProviderUsageProvider.codex + + func fetch() async -> ProviderUsageResult { + guard let executableURL = Self.resolveCodexExecutable() else { + return .unavailable(.codex) + } + + switch await Self.runAppServer(executableURL: executableURL) { + case let .responses(account, rateLimits): + return CodexUsageSnapshotParser.parse(accountData: account, rateLimitsData: rateLimits) + case .unavailable: + return .unavailable(.codex) + case .failed: + return .failed( + .codex, + String( + localized: "sidebar.usage.error.codexRead", + defaultValue: "Codex usage could not be read." + ) + ) + } + } + + private enum AppServerOutcome { + case responses(account: Data, rateLimits: Data) + case unavailable + case failed + } + + private struct CollectedResponses: Sendable { + var account: Data? + var rateLimits: Data? + var exceededCaptureLimit = false + var readFailed = false + } + + private static func runAppServer(executableURL: URL) async -> AppServerOutcome { + let process = Process() + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + + process.executableURL = executableURL + process.arguments = ["app-server"] + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + return .failed + } + + async let collectedResponses = collectResponses(from: stdout.fileHandleForReading) + async let drainedStderr: Void = drain(stderr.fileHandleForReading) + + do { + let input = try requestPayload() + try stdin.fileHandleForWriting.write(contentsOf: input) + try stdin.fileHandleForWriting.close() + } catch { + terminate(process) + _ = await collectedResponses + await drainedStderr + return .failed + } + + let deadline = Date().addingTimeInterval(5) + while process.isRunning, Date() < deadline, !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(20)) + } + if process.isRunning { + terminate(process) + } + let responses = await collectedResponses + await drainedStderr + + guard !Task.isCancelled, + !responses.exceededCaptureLimit, + !responses.readFailed else { + return .failed + } + guard let accountResponse = responses.account, + let rateLimitsResponse = responses.rateLimits else { + return process.terminationStatus == 127 ? .unavailable : .failed + } + return .responses(account: accountResponse, rateLimits: rateLimitsResponse) + } + + /// Reads JSONL incrementally and retains only the two bounded response envelopes. + /// The account envelope is reduced to an authenticated/null marker before storage, + /// so email and other account identifiers never leave the transient input line. + private static func collectResponses(from handle: FileHandle) async -> CollectedResponses { + let maximumCapturedBytes = 1_048_576 + var result = CollectedResponses() + var line = Data() + var receivedBytes = 0 + + do { + for try await byte in handle.bytes { + receivedBytes += 1 + if receivedBytes > maximumCapturedBytes { + result.exceededCaptureLimit = true + line.removeAll(keepingCapacity: false) + continue + } + + if byte == 0x0A { + consumeResponseLine(line, into: &result) + line.removeAll(keepingCapacity: true) + } else { + line.append(byte) } - self.reloadFromDisk() } + if !line.isEmpty, !result.exceededCaptureLimit { + consumeResponseLine(line, into: &result) + } + } catch { + result.readFailed = true + } + return result + } + + private static func consumeResponseLine(_ line: Data, into responses: inout CollectedResponses) { + guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], + let responseID = rpcID(object["id"]), + responseID == 1 || responseID == 2 else { + return } - if !started { - startWatchingDirectory() + + if responseID == 1 { + responses.account = sanitizedAccountEnvelope(object) + } else { + responses.rateLimits = try? JSONSerialization.data(withJSONObject: object) } } - private func startWatchingDirectory() { - let started = fileWatcher.start( - path: directoryPath, - eventMask: [.write, .link, .rename] - ) { [weak self] _ in - guard let self else { return } - DispatchQueue.main.async { - guard FileManager.default.fileExists(atPath: self.filePath) else { return } - self.startWatchingFile() + private static func sanitizedAccountEnvelope(_ envelope: [String: Any]) -> Data? { + let sanitized: [String: Any] + if envelope["error"] != nil { + sanitized = ["id": 1, "error": [String: Any]()] + } else if let result = envelope["result"] as? [String: Any], + result.keys.contains("account") { + let accountMarker: Any + if result["account"] is NSNull { + accountMarker = NSNull() + } else if result["account"] is [String: Any] { + accountMarker = [String: Any]() + } else { + accountMarker = "invalid" } + sanitized = ["id": 1, "result": ["account": accountMarker]] + } else { + sanitized = ["id": 1] } - if started { - reloadFromDisk() + return try? JSONSerialization.data(withJSONObject: sanitized) + } + + private static func drain(_ handle: FileHandle) async { + do { + for try await _ in handle.bytes {} + } catch { + // Stderr is intentionally discarded and never surfaced or stored. } } - /// Cheap, event-driven re-check for the case where `~/.claude/tmp` itself did not - /// exist yet at startup (so even directory-level watching could not be installed). - /// This only runs on app activation, never on a timer. - private func ensureWatchingOnActivate() { - if FileManager.default.fileExists(atPath: filePath) { - startWatchingFile() - } else if FileManager.default.fileExists(atPath: directoryPath) { - startWatchingDirectory() + private static func requestPayload() throws -> Data { + let messages: [[String: Any]] = [ + [ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": [ + "clientInfo": [ + "name": "programa", + "title": "Programa", + "version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0", + ], + "capabilities": [String: Any](), + ], + ], + [ + "jsonrpc": "2.0", + "method": "initialized", + "params": [String: Any](), + ], + [ + "jsonrpc": "2.0", + "id": 1, + "method": "account/read", + "params": ["refreshToken": false], + ], + [ + "jsonrpc": "2.0", + "id": 2, + "method": "account/rateLimits/read", + "params": [String: Any](), + ], + ] + + var payload = Data() + for message in messages { + payload.append(try JSONSerialization.data(withJSONObject: message)) + payload.append(0x0A) } - reloadFromDisk() + return payload } - /// Reads and parses the file off the main thread, then publishes the result on main. - private func reloadFromDisk() { - let path = filePath - DispatchQueue.global(qos: .utility).async { [weak self] in - let data = FileManager.default.contents(atPath: path) - let parsed = data.flatMap(ClaudeQuotaSnapshotParser.parse) - DispatchQueue.main.async { - self?.snapshot = parsed + private static func rpcID(_ raw: Any?) -> Int? { + if let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() { + return number.intValue + } + if let string = raw as? String { + return Int(string) + } + return nil + } + + private static func terminate(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let graceDeadline = Date().addingTimeInterval(0.25) + while process.isRunning, Date() < graceDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + let killDeadline = Date().addingTimeInterval(0.25) + while process.isRunning, Date() < killDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + } + } + + private static func resolveCodexExecutable() -> URL? { + let fileManager = FileManager.default + var candidates: [String] = [] + if let path = ProcessInfo.processInfo.environment["PATH"] { + candidates.append(contentsOf: path.split(separator: ":").map { "\($0)/codex" }) + } + + let home = fileManager.homeDirectoryForCurrentUser.path + candidates.append(contentsOf: [ + "\(home)/.local/bin/codex", + "\(home)/.bun/bin/codex", + "/opt/homebrew/bin/codex", + "/usr/local/bin/codex", + "/Applications/Codex.app/Contents/Resources/codex", + ]) + + var seen: Set = [] + for candidate in candidates where seen.insert(candidate).inserted { + if fileManager.isExecutableFile(atPath: candidate) { + return URL(fileURLWithPath: candidate) } } + return nil } } diff --git a/Sources/MobileBridge/MobileBridgeListener.swift b/Sources/MobileBridge/MobileBridgeListener.swift index 766dae7c..f0cf0393 100644 --- a/Sources/MobileBridge/MobileBridgeListener.swift +++ b/Sources/MobileBridge/MobileBridgeListener.swift @@ -27,6 +27,262 @@ struct MobileBridgePairingInfo: Sendable { let expiresAt: Date } +enum MobileBridgeDeviceRevocationOutcome: Sendable, Equatable { + case revoked + case persistenceFailed +} + +final class MobileBridgeConnectionRegistry: @unchecked Sendable { + typealias CloseAction = @Sendable () -> Void + + struct PendingAdmissionLease: Sendable { + fileprivate let id: UUID + fileprivate let listenerGeneration: UInt64 + } + + struct AdmissionTicket: Sendable { + fileprivate let endpointId: String + fileprivate let endpointGeneration: UInt64 + fileprivate let listenerGeneration: UInt64 + fileprivate let pendingAdmissionID: UUID? + } + + struct ListenerLifecycle: Sendable { + fileprivate let listenerGeneration: UInt64 + } + + enum RegistrationResult: Sendable { + case registered(superseded: [CloseAction]) + case rejected(CloseAction) + } + + private struct IdentifiedPendingAdmission { + let endpointId: String + let endpointGeneration: UInt64 + let listenerGeneration: UInt64 + let close: CloseAction + } + + private static let maximumPendingAdmissions = 10 + private static let maximumLiveConnections = 10 + private static let noOpClose: CloseAction = {} + + private let lock = NSLock() + private var isAccepting = false + private var listenerGeneration: UInt64 = 0 + private var endpointGenerations: [String: UInt64] = [:] + private var anonymousPendingAdmissions: [UUID: UInt64] = [:] + private var identifiedPendingAdmissions: [UUID: IdentifiedPendingAdmission] = [:] + private var liveRecords: [String: [ObjectIdentifier: CloseAction]] = [:] + + func start() -> ListenerLifecycle { + lock.withLock { + if !isAccepting { + listenerGeneration &+= 1 + isAccepting = true + } + return ListenerLifecycle(listenerGeneration: listenerGeneration) + } + } + + func beginAdmission(endpointId: String, lifecycle: ListenerLifecycle) -> AdmissionTicket? { + lock.withLock { + guard isAccepting, lifecycle.listenerGeneration == listenerGeneration else { return nil } + return AdmissionTicket( + endpointId: endpointId, + endpointGeneration: endpointGenerations[endpointId] ?? 0, + listenerGeneration: listenerGeneration, + pendingAdmissionID: nil + ) + } + } + + func reservePending(lifecycle: ListenerLifecycle) -> PendingAdmissionLease? { + lock.withLock { + guard isAccepting, + lifecycle.listenerGeneration == listenerGeneration, + anonymousPendingAdmissions.count + identifiedPendingAdmissions.count + < Self.maximumPendingAdmissions + else { + return nil + } + + let lease = PendingAdmissionLease( + id: UUID(), + listenerGeneration: listenerGeneration + ) + anonymousPendingAdmissions[lease.id] = listenerGeneration + return lease + } + } + + func identifyPending( + _ lease: PendingAdmissionLease, + endpointId: String, + close: @escaping CloseAction + ) -> AdmissionTicket? { + lock.withLock { + guard let reservedGeneration = anonymousPendingAdmissions.removeValue(forKey: lease.id), + reservedGeneration == lease.listenerGeneration, + isAccepting, + lease.listenerGeneration == listenerGeneration, + !identifiedPendingAdmissions.values.contains(where: { $0.endpointId == endpointId }) + else { + return nil + } + + let endpointGeneration = endpointGenerations[endpointId] ?? 0 + identifiedPendingAdmissions[lease.id] = IdentifiedPendingAdmission( + endpointId: endpointId, + endpointGeneration: endpointGeneration, + listenerGeneration: listenerGeneration, + close: close + ) + return AdmissionTicket( + endpointId: endpointId, + endpointGeneration: endpointGeneration, + listenerGeneration: listenerGeneration, + pendingAdmissionID: lease.id + ) + } + } + + func expireAdmission(_ ticket: AdmissionTicket) -> CloseAction? { + claimPendingAdmission(ticket) + } + + @discardableResult + func abandonPending(_ lease: PendingAdmissionLease) -> Bool { + lock.withLock { + guard anonymousPendingAdmissions[lease.id] == lease.listenerGeneration else { return false } + anonymousPendingAdmissions[lease.id] = nil + return true + } + } + + func abandonAdmission(_ ticket: AdmissionTicket) -> CloseAction? { + claimPendingAdmission(ticket) + } + + func registerIfCurrent( + connectionID: ObjectIdentifier, + ticket: AdmissionTicket, + close: @escaping CloseAction, + beforeRegister: () -> Bool = { true } + ) -> RegistrationResult { + lock.withLock { + let candidateClose: CloseAction + if let pendingAdmissionID = ticket.pendingAdmissionID { + guard let pending = identifiedPendingAdmissions[pendingAdmissionID] else { + return .rejected(Self.noOpClose) + } + guard isAccepting, + ticket.listenerGeneration == listenerGeneration, + ticket.endpointGeneration == (endpointGenerations[ticket.endpointId] ?? 0), + pending.endpointId == ticket.endpointId, + pending.endpointGeneration == ticket.endpointGeneration, + pending.listenerGeneration == ticket.listenerGeneration + else { + identifiedPendingAdmissions[pendingAdmissionID] = nil + return .rejected(pending.close) + } + candidateClose = pending.close + } else { + guard isAccepting, + ticket.listenerGeneration == listenerGeneration, + ticket.endpointGeneration == (endpointGenerations[ticket.endpointId] ?? 0) + else { + return .rejected(close) + } + candidateClose = close + } + + let liveConnectionCount = liveRecords.values.reduce(into: 0) { + $0 += $1.count + } + let replacesCurrentEndpoint = liveRecords[ticket.endpointId]?.isEmpty == false + guard liveConnectionCount < Self.maximumLiveConnections || replacesCurrentEndpoint else { + if let pendingAdmissionID = ticket.pendingAdmissionID { + identifiedPendingAdmissions[pendingAdmissionID] = nil + } + return .rejected(candidateClose) + } + guard beforeRegister() else { + if let pendingAdmissionID = ticket.pendingAdmissionID { + identifiedPendingAdmissions[pendingAdmissionID] = nil + } + return .rejected(candidateClose) + } + + if let pendingAdmissionID = ticket.pendingAdmissionID { + identifiedPendingAdmissions[pendingAdmissionID] = nil + } + let superseded = liveRecords.removeValue(forKey: ticket.endpointId).map { + Array($0.values) + } ?? [] + liveRecords[ticket.endpointId] = [connectionID: candidateClose] + return .registered(superseded: superseded) + } + } + + func unregister(connectionID: ObjectIdentifier, endpointId: String) { + lock.withLock { + liveRecords[endpointId]?[connectionID] = nil + if liveRecords[endpointId]?.isEmpty == true { + liveRecords[endpointId] = nil + } + } + } + + func revoke(endpointId: String, beforeClaim: () -> Void = {}) -> [CloseAction] { + lock.withLock { + endpointGenerations[endpointId] = (endpointGenerations[endpointId] ?? 0) &+ 1 + beforeClaim() + + let pendingIDs = identifiedPendingAdmissions.compactMap { id, admission in + admission.endpointId == endpointId ? id : nil + } + let pendingActions = pendingIDs.compactMap { + identifiedPendingAdmissions.removeValue(forKey: $0)?.close + } + let liveActions = liveRecords.removeValue(forKey: endpointId).map { + Array($0.values) + } ?? [] + return pendingActions + liveActions + } + } + + func stop() -> [CloseAction] { + lock.withLock { + if isAccepting { + isAccepting = false + listenerGeneration &+= 1 + } + let actions = identifiedPendingAdmissions.values.map(\.close) + + liveRecords.values.flatMap { Array($0.values) } + anonymousPendingAdmissions.removeAll(keepingCapacity: true) + identifiedPendingAdmissions.removeAll(keepingCapacity: true) + liveRecords.removeAll(keepingCapacity: true) + return actions + } + } + + private func claimPendingAdmission(_ ticket: AdmissionTicket) -> CloseAction? { + lock.withLock { + guard let pendingAdmissionID = ticket.pendingAdmissionID, + let pending = identifiedPendingAdmissions[pendingAdmissionID], + pending.endpointId == ticket.endpointId, + pending.endpointGeneration == ticket.endpointGeneration, + pending.listenerGeneration == ticket.listenerGeneration + else { + return nil + } + identifiedPendingAdmissions[pendingAdmissionID] = nil + return pending.close + } + } +} + /// Owns the in-process iroh endpoint that lets a paired iPhone reach this /// Mac's terminal control dispatch without the user ever running the /// `tools/mobile-spike bridge` CLI in a terminal. Ported from @@ -43,24 +299,21 @@ final class MobileBridgeListener: @unchecked Sendable { static let shared = MobileBridgeListener() private let stateLock = NSLock() + private let endpointBinder: @Sendable () async throws -> Endpoint private var endpoint: Endpoint? private var acceptTask: Task? private var pairingWindow: MobileBridgePairingWindow? private var isStarting = false private var generation: UInt64 = 0 + private let connectionRegistry = MobileBridgeConnectionRegistry() - /// Live connections per admitted `endpointId`, so `revoke(endpointId:)` - /// can reach an in-progress relay rather than only blocking future - /// reconnects. Keyed by `ObjectIdentifier` (not a `Set`, since - /// `Connection` isn't `Hashable`) to tolerate more than one concurrent - /// connection from the same device. Guarded by `stateLock`, the same - /// lock as every other mutable field on this type. Registered in - /// `handleIncoming` right after `admit()` succeeds; unregistered via - /// `defer` so every relay exit path (normal completion, thrown error) - /// clears its entry. - private var liveConnections: [String: [ObjectIdentifier: Connection]] = [:] - - private init() {} + init( + endpointBinder: @escaping @Sendable () async throws -> Endpoint = { + try await MobileBridgeListener.bindEndpoint() + } + ) { + self.endpointBinder = endpointBinder + } /// Starts the endpoint if it is not already running or starting. /// Idempotent. Never blocks the caller. @@ -74,142 +327,151 @@ final class MobileBridgeListener: @unchecked Sendable { func start(tabManager: TabManager) { TerminalController.shared.tabManager = tabManager - stateLock.lock() - guard endpoint == nil, !isStarting else { - stateLock.unlock() - return + let startState = stateLock.withLock { + guard endpoint == nil, !isStarting else { + return nil as (generation: UInt64, lifecycle: MobileBridgeConnectionRegistry.ListenerLifecycle)? + } + isStarting = true + generation &+= 1 + return ( + generation: generation, + lifecycle: connectionRegistry.start() + ) } - isStarting = true - generation += 1 - let currentGeneration = generation - stateLock.unlock() + guard let startState else { return } Task { [weak self] in - await self?.bindAndAccept(generation: currentGeneration) + await self?.bindAndAccept( + generation: startState.generation, + lifecycle: startState.lifecycle + ) } } /// Stops the listener and closes the endpoint. Safe to call whether or /// not the listener is currently running. func stop() { - stateLock.lock() - let task = acceptTask - let ep = endpoint - acceptTask = nil - endpoint = nil - pairingWindow = nil - isStarting = false - generation += 1 - stateLock.unlock() - - task?.cancel() - if let ep { + let stoppedState = stateLock.withLock { + let task = acceptTask + let ep = endpoint + pairingWindow?.invalidate() + acceptTask = nil + endpoint = nil + pairingWindow = nil + isStarting = false + generation &+= 1 + return ( + task: task, + endpoint: ep, + closeActions: connectionRegistry.stop() + ) + } + + stoppedState.task?.cancel() + stoppedState.closeActions.forEach { $0() } + if let ep = stoppedState.endpoint { Task { try? await ep.close() } } } /// Opens a single-use, 5-minute pairing window and returns the pairing /// payload (ticket) and token to display in Settings. Returns `nil` if - /// the endpoint isn't bound yet (mode just enabled, still connecting to - /// relays) -- the caller should show a brief error and let the user - /// retry. + /// the endpoint isn't bound yet (mode just enabled, still binding) -- + /// the caller should show a brief error and let the user retry. func beginPairing() async -> MobileBridgePairingInfo? { - stateLock.lock() - let ep = endpoint - stateLock.unlock() - guard let ep else { return nil } + let snapshot = stateLock.withLock { + endpoint.map { (endpoint: $0, generation: generation) } + } + guard let snapshot else { return nil } let tokenBytes = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) }) let tokenString = MobileBridgeBase64URL.encode(tokenBytes) let window = MobileBridgePairingWindow(token: Data(tokenString.utf8), duration: mobileBridgePairingWindowDuration) let expiresAt = Date().addingTimeInterval(mobileBridgePairingWindowDuration.timeIntervalValue) - stateLock.lock() - pairingWindow = window - stateLock.unlock() + guard let ticket = try? EndpointTicket.fromAddr(addr: snapshot.endpoint.addr()) else { + window.invalidate() + return nil + } + + let published = stateLock.withLock { + guard generation == snapshot.generation, endpoint === snapshot.endpoint else { + return false + } + pairingWindow?.invalidate() + pairingWindow = window + return true + } + guard published else { + window.invalidate() + return nil + } - guard let ticket = try? EndpointTicket.fromAddr(addr: ep.addr()) else { return nil } return MobileBridgePairingInfo(ticket: ticket.description, token: tokenString, expiresAt: expiresAt) } - /// Revokes a previously paired device immediately: removes it from the - /// trusted store (so a reconnect is rejected at `admit()`, unchanged) - /// and closes every connection currently registered for it, so a - /// long-lived relay session doesn't keep running on borrowed trust - /// until the phone disconnects on its own. - func revoke(endpointId: String) async { - await MobileBridgeTrustedDeviceStore.shared.remove(endpointId: endpointId) - - stateLock.lock() - let connections = liveConnections[endpointId] - stateLock.unlock() - - guard let connections else { return } - for connection in connections.values { - try? connection.close(errorCode: 0, reason: Data("revoked".utf8)) + /// Revokes a previously paired device after its removal is durably stored: + /// reconnects are then rejected at `admit()`, and every registered + /// connection is closed so a long-lived relay session cannot keep running + /// on borrowed trust until the phone disconnects on its own. + func revoke(endpointId: String) async -> MobileBridgeDeviceRevocationOutcome { + let result = await MobileBridgeTrustedDeviceStore.shared.revokeAndClaimConnections( + endpointId: endpointId, + registry: connectionRegistry + ) + if let persistenceFailure = result.persistenceFailure { + NSLog( + "MobileBridge: failed to persist device revocation; connection remains active: %@", + persistenceFailure + ) + return .persistenceFailed } - } - /// Registers a live, admitted connection so `revoke(endpointId:)` can - /// close it later. Must be paired with `unregisterLiveConnection` on - /// every exit path (see call site in `handleIncoming`). - private func registerLiveConnection(_ connection: Connection, endpointId: String) { - let key = ObjectIdentifier(connection) - stateLock.lock() - liveConnections[endpointId, default: [:]][key] = connection - stateLock.unlock() + result.closeActions.forEach { $0() } + return .revoked } - private func unregisterLiveConnection(_ connection: Connection, endpointId: String) { - let key = ObjectIdentifier(connection) - stateLock.lock() - liveConnections[endpointId]?[key] = nil - if liveConnections[endpointId]?.isEmpty == true { - liveConnections[endpointId] = nil - } - stateLock.unlock() + private static func bindEndpoint() async throws -> Endpoint { + let secretKey = try MobileBridgeSecretKeyStore.loadOrCreate() + // Mirrors `makeEndpointOptions` in + // `tools/mobile-spike/Sources/iroh-spike/App.swift` exactly -- + // see that file's doc comment for why each field matters + // (`presetN0()`, `RelayMode.defaultMode()`, `0.0.0.0:0` bind, + // `portMappingEnabled: true`). + let options = EndpointOptions( + preset: presetN0(), + bindAddr: "0.0.0.0:0", + secretKey: secretKey, + alpns: [mobileBridgeALPN], + relayMode: RelayMode.defaultMode(), + portMappingEnabled: true, + deferNatTraversalUntilAuthorized: true, + initialMaxConcurrentBiStreams: 0, + initialMaxConcurrentUniStreams: 0 + ) + return try await Endpoint.bind(options: options) } - private func bindAndAccept(generation: UInt64) async { + private func bindAndAccept( + generation: UInt64, + lifecycle: MobileBridgeConnectionRegistry.ListenerLifecycle + ) async { do { - let secretKey = try MobileBridgeSecretKeyStore.loadOrCreate() - // Mirrors `makeEndpointOptions` in - // `tools/mobile-spike/Sources/iroh-spike/App.swift` exactly -- - // see that file's doc comment for why each field matters - // (`presetN0()`, `RelayMode.defaultMode()`, `0.0.0.0:0` bind, - // `portMappingEnabled: true`). - let options = EndpointOptions( - preset: presetN0(), - bindAddr: "0.0.0.0:0", - secretKey: secretKey, - alpns: [mobileBridgeALPN], - relayMode: RelayMode.defaultMode(), - portMappingEnabled: true, - deferNatTraversalUntilAuthorized: true, - initialMaxConcurrentBiStreams: 0, - initialMaxConcurrentUniStreams: 0 - ) - let ep = try await Endpoint.bind(options: options) + let ep = try await endpointBinder() - await withTaskGroup(of: Void.self) { group in - group.addTask { await ep.online() } - group.addTask { try? await Task.sleep(for: .seconds(10)) } - await group.next() - group.cancelAll() + let published = stateLock.withLock { + guard self.generation == generation else { return false } + self.endpoint = ep + self.isStarting = false + return true } - - stateLock.lock() - guard self.generation == generation else { + guard published else { // `stop()` (or a subsequent `start()`) ran while we were - // binding/waiting for relay connectivity -- this bind is - // stale, close it without publishing state. - stateLock.unlock() + // binding -- this bind is stale, close it without publishing + // state. try? await ep.close() return } - self.endpoint = ep - self.isStarting = false - stateLock.unlock() #if DEBUG // Log the dialable ticket, not just the node id: without it there is @@ -223,39 +485,111 @@ final class MobileBridgeListener: @unchecked Sendable { let task = Task { [weak self] in guard let self else { return } - await self.acceptLoop(endpoint: ep, generation: generation) + await self.acceptLoop( + endpoint: ep, + generation: generation, + lifecycle: lifecycle + ) } - stateLock.lock() - if self.generation == generation { + let installed = stateLock.withLock { + guard self.generation == generation else { return false } acceptTask = task - } else { + return true + } + if !installed { task.cancel() } - stateLock.unlock() } catch { NSLog("MobileBridge: failed to bind endpoint: %@", "\(error)") - stateLock.lock() - if self.generation == generation { - isStarting = false + stateLock.withLock { + if self.generation == generation { + isStarting = false + } } - stateLock.unlock() } } - private func acceptLoop(endpoint: Endpoint, generation: UInt64) async { + private func acceptLoop( + endpoint: Endpoint, + generation: UInt64, + lifecycle: MobileBridgeConnectionRegistry.ListenerLifecycle + ) async { while let incoming = await endpoint.acceptNext() { - stateLock.lock() - let stillCurrent = self.generation == generation - stateLock.unlock() - guard stillCurrent else { break } + let stillCurrent = stateLock.withLock { self.generation == generation } + guard stillCurrent else { + try? await incoming.refuse() + break + } + guard let pendingLease = connectionRegistry.reservePending(lifecycle: lifecycle) else { + try? await incoming.refuse() + let remainsCurrent = stateLock.withLock { self.generation == generation } + if !remainsCurrent { + break + } + continue + } + + let registry = connectionRegistry + let pendingDeadline = Self.startPendingAdmissionDeadline( + registry: registry, + lease: pendingLease, + timeout: .seconds(15) + ) { + try? await incoming.refuse() + } Task { [weak self] in - await self?.handleIncoming(incoming) + guard let self else { + pendingDeadline.cancel() + if registry.abandonPending(pendingLease) { + try? await incoming.refuse() + } + return + } + await self.handleIncoming( + incoming, + pendingLease: pendingLease, + pendingDeadline: pendingDeadline + ) } } } - private func handleIncoming(_ incoming: Incoming) async { + static func startPendingAdmissionDeadline( + registry: MobileBridgeConnectionRegistry, + lease: MobileBridgeConnectionRegistry.PendingAdmissionLease, + timeout: Duration, + onTimeout: @escaping @Sendable () async -> Void + ) -> Task { + Task { + do { + try await Task.sleep(for: timeout) + } catch { + return + } + guard registry.abandonPending(lease) else { return } + await onTimeout() + } + } + + private func handleIncoming( + _ incoming: Incoming, + pendingLease initialPendingLease: MobileBridgeConnectionRegistry.PendingAdmissionLease, + pendingDeadline: Task + ) async { + var pendingLease: MobileBridgeConnectionRegistry.PendingAdmissionLease? = initialPendingLease + var admissionTicket: MobileBridgeConnectionRegistry.AdmissionTicket? + var admissionDeadline: Task? + defer { + pendingDeadline.cancel() + admissionDeadline?.cancel() + if let admissionTicket { + connectionRegistry.abandonAdmission(admissionTicket)?() + } else if let pendingLease { + connectionRegistry.abandonPending(pendingLease) + } + } + do { let accepting = try await incoming.accept() let remoteALPN = try await accepting.alpn() @@ -267,44 +601,105 @@ final class MobileBridgeListener: @unchecked Sendable { } let connection = try await accepting.connect() - try connection.setMaxConcurrentBiStreams(count: 1) - try connection.setMaxConcurrentUniStreams(count: 0) - try await connection.authorizeNatTraversal() + let connectionClose = MobileBridgeCloseOnce { + try? connection.close( + errorCode: 0, + reason: Data("bridge session closed".utf8) + ) + } + let closeAction: MobileBridgeConnectionRegistry.CloseAction = { + connectionClose.close() + } + defer { closeAction() } let idString = connection.remoteId().description + let ticket = connectionRegistry.identifyPending( + initialPendingLease, + endpointId: idString, + close: closeAction + ) + pendingLease = nil + pendingDeadline.cancel() + guard let ticket else { + closeAction() + return + } + admissionTicket = ticket + + let registry = connectionRegistry + admissionDeadline = Task { + do { + try await Task.sleep(for: .seconds(15)) + } catch { + return + } + registry.expireAdmission(ticket)?() + } + + try connection.setMaxConcurrentBiStreams(count: 1) + try connection.setMaxConcurrentUniStreams(count: 0) let stream = try await connection.acceptBi() let reader = MobileBridgeStreamLineReader(stream: stream.recv()) let writer = MobileBridgeFrameWriter(stream: stream.send()) - stateLock.lock() - let window = pairingWindow - stateLock.unlock() + let window = stateLock.withLock { pairingWindow } - let admitted = try await MobileBridgeSession.admit( + guard let admissionOutcome = try await MobileBridgeSession.admit( idString: idString, reader: reader, writer: writer, pairingWindow: window - ) - guard admitted else { - _ = await connection.closed() + ) else { + return + } + + let connectionID = ObjectIdentifier(connection) + let registrationResult: MobileBridgeConnectionRegistry.RegistrationResult + switch admissionOutcome { + case .trusted: + registrationResult = connectionRegistry.registerIfCurrent( + connectionID: connectionID, + ticket: ticket, + close: closeAction + ) + case .paired(let label): + registrationResult = await MobileBridgeTrustedDeviceStore.shared.registerPairedIfCurrent( + endpointId: idString, + label: label, + registry: connectionRegistry, + connectionID: connectionID, + ticket: ticket, + close: closeAction + ) + } + admissionDeadline?.cancel() + + switch registrationResult { + case .registered(let superseded): + superseded.forEach { $0() } + case .rejected(let close): + close() return } + defer { + connectionRegistry.unregister(connectionID: connectionID, endpointId: idString) + } - // Registered only once admitted -- `revoke()` must never be - // able to reach a connection that hasn't passed `admit()` yet. - // Unregistered via `defer` so this fires whether `relay()` - // returns normally, this scope exits early, or an error is - // thrown while unwinding out of the enclosing `do` block. - registerLiveConnection(connection, endpointId: idString) - defer { unregisterLiveConnection(connection, endpointId: idString) } + if case .paired = admissionOutcome { + try await writer.writeLine(Data(#"{"ok":true,"paired":true}"#.utf8)) + } + try await connection.authorizeNatTraversal() #if DEBUG dlog("mobileBridge.connected id=\(idString)") #endif - await MobileBridgeSession.relay(reader: reader, writer: writer, idString: idString) - _ = await connection.closed() + await MobileBridgeSession.relay( + reader: reader, + writer: writer, + idString: idString, + closeRemote: closeAction + ) #if DEBUG dlog("mobileBridge.disconnected id=\(idString)") #endif diff --git a/Sources/MobileBridge/MobileBridgeSession.swift b/Sources/MobileBridge/MobileBridgeSession.swift index 8634d001..5288e7a3 100644 --- a/Sources/MobileBridge/MobileBridgeSession.swift +++ b/Sources/MobileBridge/MobileBridgeSession.swift @@ -1,6 +1,41 @@ import Darwin import Foundation +protocol MobileBridgeRelayLineReading: Sendable { + func nextLine() async throws -> Data? +} + +protocol MobileBridgeRelayFrameWriting: Sendable { + func writeLine(_ data: Data) async throws +} + +protocol MobileBridgeRelayLocalPiping: Sendable { + func nextLine() async throws -> Data? + func send(_ data: Data) async throws + func shutdownLocalEnd() +} + +extension MobileBridgeStreamLineReader: MobileBridgeRelayLineReading {} +extension MobileBridgeFrameWriter: MobileBridgeRelayFrameWriting {} + +final class MobileBridgeCloseOnce: @unchecked Sendable { + private let lock = NSLock() + private var action: (@Sendable () -> Void)? + + init(_ action: @escaping @Sendable () -> Void) { + self.action = action + } + + func close() { + let action = lock.withLock { + let action = self.action + self.action = nil + return action + } + action?() + } +} + /// One phone connection's admission and relay to Programa's terminal /// control dispatch. Ported from /// `tools/mobile-spike/Sources/iroh-spike/Bridge.swift`'s `admit`/`relay`/ @@ -20,34 +55,39 @@ import Foundation /// will admit and relay-connect phones, but `handleClient` will return /// immediately without processing any commands. enum MobileBridgeSession { + enum AdmissionOutcome: Sendable { + case trusted + case paired(label: String) + } + /// Admission order: trusted devices are admitted outright; otherwise, /// if a pairing window is open and unexpired, the first line is read /// and checked as a `{"pair":""}` frame; otherwise the - /// connection is rejected as not paired. Returns `true` if the - /// connection should proceed to relay. + /// connection is rejected as not paired. Pairing only proves the token; + /// the listener commits trust and registration as one transaction. static func admit( idString: String, reader: MobileBridgeStreamLineReader, writer: MobileBridgeFrameWriter, pairingWindow: MobileBridgePairingWindow? - ) async throws -> Bool { + ) async throws -> AdmissionOutcome? { if await MobileBridgeTrustedDeviceStore.shared.isTrusted(idString) { - return true + return .trusted } - if let pairingWindow, await pairingWindow.isOpen { + if let pairingWindow, pairingWindow.isOpen { guard let firstLine = try await reader.nextLine() else { - return false + return nil } guard let object = try? JSONSerialization.jsonObject(with: firstLine) as? [String: Any], let presentedToken = object["pair"] as? String else { try? await writer.writeLine(errorFrame(id: nil, code: "pairing_failed")) - return false + return nil } - let matched = await pairingWindow.attemptConsume(Data(presentedToken.utf8)) + let matched = pairingWindow.attemptConsume(Data(presentedToken.utf8)) if matched { // The phone may send a human-readable name alongside the // token so the device list reads "Franco's iPhone" rather @@ -58,18 +98,16 @@ enum MobileBridgeSession { .trimmingCharacters(in: .whitespacesAndNewlines) .prefix(64) .description - let resolvedLabel = (label?.isEmpty == false) ? label! : "paired-device" - await MobileBridgeTrustedDeviceStore.shared.add(endpointId: idString, label: resolvedLabel) - try await writer.writeLine(Data(#"{"ok":true,"paired":true}"#.utf8)) - return true + let resolvedLabel = label.flatMap { $0.isEmpty ? nil : $0 } ?? "paired-device" + return .paired(label: resolvedLabel) } else { try? await writer.writeLine(errorFrame(id: nil, code: "pairing_failed")) - return false + return nil } } try? await writer.writeLine(errorFrame(id: nil, code: "not_paired")) - return false + return nil } /// Creates a connected `socketpair`, hands one end to @@ -84,8 +122,12 @@ enum MobileBridgeSession { static func relay( reader: MobileBridgeStreamLineReader, writer: MobileBridgeFrameWriter, - idString: String + idString: String, + closeRemote: @escaping @Sendable () -> Void ) async { + let remoteClose = MobileBridgeCloseOnce(closeRemote) + defer { remoteClose.close() } + // Greet every admitted phone, pairing and trusted reconnect alike, so a // renamed Mac corrects itself on the next connect instead of staying // stale until re-pair. Shaped as an event frame ("event" key, no "id") @@ -112,38 +154,65 @@ enum MobileBridgeSession { TerminalController.shared.handleClient(remoteFD, peerPid: getpid(), ignoresListenerState: true) } - await withTaskGroup(of: Void.self) { group in - group.addTask { - do { - while let line = try await reader.nextLine() { - await forwardPhoneLine(line, pipe: pipe, writer: writer, idString: idString) + await pump( + reader: reader, + writer: writer, + pipe: pipe, + closeRemote: { remoteClose.close() }, + idString: idString + ) + } + + static func pump( + reader: any MobileBridgeRelayLineReading, + writer: any MobileBridgeRelayFrameWriting, + pipe: any MobileBridgeRelayLocalPiping, + closeRemote: @escaping @Sendable () -> Void, + idString: String = "unknown" + ) async { + let shutdown = MobileBridgeCloseOnce { + closeRemote() + pipe.shutdownLocalEnd() + } + + await withTaskCancellationHandler { + await withTaskGroup(of: Void.self) { group in + group.addTask { + do { + while let line = try await reader.nextLine() { + await forwardPhoneLine( + line, + pipe: pipe, + writer: writer, + idString: idString + ) + } + } catch { + NSLog("MobileBridge: phone read error for %@: %@", idString, "\(error)") } - } catch { - NSLog("MobileBridge: phone read error for %@: %@", idString, "\(error)") } - // Signal the local side's blocking read to unblock (EOF) - // once the phone has disconnected, so `handleClient`'s - // read loop can also observe the peer going away. - pipe.closeLocalEnd() - } - group.addTask { - do { - while let line = try await pipe.nextLine() { - try await writer.writeLine(line) + group.addTask { + do { + while let line = try await pipe.nextLine() { + try await writer.writeLine(line) + } + } catch { + NSLog("MobileBridge: local read error for %@: %@", idString, "\(error)") } - } catch { - NSLog("MobileBridge: local read error for %@: %@", idString, "\(error)") } + await group.next() + shutdown.close() + group.cancelAll() } - await group.next() - group.cancelAll() + } onCancel: { + shutdown.close() } } private static func forwardPhoneLine( _ line: Data, - pipe: MobileBridgeLocalPipe, - writer: MobileBridgeFrameWriter, + pipe: any MobileBridgeRelayLocalPiping, + writer: any MobileBridgeRelayFrameWriting, idString: String ) async { guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { @@ -196,11 +265,12 @@ enum MobileBridgeSession { /// `handleClient` and this pipe's local end both perform blocking /// `read`/`write` syscalls that must never run on Swift Concurrency's /// cooperative thread pool. -final class MobileBridgeLocalPipe: @unchecked Sendable { +final class MobileBridgeLocalPipe: MobileBridgeRelayLocalPiping, @unchecked Sendable { let remoteFD: Int32 private let localFD: Int32 private var buffer = Data() private let closeLock = NSLock() + private var localShutdown = false private var localClosed = false private init(localFD: Int32, remoteFD: Int32) { @@ -221,11 +291,22 @@ final class MobileBridgeLocalPipe: @unchecked Sendable { /// closes `remoteFD` itself via its own `defer`, so this must never /// touch `remoteFD`. func closeLocalEnd() { - closeLock.lock() - defer { closeLock.unlock() } - guard !localClosed else { return } - localClosed = true - Darwin.close(localFD) + closeLock.withLock { + guard !localClosed else { return } + localClosed = true + Darwin.close(localFD) + } + } + + /// Wakes both blocking local-end syscalls without releasing the file + /// descriptor. The pump closes it only after both child tasks have joined, + /// so a concurrent teardown cannot target a reused descriptor. + func shutdownLocalEnd() { + closeLock.withLock { + guard !localClosed, !localShutdown else { return } + localShutdown = true + Darwin.shutdown(localFD, SHUT_RDWR) + } } private func readLineBlocking() throws -> Data? { diff --git a/Sources/MobileBridge/MobileBridgeSettings.swift b/Sources/MobileBridge/MobileBridgeSettings.swift index 7ba4cbf0..b4b4ac62 100644 --- a/Sources/MobileBridge/MobileBridgeSettings.swift +++ b/Sources/MobileBridge/MobileBridgeSettings.swift @@ -65,14 +65,28 @@ struct MobileBridgeTrustedDevice: Codable, Equatable, Identifiable { /// plaintext JSON under `MobileBridgeHome`, owner-only (0600) permissions. /// Unlike the spike, devices can also be removed (Settings "Remove" action). actor MobileBridgeTrustedDeviceStore { + typealias Persistence = @Sendable (Data, URL) throws -> Void + + struct RevocationResult: Sendable { + let closeActions: [MobileBridgeConnectionRegistry.CloseAction] + let persistenceFailure: String? + } + static let shared = MobileBridgeTrustedDeviceStore() private var devices: [MobileBridgeTrustedDevice] = [] private var didLoad = false private let fileURL: URL + private let persistence: Persistence - private init(fileURL: URL = MobileBridgeHome.directory().appendingPathComponent("mobile-bridge-trusted-devices.json")) { + init( + fileURL: URL = MobileBridgeHome.directory().appendingPathComponent("mobile-bridge-trusted-devices.json"), + persistence: @escaping Persistence = { data, fileURL in + try MobileBridgeTrustedDeviceStore.persistToDisk(data, fileURL) + } + ) { self.fileURL = fileURL + self.persistence = persistence } private func loadIfNeeded() { @@ -90,22 +104,63 @@ actor MobileBridgeTrustedDeviceStore { return devices.contains { $0.endpointId == endpointId } } - /// Persists `endpointId` as trusted. Idempotent: re-adding an - /// already-trusted device is a no-op (no duplicate entries, no rewrite). - @discardableResult - func add(endpointId: String, label: String) -> Bool { - loadIfNeeded() - guard !devices.contains(where: { $0.endpointId == endpointId }) else { return false } - devices.append(MobileBridgeTrustedDevice(endpointId: endpointId, label: label, pairedAt: Date())) - persist() - return true + func registerPairedIfCurrent( + endpointId: String, + label: String, + registry: MobileBridgeConnectionRegistry, + connectionID: ObjectIdentifier, + ticket: MobileBridgeConnectionRegistry.AdmissionTicket, + close: @escaping MobileBridgeConnectionRegistry.CloseAction + ) -> MobileBridgeConnectionRegistry.RegistrationResult { + registry.registerIfCurrent( + connectionID: connectionID, + ticket: ticket, + close: close, + beforeRegister: { + self.loadIfNeeded() + guard !self.devices.contains(where: { $0.endpointId == endpointId }) else { + return true + } + + let previousDevices = self.devices + self.devices.append(MobileBridgeTrustedDevice( + endpointId: endpointId, + label: label, + pairedAt: Date() + )) + do { + try self.persist() + return true + } catch { + self.devices = previousDevices + NSLog("MobileBridge: failed to persist paired device: %@", "\(error)") + return false + } + } + ) } - /// Revokes a paired device immediately (Settings "Remove" action). - func remove(endpointId: String) { + func revokeAndClaimConnections( + endpointId: String, + registry: MobileBridgeConnectionRegistry + ) -> RevocationResult { loadIfNeeded() + let previousDevices = devices devices.removeAll { $0.endpointId == endpointId } - persist() + do { + try persist() + } catch { + devices = previousDevices + return RevocationResult( + closeActions: [], + persistenceFailure: String(describing: error) + ) + } + + return RevocationResult( + closeActions: registry.revoke(endpointId: endpointId), + persistenceFailure: nil + ) } func allDevices() -> [MobileBridgeTrustedDevice] { @@ -113,17 +168,46 @@ actor MobileBridgeTrustedDeviceStore { return devices.sorted { $0.pairedAt > $1.pairedAt } } - private func persist() { - guard let data = try? JSONEncoder().encode(devices) else { return } - try? FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), + private func persist() throws { + let data = try JSONEncoder().encode(devices) + try persistence(data, fileURL) + } + + private static func persistToDisk(_ data: Data, _ fileURL: URL) throws { + let fileManager = FileManager.default + let directory = fileURL.deletingLastPathComponent() + try fileManager.createDirectory( + at: directory, withIntermediateDirectories: true ) - try? data.write(to: fileURL, options: .atomic) - try? FileManager.default.setAttributes( + + let temporaryURL = directory.appendingPathComponent( + ".\(fileURL.lastPathComponent).\(UUID().uuidString).tmp" + ) + var committed = false + defer { + if !committed { + try? fileManager.removeItem(at: temporaryURL) + } + } + + try data.write(to: temporaryURL, options: [.atomic, .withoutOverwriting]) + try fileManager.setAttributes( [.posixPermissions: 0o600], - ofItemAtPath: fileURL.path + ofItemAtPath: temporaryURL.path ) + + if fileManager.fileExists(atPath: fileURL.path) { + _ = try fileManager.replaceItemAt( + fileURL, + withItemAt: temporaryURL, + backupItemName: nil, + options: .usingNewMetadataOnly + ) + } else { + try fileManager.moveItem(at: temporaryURL, to: fileURL) + } + committed = true } } diff --git a/Sources/MobileBridge/MobileBridgeStreamSupport.swift b/Sources/MobileBridge/MobileBridgeStreamSupport.swift index 951a9654..092049ae 100644 --- a/Sources/MobileBridge/MobileBridgeStreamSupport.swift +++ b/Sources/MobileBridge/MobileBridgeStreamSupport.swift @@ -1,6 +1,10 @@ import Foundation import IrohLib +enum MobileBridgeStreamLineReaderError: Error { + case frameTooLarge +} + /// Buffers reads from a QUIC `RecvStream` and splits them on `\n`, mirroring /// the newline-delimited JSON-RPC framing Programa's own control socket /// uses. Ported verbatim (renamed) from @@ -10,9 +14,12 @@ import IrohLib /// Not safe to call `nextLine()` concurrently from two callers -- each /// instance is driven by exactly one reader task for its lifetime. final class MobileBridgeStreamLineReader: @unchecked Sendable { + private static let maximumLineByteCount = 8 * 1024 * 1024 + private let stream: RecvStream private var buffer = Data() - private let chunkSize: UInt32 = 65536 + private var scannedByteCount = 0 + private let chunkSize = 65_536 init(stream: RecvStream) { self.stream = stream @@ -22,18 +29,37 @@ final class MobileBridgeStreamLineReader: @unchecked Sendable { /// stream. func nextLine() async throws -> Data? { while true { - if let newlineIndex = buffer.firstIndex(of: 0x0A) { + let searchStartIndex = buffer.index( + buffer.startIndex, + offsetBy: scannedByteCount + ) + if let newlineIndex = buffer[searchStartIndex...].firstIndex(of: 0x0A) { + guard buffer.distance(from: buffer.startIndex, to: newlineIndex) + <= Self.maximumLineByteCount + else { + throw MobileBridgeStreamLineReaderError.frameTooLarge + } let line = Data(buffer[buffer.startIndex ..< newlineIndex]) buffer.removeSubrange(buffer.startIndex ... newlineIndex) + scannedByteCount = 0 return line } - let chunk = try await stream.read(sizeLimit: chunkSize) + scannedByteCount = buffer.count + guard buffer.count <= Self.maximumLineByteCount else { + throw MobileBridgeStreamLineReaderError.frameTooLarge + } + + let bytesUntilOverflow = Self.maximumLineByteCount + 1 - buffer.count + let readLimit = UInt32(min(chunkSize, bytesUntilOverflow)) + let chunk = try await stream.read(sizeLimit: readLimit) if chunk.isEmpty { if !buffer.isEmpty { let remaining = buffer buffer.removeAll() + scannedByteCount = 0 return remaining } + scannedByteCount = 0 return nil } buffer.append(chunk) @@ -88,7 +114,8 @@ enum MobileBridgeBase64URL { /// can't be replayed later to pair a second device once the intended one /// has paired. Ported verbatim (renamed) from /// `tools/mobile-spike/Sources/iroh-spike/PairingWindow.swift`. -actor MobileBridgePairingWindow { +final class MobileBridgePairingWindow: @unchecked Sendable { + private let lock = NSLock() private var tokenData: Data? private let expiresAt: ContinuousClock.Instant @@ -98,7 +125,9 @@ actor MobileBridgePairingWindow { } var isOpen: Bool { - tokenData != nil && ContinuousClock.now < expiresAt + lock.withLock { + tokenData != nil && ContinuousClock.now < expiresAt + } } /// Compares `presented` against the window's token in constant time. On @@ -106,10 +135,18 @@ actor MobileBridgePairingWindow { /// leaves the window open -- a mistyped attempt shouldn't lock out a /// legitimate retry within the 5-minute window -- and returns `false`. func attemptConsume(_ presented: Data) -> Bool { - guard let tokenData, ContinuousClock.now < expiresAt else { return false } - guard MobileBridgeConstantTime.equal(tokenData, presented) else { return false } - self.tokenData = nil - return true + lock.withLock { + guard let tokenData, ContinuousClock.now < expiresAt else { return false } + guard MobileBridgeConstantTime.equal(tokenData, presented) else { return false } + self.tokenData = nil + return true + } + } + + func invalidate() { + lock.withLock { + tokenData = nil + } } } diff --git a/Sources/Panels/BrowserDataImport.swift b/Sources/Panels/BrowserDataImport.swift index 3e835d97..b70eb6eb 100644 --- a/Sources/Panels/BrowserDataImport.swift +++ b/Sources/Panels/BrowserDataImport.swift @@ -1944,9 +1944,9 @@ enum BrowserDataImporter { while value.hasPrefix(".") { value.removeFirst() } - guard !value.isEmpty else { continue } - guard seen.insert(value).inserted else { continue } - result.append(value) + guard let canonicalValue = canonicalDomain(value) else { continue } + guard seen.insert(canonicalValue).inserted else { continue } + result.append(canonicalValue) } return result } @@ -2533,16 +2533,32 @@ enum BrowserDataImporter { return Array(dedupedByKey.values) } - private static func domainMatches(host: String, filters: [String]) -> Bool { - if filters.isEmpty { return true } - var normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - while normalizedHost.hasPrefix(".") { - normalizedHost.removeFirst() + private static func canonicalDomain(_ raw: String) -> String? { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + while value.hasPrefix(".") { + value.removeFirst() + } + guard !value.isEmpty else { return nil } + guard let components = URLComponents(string: "https://\(value)"), + components.user == nil, + components.password == nil, + components.port == nil, + components.path.isEmpty, + components.query == nil, + components.fragment == nil, + let host = components.host else { + return value } - guard !normalizedHost.isEmpty else { return false } + return host.lowercased() + } + + static func domainMatches(host: String, filters: [String]) -> Bool { + if filters.isEmpty { return true } + guard let normalizedHost = canonicalDomain(host) else { return false } for filter in filters { - if normalizedHost == filter { return true } - if normalizedHost.hasSuffix(".\(filter)") { return true } + guard let normalizedFilter = canonicalDomain(filter) else { continue } + if normalizedHost == normalizedFilter { return true } + if normalizedHost.hasSuffix(".\(normalizedFilter)") { return true } } return false } diff --git a/Sources/Panels/BrowserDownloadDelegate.swift b/Sources/Panels/BrowserDownloadDelegate.swift index 5b3d520f..dd176331 100644 --- a/Sources/Panels/BrowserDownloadDelegate.swift +++ b/Sources/Panels/BrowserDownloadDelegate.swift @@ -2,6 +2,21 @@ import AppKit import Bonsplit import WebKit +struct BrowserDownloadFinalizationError: Error, CustomNSError { + let moveError: Error + let retainedTempURL: URL + + static let errorDomain = "com.darkroom.programa.browser-download-finalization" + var errorCode: Int { 1 } + var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: moveError.localizedDescription, + NSUnderlyingErrorKey: moveError, + "BrowserDownloadRetainedTemporaryURL": retainedTempURL, + ] + } +} + // MARK: - Download Delegate /// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI @@ -56,6 +71,26 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { return candidate } + static func finalizeDownload( + from tempURL: URL, + to destinationURL: URL, + fileManager: FileManager = .default, + onReady: () -> Void, + onFailure: (Error) -> Void + ) { + do { + try fileManager.moveItem(at: tempURL, to: destinationURL) + onReady() + } catch let moveError { + onFailure( + BrowserDownloadFinalizationError( + moveError: moveError, + retainedTempURL: tempURL + ) + ) + } + } + private func storeState(_ state: DownloadState, for download: WKDownload) { activeDownloadsLock.lock() activeDownloads[ObjectIdentifier(download)] = state @@ -113,15 +148,27 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { // #9: auto-save to ~/Downloads (Safari-style) instead of prompting with a save panel. DispatchQueue.main.async { - self.onDownloadReadyToSave?() let destURL = Self.uniqueDownloadsURL(for: info.suggestedFilename) - do { - try FileManager.default.moveItem(at: info.tempURL, to: destURL) - NSLog("BrowserPanel download saved: %@", destURL.path) - } catch { - NSLog("BrowserPanel download move failed: %@", error.localizedDescription) - try? FileManager.default.removeItem(at: info.tempURL) - } + Self.finalizeDownload( + from: info.tempURL, + to: destURL, + onReady: { + NSLog("BrowserPanel download saved: %@", destURL.path) + self.onDownloadReadyToSave?() + }, + onFailure: { error in + if let finalizationError = error as? BrowserDownloadFinalizationError { + NSLog( + "BrowserPanel download move failed: %@; completed download retained at: %@", + finalizationError.moveError.localizedDescription, + finalizationError.retainedTempURL.path + ) + } else { + NSLog("BrowserPanel download move failed: %@", error.localizedDescription) + } + self.onDownloadFailed?(error) + } + ) } } diff --git a/Sources/Panels/BrowserHistoryStore.swift b/Sources/Panels/BrowserHistoryStore.swift index 3665a0d1..c9a9e975 100644 --- a/Sources/Panels/BrowserHistoryStore.swift +++ b/Sources/Panels/BrowserHistoryStore.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import os func normalizedBrowserHistoryNamespace(bundleIdentifier: String) -> String { if bundleIdentifier.hasPrefix("com.darkroom.programa.debug.") { @@ -15,7 +16,7 @@ func normalizedBrowserHistoryNamespace(bundleIdentifier: String) -> String { final class BrowserHistoryStore: ObservableObject { static let shared = BrowserHistoryStore() - struct Entry: Codable, Identifiable, Hashable { + struct Entry: Codable, Identifiable, Hashable, Sendable { let id: UUID var url: String var title: String? @@ -64,11 +65,61 @@ final class BrowserHistoryStore: ObservableObject { } } + struct Persistence: Sendable { + let load: @Sendable (URL) throws -> Data + let persist: @Sendable ([Entry], URL) throws -> Void + let remove: @Sendable (URL) throws -> Void + + nonisolated static let live = Persistence( + load: { fileURL in + try Data(contentsOf: fileURL) + }, + persist: { snapshot, fileURL in + try BrowserHistoryStore.persistSnapshot(snapshot, to: fileURL) + }, + remove: { fileURL in + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: fileURL.path) else { return } + try fileManager.removeItem(at: fileURL) + } + ) + } + + private final class SaveCancellationToken: Sendable { + private let cancelled = OSAllocatedUnfairLock(initialState: false) + + func cancel() { + cancelled.withLock { $0 = true } + } + + var isCancelled: Bool { + cancelled.withLock { $0 } + } + } + + private struct PendingSave { + let workItem: DispatchWorkItem + let cancellationToken: SaveCancellationToken + } + + private enum LoadState { + case notLoaded + case loaded + case failed + } + @Published private(set) var entries: [Entry] = [] private let fileURL: URL? - private var didLoad: Bool = false - private var saveTask: Task? + private let persistence: Persistence + private let persistenceQueue = DispatchQueue( + label: "com.darkroom.programa.browser-history.persistence", + qos: .utility + ) + private var loadState: LoadState = .notLoaded + private var pendingSave: PendingSave? + private var mutationRevision: UInt64 = 0 + private var isDirty: Bool = false private let maxEntries: Int = 5000 private let saveDebounceNanoseconds: UInt64 = 120_000_000 @@ -86,35 +137,48 @@ final class BrowserHistoryStore: ObservableObject { let score: Double } - init(fileURL: URL? = nil) { + init(fileURL: URL? = nil, persistence: Persistence = .live) { // Avoid calling @MainActor-isolated static methods from default argument context. self.fileURL = fileURL ?? BrowserHistoryStore.defaultHistoryFileURL() + self.persistence = persistence } - func loadIfNeeded() { - guard !didLoad else { return } - didLoad = true - guard let fileURL else { return } + @discardableResult + func loadIfNeeded(retryAfterFailure: Bool = true) -> Bool { + if case .loaded = loadState { return true } + if case .failed = loadState, !retryAfterFailure { return false } + guard let fileURL else { + loadState = .loaded + return true + } migrateLegacyTaggedHistoryFileIfNeeded(to: fileURL) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + loadState = .loaded + return true + } + // Load synchronously on first access so the first omnibar query can use // persisted history immediately (important for deterministic UI behavior). let data: Data do { - data = try Data(contentsOf: fileURL) + data = try persistence.load(fileURL) } catch { - return + loadState = .failed + return false } let decoded: [Entry] do { decoded = try JSONDecoder().decode([Entry].self, from: data) } catch { - return + loadState = .failed + return false } // Most-recent first. entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited }) + loadState = .loaded // Remove entries with invalid hosts (no TLD), e.g. "https://news." let beforeCount = entries.count @@ -127,10 +191,11 @@ final class BrowserHistoryStore: ObservableObject { if entries.count != beforeCount { scheduleSave() } + return true } func recordVisit(url: URL?, title: String?) { - loadIfNeeded() + guard loadIfNeeded() else { return } guard let url else { return } guard let scheme = url.scheme?.lowercased(), @@ -175,7 +240,7 @@ final class BrowserHistoryStore: ObservableObject { } func recordTypedNavigation(url: URL?) { - loadIfNeeded() + guard loadIfNeeded() else { return } guard let url else { return } guard let scheme = url.scheme?.lowercased(), @@ -219,7 +284,7 @@ final class BrowserHistoryStore: ObservableObject { } func suggestions(for input: String, limit: Int = 10) -> [Entry] { - loadIfNeeded() + _ = loadIfNeeded(retryAfterFailure: false) guard limit > 0 else { return [] } let q = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -246,7 +311,7 @@ final class BrowserHistoryStore: ObservableObject { } func recentSuggestions(limit: Int = 10) -> [Entry] { - loadIfNeeded() + _ = loadIfNeeded(retryAfterFailure: false) guard limit > 0 else { return [] } let ranked = entries.sorted { lhs, rhs in @@ -265,7 +330,7 @@ final class BrowserHistoryStore: ObservableObject { @discardableResult func mergeImportedEntries(_ importedEntries: [Entry]) -> Int { - loadIfNeeded() + guard loadIfNeeded() else { return 0 } guard !importedEntries.isEmpty else { return 0 } var mergedCount = 0 @@ -358,17 +423,29 @@ final class BrowserHistoryStore: ObservableObject { } func clearHistory() { - loadIfNeeded() - saveTask?.cancel() - saveTask = nil + _ = loadIfNeeded() + cancelPendingSave() entries = [] guard let fileURL else { return } - try? FileManager.default.removeItem(at: fileURL) + + mutationRevision += 1 + let revision = mutationRevision + isDirty = true + let succeeded = persistenceQueue.sync { + do { + try persistence.remove(fileURL) + return true + } catch { + return false + } + } + loadState = .loaded + completePersistence(revision: revision, succeeded: succeeded) } @discardableResult func removeHistoryEntry(urlString: String) -> Bool { - loadIfNeeded() + guard loadIfNeeded() else { return false } let normalized = normalizedHistoryKey(urlString: urlString) let originalCount = entries.count entries.removeAll { entry in @@ -384,34 +461,67 @@ final class BrowserHistoryStore: ObservableObject { } func flushPendingSaves() { - loadIfNeeded() - saveTask?.cancel() - saveTask = nil - guard let fileURL else { return } - try? Self.persistSnapshot(entries, to: fileURL) + guard loadIfNeeded() else { return } + cancelPendingSave() + guard let fileURL, isDirty else { return } + + let snapshot = entries + let revision = mutationRevision + let succeeded = persistenceQueue.sync { + do { + try persistence.persist(snapshot, fileURL) + return true + } catch { + return false + } + } + completePersistence(revision: revision, succeeded: succeeded) } private func scheduleSave() { guard let fileURL else { return } - saveTask?.cancel() + cancelPendingSave() + mutationRevision += 1 + let revision = mutationRevision + isDirty = true let snapshot = entries - let debounceNanoseconds = saveDebounceNanoseconds + let persistence = persistence + let cancellationToken = SaveCancellationToken() + let workItem = DispatchWorkItem { [weak self] in + guard !cancellationToken.isCancelled else { return } - saveTask = Task.detached(priority: .utility) { + let succeeded: Bool do { - try await Task.sleep(nanoseconds: debounceNanoseconds) // debounce + try persistence.persist(snapshot, fileURL) + succeeded = true } catch { - return + succeeded = false } - if Task.isCancelled { return } - do { - try Self.persistSnapshot(snapshot, to: fileURL) - } catch { - return + Task { @MainActor [weak self] in + self?.completePersistence(revision: revision, succeeded: succeeded) } } + pendingSave = PendingSave(workItem: workItem, cancellationToken: cancellationToken) + persistenceQueue.asyncAfter( + deadline: .now() + .nanoseconds(Int(saveDebounceNanoseconds)), + execute: workItem + ) + } + + private func cancelPendingSave() { + pendingSave?.cancellationToken.cancel() + pendingSave?.workItem.cancel() + pendingSave = nil + } + + private func completePersistence(revision: UInt64, succeeded: Bool) { + guard revision == mutationRevision else { return } + pendingSave = nil + if succeeded { + isDirty = false + } } private func migrateLegacyTaggedHistoryFileIfNeeded(to targetURL: URL) { @@ -550,6 +660,31 @@ final class BrowserHistoryStore: ObservableObject { return normalizedHistoryKey(components: &components) } + private func canonicalizedPercentEscapes(in value: String) -> String { + func isHexDigit(_ byte: UInt8) -> Bool { + switch byte { + case 0x30...0x39, 0x41...0x46, 0x61...0x66: true + default: false + } + } + + var bytes = Array(value.utf8) + var index = 0 + while index + 2 < bytes.count { + guard bytes[index] == 0x25, + isHexDigit(bytes[index + 1]), + isHexDigit(bytes[index + 2]) else { + index += 1 + continue + } + + if bytes[index + 1] >= 0x61 { bytes[index + 1] -= 0x20 } + if bytes[index + 2] >= 0x61 { bytes[index + 2] -= 0x20 } + index += 3 + } + return String(decoding: bytes, as: UTF8.self) + } + private func normalizedHistoryKey(components: inout URLComponents) -> String? { guard let scheme = components.scheme?.lowercased(), scheme == "http" || scheme == "https", @@ -581,7 +716,7 @@ final class BrowserHistoryStore: ObservableObject { let queryPart: String if let query = components.percentEncodedQuery, !query.isEmpty { - queryPart = "?\(query.lowercased())" + queryPart = "?\(canonicalizedPercentEscapes(in: query))" } else { queryPart = "" } diff --git a/Sources/Panels/BrowserImportWizardView.swift b/Sources/Panels/BrowserImportWizardView.swift index 7c66906c..d2e02a46 100644 --- a/Sources/Panels/BrowserImportWizardView.swift +++ b/Sources/Panels/BrowserImportWizardView.swift @@ -602,7 +602,7 @@ private struct BrowserImportDataTypesStepView: View { ) .toggleStyle(.checkbox) .accessibilityIdentifier("BrowserImportCookiesCheckbox") - .onChange(of: viewModel.includeCookies) { _ in viewModel.validationMessage = nil } + .onChange(of: viewModel.includeCookies) { viewModel.validationMessage = nil } Toggle( String(localized: "browser.import.history", defaultValue: "History (visited pages)"), @@ -610,7 +610,7 @@ private struct BrowserImportDataTypesStepView: View { ) .toggleStyle(.checkbox) .accessibilityIdentifier("BrowserImportHistoryCheckbox") - .onChange(of: viewModel.includeHistory) { _ in viewModel.validationMessage = nil } + .onChange(of: viewModel.includeHistory) { viewModel.validationMessage = nil } Toggle( String( @@ -621,7 +621,7 @@ private struct BrowserImportDataTypesStepView: View { ) .toggleStyle(.checkbox) .accessibilityIdentifier("BrowserImportAdditionalDataCheckbox") - .onChange(of: viewModel.includeAdditionalData) { _ in viewModel.validationMessage = nil } + .onChange(of: viewModel.includeAdditionalData) { viewModel.validationMessage = nil } if viewModel.includeAdditionalData { Text( diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index 42562a30..061eba84 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import os /// Batched port scanner that replaces per-shell `ps + lsof` scanning. /// @@ -16,6 +17,13 @@ import Foundation final class PortScanner: @unchecked Sendable { static let shared = PortScanner() + typealias AgentScanOverride = @Sendable ( + _ workspaceIds: Set, + _ agentPIDsByWorkspace: [UUID: Set] + ) -> [UUID: Set] + typealias AgentResultsValidatedHook = @Sendable (_ results: [(UUID, [Int])]) async -> Void + typealias AgentResultsApplyCompletedHook = @Sendable (_ results: [(UUID, [Int])]) -> Void + /// Callback delivers `(workspaceId, panelId, ports)` on the main actor. var onPortsUpdated: (@MainActor (_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)? /// Callback delivers workspace-scoped ports owned by tracked agents. @@ -23,15 +31,15 @@ final class PortScanner: @unchecked Sendable { /// Provider returns tracked agent root PIDs for the given workspaces. var agentPIDsProvider: (@MainActor (_ workspaceIds: Set) -> [UUID: Set])? - // MARK: - State (all guarded by `queue`) + // MARK: - State (guarded by `queue` unless noted) private let queue = DispatchQueue(label: "com.cmux.port-scanner", qos: .utility) /// TTY name per (workspace, panel). private var ttyNames: [PanelKey: String] = [:] - /// Monotonic revision per workspace for tracked agent PID changes. - private var agentRevisionByWorkspace: [UUID: UInt64] = [:] + /// Monotonic revision per workspace for tracked agent PID changes, guarded by this lock. + private let agentRevisionByWorkspace = OSAllocatedUnfairLock(initialState: [UUID: UInt64]()) /// Workspaces with active agent PID tracking that need background rescans. private var trackedAgentWorkspaces: Set = [] @@ -55,6 +63,11 @@ final class PortScanner: @unchecked Sendable { /// Token for the occlusion-state observer registered in `init`. private var occlusionObserver: NSObjectProtocol? + /// Test seams for deterministic agent-port scans and delivery ordering. + private let agentScanOverride: AgentScanOverride? + private let agentResultsValidatedHook: AgentResultsValidatedHook? + private let agentResultsApplyCompletedHook: AgentResultsApplyCompletedHook? + /// Burst scan offsets in seconds from the start of the burst. /// Each scan fires at this absolute offset; the recursive scheduler /// converts to relative delays between consecutive scans. @@ -65,8 +78,18 @@ final class PortScanner: @unchecked Sendable { /// event — 10s latency there is acceptable. private static let agentRescanInterval: TimeInterval = 10 - init() { - registerOcclusionObserver() + init( + observesAppVisibility: Bool = true, + agentScanOverride: AgentScanOverride? = nil, + agentResultsValidatedHook: AgentResultsValidatedHook? = nil, + agentResultsApplyCompletedHook: AgentResultsApplyCompletedHook? = nil + ) { + self.agentScanOverride = agentScanOverride + self.agentResultsValidatedHook = agentResultsValidatedHook + self.agentResultsApplyCompletedHook = agentResultsApplyCompletedHook + if observesAppVisibility { + registerOcclusionObserver() + } } // MARK: - Public API @@ -105,9 +128,15 @@ final class PortScanner: @unchecked Sendable { } } + @MainActor func refreshAgentPorts(workspaceId: UUID, agentPIDs: Set) { + let agentRevision = nextAgentRevision(for: workspaceId) queue.async { [self] in - refreshAgentPortsLocked(workspaceId: workspaceId, agentPIDs: agentPIDs) + refreshAgentPortsLocked( + workspaceId: workspaceId, + agentPIDs: agentPIDs, + agentRevision: agentRevision + ) } } @@ -259,8 +288,15 @@ final class PortScanner: @unchecked Sendable { ) } - private func refreshAgentPortsLocked(workspaceId: UUID, agentPIDs: Set) { - let agentRevision = nextAgentRevision(for: workspaceId) + private func refreshAgentPortsLocked( + workspaceId: UUID, + agentPIDs: Set, + agentRevision: UInt64 + ) { + guard isCurrentAgentRevision( + workspaceId: workspaceId, + expected: agentRevision + ) else { return } let normalizedPIDs = Set(agentPIDs.filter { $0 > 0 }) if normalizedPIDs.isEmpty { trackedAgentWorkspaces.remove(workspaceId) @@ -371,7 +407,15 @@ final class PortScanner: @unchecked Sendable { guard !valid.isEmpty else { return } partial[item.key] = valid } - let inactiveWorkspaceIds = workspaceIds.subtracting(normalizedPIDsByWorkspace.keys) + let inactiveWorkspaceIds = workspaceIds + .subtracting(normalizedPIDsByWorkspace.keys) + .filter { workspaceId in + guard let expectedRevision = agentRevisions[workspaceId] else { return false } + return isCurrentAgentRevision( + workspaceId: workspaceId, + expected: expectedRevision + ) + } if !inactiveWorkspaceIds.isEmpty { trackedAgentWorkspaces.subtract(inactiveWorkspaceIds) updateAgentScanTimerLocked() @@ -391,6 +435,15 @@ final class PortScanner: @unchecked Sendable { ) { guard !workspaceIds.isEmpty else { return } + if let agentScanOverride { + deliverAgentResults( + workspaceIds: workspaceIds, + agentPortsByWorkspace: agentScanOverride(workspaceIds, agentPIDsByWorkspace), + agentRevisions: agentRevisions + ) + return + } + let agentPidToWorkspaces = expandAgentProcessTree(agentPIDsByWorkspace: agentPIDsByWorkspace) guard !agentPidToWorkspaces.isEmpty else { deliverAgentResults( @@ -453,11 +506,19 @@ final class PortScanner: @unchecked Sendable { agentRevisions: agentRevisions ) guard !validatedResults.isEmpty else { return } + if let agentResultsValidatedHook { + await agentResultsValidatedHook(validatedResults.map { ($0.workspaceId, $0.ports) }) + } await MainActor.run { - for (workspaceId, ports) in validatedResults { - agentCallback(workspaceId, ports) + for result in validatedResults { + guard self.isCurrentAgentRevision( + workspaceId: result.workspaceId, + expected: result.revision + ) else { continue } + agentCallback(result.workspaceId, result.ports) } } + agentResultsApplyCompletedHook?(validatedResults.map { ($0.workspaceId, $0.ports) }) } } @@ -465,16 +526,18 @@ final class PortScanner: @unchecked Sendable { workspaceIds: Set, agentPortsByWorkspace: [UUID: Set], agentRevisions: [UUID: UInt64] - ) async -> [(UUID, [Int])] { + ) async -> [(workspaceId: UUID, ports: [Int], revision: UInt64)] { await withCheckedContinuation { continuation in queue.async { [self] in - var results: [(UUID, [Int])] = [] + var results: [(workspaceId: UUID, ports: [Int], revision: UInt64)] = [] for workspaceId in workspaceIds.sorted(by: { $0.uuidString < $1.uuidString }) { - let currentRevision = agentRevisionByWorkspace[workspaceId, default: 0] let expectedRevision = agentRevisions[workspaceId, default: 0] - guard currentRevision == expectedRevision else { continue } + guard isCurrentAgentRevision( + workspaceId: workspaceId, + expected: expectedRevision + ) else { continue } let ports = Array(agentPortsByWorkspace[workspaceId] ?? []).sorted() - results.append((workspaceId, ports)) + results.append((workspaceId, ports, expectedRevision)) } continuation.resume(returning: results) } @@ -482,15 +545,25 @@ final class PortScanner: @unchecked Sendable { } private func agentRevisionSnapshot(for workspaceIds: Set) -> [UUID: UInt64] { - workspaceIds.reduce(into: [UUID: UInt64]()) { partial, workspaceId in - partial[workspaceId] = agentRevisionByWorkspace[workspaceId, default: 0] + agentRevisionByWorkspace.withLock { revisions in + workspaceIds.reduce(into: [UUID: UInt64]()) { partial, workspaceId in + partial[workspaceId] = revisions[workspaceId, default: 0] + } } } private func nextAgentRevision(for workspaceId: UUID) -> UInt64 { - let nextRevision = agentRevisionByWorkspace[workspaceId, default: 0] &+ 1 - agentRevisionByWorkspace[workspaceId] = nextRevision - return nextRevision + agentRevisionByWorkspace.withLock { revisions in + let nextRevision = revisions[workspaceId, default: 0] &+ 1 + revisions[workspaceId] = nextRevision + return nextRevision + } + } + + private func isCurrentAgentRevision(workspaceId: UUID, expected: UInt64) -> Bool { + agentRevisionByWorkspace.withLock { revisions in + revisions[workspaceId, default: 0] == expected + } } // MARK: - Process helpers diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index c128a20a..2f580763 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -41,9 +41,35 @@ enum UITestLaunchManifest { } } +@MainActor +final class PrimaryTabManagerStore: ObservableObject { + @Published private(set) var manager: TabManager + + init(initialManager: TabManager? = nil) { + let resolvedManager = initialManager ?? TabManager() + manager = resolvedManager + bindReplacement(for: resolvedManager) + } + + private func bindReplacement(for managedInstance: TabManager) { + managedInstance.onWindowCloseTeardown = { [weak self, weak managedInstance] in + guard let self, + let managedInstance, + self.manager === managedInstance, + managedInstance.isStopped else { + return + } + + let replacement = TabManager() + self.bindReplacement(for: replacement) + self.manager = replacement + } + } +} + @main struct programaApp: App { - @StateObject private var tabManager: TabManager + @StateObject private var primaryTabManagerStore: PrimaryTabManagerStore @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject private var sidebarState = SidebarState() @StateObject private var sidebarSelectionState = SidebarSelectionState() @@ -60,6 +86,10 @@ struct programaApp: App { @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + private var tabManager: TabManager { + primaryTabManagerStore.manager + } + private var browserToolbarAccessorySpacing: Int { BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw) } @@ -101,7 +131,8 @@ struct programaApp: App { defaults: defaults, nativeGlassAvailable: WindowGlassEffect.isAvailable ) - _tabManager = StateObject(wrappedValue: TabManager()) + let primaryTabManagerStore = PrimaryTabManagerStore() + _primaryTabManagerStore = StateObject(wrappedValue: primaryTabManagerStore) // Rebrand: forward every legacy cmux-prefixed default to its programa key // before anything reads the new keys, so existing users keep their prefs. Self.migrateCmuxDefaultsToProgramaIfNeeded(defaults: defaults) @@ -131,7 +162,11 @@ struct programaApp: App { // UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance // callbacks (e.g. `.onAppear`) are delayed or skipped. - appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + appDelegate.configure( + tabManager: primaryTabManagerStore.manager, + notificationStore: notificationStore, + sidebarState: sidebarState + ) } private static func terminateForMissingLaunchTag() -> Never { diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 117f09a9..580ad4db 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -117,6 +117,8 @@ struct SettingsView: View { @State private var mobileBridgePairingToken: String? @State private var mobileBridgePairingExpiresAt: Date? @State private var mobileBridgePairingErrorMessage: String? + @State private var mobileBridgeRevocationErrorMessage: String? + @State private var mobileBridgeRevocationsInFlight: Set = [] @State private var isPairingMobileBridgeDevice = false private var selectedWorkspacePlacement: NewWorkspacePlacement { @@ -512,6 +514,7 @@ struct SettingsView: View { resetSection case .appearance: appearanceSection + terminalAppearanceSection sidebarSection case .automation: socketControlSection @@ -933,7 +936,10 @@ struct SettingsView: View { } } } + } + @ViewBuilder + private var terminalAppearanceSection: some View { SettingsSectionHeader( title: String(localized: "settings.section.terminal", defaultValue: "Terminal") ) @@ -1127,15 +1133,15 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - String(localized: "settings.sidebarAppearance.showClaudeQuota", defaultValue: "Show Claude Quota"), - subtitle: String(localized: "settings.sidebarAppearance.showClaudeQuota.subtitle", defaultValue: "Show remaining Claude Code usage quota in the sidebar footer.") + String(localized: "settings.sidebarAppearance.showClaudeQuota", defaultValue: "Show Provider Usage"), + subtitle: String(localized: "settings.sidebarAppearance.showClaudeQuota.subtitle", defaultValue: "Show on-demand usage for providers with available usage data in the sidebar footer.") ) { Toggle("", isOn: $showClaudeQuota) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .accessibilityLabel( - String(localized: "settings.sidebarAppearance.showClaudeQuota", defaultValue: "Show Claude Quota") + String(localized: "settings.sidebarAppearance.showClaudeQuota", defaultValue: "Show Provider Usage") ) } // Light/dark tint hex, tint opacity and a section-local reset used to @@ -1327,9 +1333,19 @@ struct SettingsView: View { } private func revokeMobileBridgeDevice(_ device: MobileBridgeTrustedDevice) { + guard mobileBridgeRevocationsInFlight.insert(device.endpointId).inserted else { return } + mobileBridgeRevocationErrorMessage = nil + Task { - await MobileBridgeListener.shared.revoke(endpointId: device.endpointId) + defer { mobileBridgeRevocationsInFlight.remove(device.endpointId) } + let outcome = await MobileBridgeListener.shared.revoke(endpointId: device.endpointId) await refreshMobileBridgePairedDevices() + if outcome == .persistenceFailed { + mobileBridgeRevocationErrorMessage = String( + localized: "settings.phone.devices.revokeFailed", + defaultValue: "Could not remove this device. Its connection remains active. Try again." + ) + } } } @@ -1499,9 +1515,19 @@ struct SettingsView: View { } .buttonStyle(.bordered) .controlSize(.small) + .disabled(mobileBridgeRevocationsInFlight.contains(device.endpointId)) } } } + + if let mobileBridgeRevocationErrorMessage { + SettingsCardDivider() + Text(mobileBridgeRevocationErrorMessage) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } } } } diff --git a/Sources/SidebarQuotaFooter.swift b/Sources/SidebarQuotaFooter.swift index 869a50f3..8a23e779 100644 --- a/Sources/SidebarQuotaFooter.swift +++ b/Sources/SidebarQuotaFooter.swift @@ -1,42 +1,162 @@ import SwiftUI -/// Compact, read-only readout of Claude Code rate-limit headroom (5h / 7d windows), -/// sourced from `~/.claude/tmp/rate-limits.json` via `ClaudeQuotaMonitor`. Renders -/// nothing at all when that file is absent (e.g. the user doesn't run cc-settings) or -/// when the user has turned the readout off in Settings. +extension ProviderUsageProvider { + var localizedDisplayName: String { + switch self { + case .claude: + String(localized: "sidebar.usage.provider.claude", defaultValue: "Claude") + case .codex: + String(localized: "sidebar.usage.provider.codex", defaultValue: "Codex") + } + } +} + +struct SidebarQuotaPresentation { + struct Failure: Identifiable, Equatable { + let provider: ProviderUsageProvider + let message: String + + var id: ProviderUsageProvider { provider } + } + + let availableSnapshots: [ProviderUsageSnapshot] + let failures: [Failure] + let unavailableProviders: [ProviderUsageProvider] + + init(results: [ProviderUsageResult]) { + availableSnapshots = results.compactMap { result in + guard case let .available(snapshot) = result else { return nil } + return snapshot + } + failures = results.compactMap { result in + guard case let .failed(provider, message) = result else { return nil } + return Failure(provider: provider, message: message) + } + unavailableProviders = results.compactMap { result in + guard case let .unavailable(provider) = result else { return nil } + return provider + } + } +} + +/// Provider usage content hosted by the sidebar footer's on-demand popover. struct SidebarQuotaFooter: View { - @ObservedObject private var monitor = ClaudeQuotaMonitor.shared - @AppStorage("sidebarShowClaudeQuota") private var showClaudeQuota = true + @ObservedObject var store: ProviderUsageStore + + private var presentation: SidebarQuotaPresentation { + SidebarQuotaPresentation(results: store.results) + } var body: some View { - if showClaudeQuota, let snapshot = monitor.snapshot { - VStack(alignment: .leading, spacing: 3) { - quotaRow( - label: String(localized: "sidebar.quota.fiveHour", defaultValue: "5h"), - window: snapshot.fiveHour - ) - quotaRow( - label: String(localized: "sidebar.quota.sevenDay", defaultValue: "7d"), - window: snapshot.sevenDay - ) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(String(localized: "sidebar.usage.title", defaultValue: "Provider Usage")) + .font(.headline) + Spacer(minLength: 8) + if store.isRefreshing { + ProgressView() + .controlSize(.small) + .accessibilityLabel( + String(localized: "sidebar.usage.refreshing", defaultValue: "Refreshing usage") + ) + } + Button(String(localized: "sidebar.usage.refresh", defaultValue: "Refresh")) { + Task { await store.refresh() } + } + .controlSize(.small) + .disabled(store.isRefreshing) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if store.results.isEmpty, store.isRefreshing { + loadingState + } else { + if presentation.availableSnapshots.isEmpty { + emptyState + } + + ForEach(presentation.availableSnapshots, id: \.provider) { snapshot in + providerSection(snapshot) + } + + ForEach(presentation.failures) { failure in + failureSection(provider: failure.provider, message: failure.message) + } + + ForEach(presentation.unavailableProviders, id: \.self) { provider in + unavailableSection(provider: provider) + } + } + } + .padding(14) } - // Sidebar spacing grid: bare rows align to the content line (card - // edge 8 + row inset 8), not the card-edge line. - .padding(.horizontal, 16) - .padding(.top, 4) - .padding(.bottom, 8) - } else { - EmptyView() } + .frame(width: 320, height: 340) } - @ViewBuilder - private func quotaRow(label: String, window: ClaudeQuotaWindow) -> some View { - HStack(spacing: 6) { - Text(label) - .font(.system(size: 9, weight: .medium)) + private var loadingState: some View { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(String(localized: "sidebar.usage.loading", defaultValue: "Loading usage…")) .foregroundStyle(.secondary) - .frame(width: 14, alignment: .leading) + } + .frame(maxWidth: .infinity, minHeight: 80, alignment: .center) + .accessibilityElement(children: .combine) + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "sidebar.usage.empty.title", defaultValue: "No usage available")) + .font(.system(size: 12, weight: .semibold)) + Text( + String( + localized: "sidebar.usage.empty.subtitle", + defaultValue: "Sign in to a supported provider, then refresh." + ) + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + private func providerSection(_ snapshot: ProviderUsageSnapshot) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(snapshot.provider.localizedDisplayName) + .font(.system(size: 12, weight: .semibold)) + .accessibilityAddTraits(.isHeader) + + ForEach(snapshot.windows) { window in + usageRow(window) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.55)) + ) + } + + private func usageRow(_ window: ProviderUsageWindow) -> some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(window.label) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + Spacer(minLength: 8) + Text(Self.percentText(window.usedPercent)) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + Text(Self.resetText(window.resetsAt)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } GeometryReader { proxy in ZStack(alignment: .leading) { @@ -47,28 +167,58 @@ struct SidebarQuotaFooter: View { .frame(width: proxy.size.width * CGFloat(window.usedPercent) / 100) } } - .frame(height: 3) + .frame(height: 4) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + String.localizedStringWithFormat( + String( + localized: "sidebar.usage.window.accessibility", + defaultValue: "%1$@, %2$@ used, resets in %3$@" + ), + window.label, + Self.percentText(window.usedPercent), + Self.resetText(window.resetsAt) + ) + ) + } - Text(Self.percentText(window.usedPercent)) - .font(.system(size: 9, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 26, alignment: .trailing) + private func failureSection(provider: ProviderUsageProvider, message: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(provider.localizedDisplayName) + .font(.system(size: 12, weight: .semibold)) + Label(message, systemImage: "exclamationmark.triangle") + .font(.system(size: 11)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.55)) + ) + .accessibilityElement(children: .combine) + } - Text(Self.resetText(window.resetsAt)) - .font(.system(size: 9, weight: .regular, design: .monospaced)) - .foregroundStyle(Color.secondary.opacity(0.7)) - .frame(width: 30, alignment: .trailing) + private func unavailableSection(provider: ProviderUsageProvider) -> some View { + HStack(spacing: 6) { + Text(provider.localizedDisplayName) + .fontWeight(.medium) + Text(String(localized: "sidebar.usage.unavailable", defaultValue: "Unavailable")) + .foregroundStyle(.secondary) } + .font(.system(size: 11)) + .accessibilityElement(children: .combine) } private static func barColor(for usedPercent: Int) -> Color { switch usedPercent { case ..<60: - return .secondary + .secondary case 60..<85: - return .orange + .orange default: - return .red + .red } } @@ -101,10 +251,9 @@ struct SidebarQuotaFooter: View { ) } - let totalDays = totalHours / 24 return String.localizedStringWithFormat( String(localized: "sidebar.quota.resetDays", defaultValue: "%@d"), - String(totalDays) + String(totalHours / 24) ) } } diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index ad40c40d..f26017ef 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -32,17 +32,68 @@ struct SidebarFooter: View { private struct SidebarFooterButtons: View { @ObservedObject var updateViewModel: UpdateViewModel + @AppStorage("sidebarShowClaudeQuota") private var showProviderUsage = true let onSendFeedback: () -> Void var body: some View { HStack(spacing: 4) { SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + if showProviderUsage { + SidebarUsageButton() + } UpdatePill(model: updateViewModel) } .frame(maxWidth: .infinity, alignment: .leading) } } +private struct SidebarUsageButton: View { + private let title = String(localized: "sidebar.usage.button", defaultValue: "Provider Usage") + private let buttonSize: CGFloat = 44 + private let iconSize: CGFloat = 11 + + @StateObject private var store = ProviderUsageStore() + @State private var isPopoverPresented = false + + var body: some View { + Button { + let isOpening = !isPopoverPresented + isPopoverPresented.toggle() + if isOpening { + Task { await store.refresh() } + } else { + store.cancelRefresh() + } + } label: { + Image(systemName: "gauge.with.dots.needle.33percent") + .symbolRenderingMode(.monochrome) + .symbolRasterSize(iconSize, weight: .medium) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxX, + detachedGap: 4 + ) { + SidebarQuotaFooter(store: store) + }) + .safeHelp(title) + .accessibilityLabel(title) + .accessibilityIdentifier("SidebarUsageButton") + .onChange(of: isPopoverPresented) { _, isPresented in + if !isPresented { + store.cancelRefresh() + } + } + .onDisappear { + store.cancelRefresh() + } + } +} + private enum SidebarHelpMenuAction { case importBrowserData case keyboardShortcuts @@ -65,7 +116,7 @@ private struct SidebarHelpMenuButton: View { private let twitterURL = URL(string: "https://x.com/darkroomdevs") private let websiteURL = URL(string: "https://darkroom.engineering") private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") - private let buttonSize: CGFloat = 22 + private let buttonSize: CGFloat = 44 private let iconSize: CGFloat = 11 @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @@ -97,7 +148,6 @@ private struct SidebarHelpMenuButton: View { ) { helpPopover }) - .accessibilityElement(children: .ignore) .safeHelp(helpTitle) .accessibilityLabel(helpTitle) .accessibilityIdentifier("SidebarHelpMenuButton") @@ -329,6 +379,10 @@ private struct ArrowlessPopoverAnchor: NSViewRepresentable Coordinator(isPresented: $isPresented) } + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.dismiss() + } + final class Coordinator: NSObject, NSPopoverDelegate { @Binding var isPresented: Bool diff --git a/Sources/TabManager+GitMetadataPolling.swift b/Sources/TabManager+GitMetadataPolling.swift index 84d79dfe..696840ca 100644 --- a/Sources/TabManager+GitMetadataPolling.swift +++ b/Sources/TabManager+GitMetadataPolling.swift @@ -12,12 +12,12 @@ extension TabManager { /// If a process has exited (SIGKILL, crash, etc.), clears the stale status entry. /// This is the safety net for cases where no hook fires (e.g. SIGKILL). func startAgentPIDSweepTimer() { + guard !isStopped, agentPIDSweepTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) timer.schedule(deadline: .now() + 30, repeating: 30) timer.setEventHandler { [weak self] in - guard let self else { return } DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, !self.isStopped else { return } self.sweepStaleAgentPIDs() } } @@ -29,13 +29,13 @@ extension TabManager { /// remote GitHub state changes (e.g. PR open -> merged) reach sidebar state /// even when the local branch/directory does not change. func startWorkspaceGitMetadataPollTimer() { + guard !isStopped, workspaceGitMetadataPollTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) let interval = Self.workspaceGitMetadataPollInterval timer.schedule(deadline: .now() + interval, repeating: interval) timer.setEventHandler { [weak self] in - guard let self else { return } DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, !self.isStopped else { return } self.refreshTrackedWorkspaceGitMetadata() } } @@ -47,13 +47,13 @@ extension TabManager { /// newly created PRs show up in the sidebar without waiting for the slower /// background sweep across every tracked workspace. func startSelectedWorkspaceGitMetadataPollTimer() { + guard !isStopped, selectedWorkspaceGitMetadataPollTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) let interval = Self.selectedWorkspaceGitMetadataPollInterval timer.schedule(deadline: .now() + interval, repeating: interval) timer.setEventHandler { [weak self] in - guard let self else { return } DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, !self.isStopped else { return } self.refreshSelectedWorkspaceGitMetadata() } } @@ -86,6 +86,7 @@ extension TabManager { } private func refreshTrackedWorkspaceGitMetadata() { + guard !isStopped else { return } let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) let selectedWorkspaceId = selectedWorkspace?.id let now = Date() @@ -119,6 +120,7 @@ extension TabManager { } private func refreshSelectedWorkspaceGitMetadata() { + guard !isStopped else { return } guard let workspace = selectedWorkspace, let focusedPanelId = workspace.focusedPanelId else { return @@ -252,7 +254,8 @@ extension TabManager { panelId: UUID, reason: String = "initial" ) { - guard let workspace = workspace(withId: workspaceId), + guard !isStopped, + let workspace = workspace(withId: workspaceId), !workspace.isRemoteWorkspace else { return } @@ -276,7 +279,8 @@ extension TabManager { reason: String, delays: [TimeInterval] = [0] ) -> Bool { - guard let workspace = workspace(withId: workspaceId), + guard !isStopped, + let workspace = workspace(withId: workspaceId), workspace.panels[panelId] != nil, let directory = gitProbeDirectory(for: workspace, panelId: panelId) else { return false diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 3c4f2702..cbbecec3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -429,6 +429,7 @@ final class NotificationBurstCoalescer { private let delay: TimeInterval private var isFlushScheduled = false private var pendingAction: (() -> Void)? + private var scheduleGeneration: UInt64 = 0 init(delay: TimeInterval = 1.0 / 30.0) { self.delay = max(0, delay) @@ -440,16 +441,30 @@ final class NotificationBurstCoalescer { scheduleFlushIfNeeded() } + var hasPendingWork: Bool { + isFlushScheduled || pendingAction != nil + } + + func cancel() { + precondition(Thread.isMainThread, "NotificationBurstCoalescer must be used on the main thread") + scheduleGeneration &+= 1 + isFlushScheduled = false + pendingAction = nil + } + private func scheduleFlushIfNeeded() { guard !isFlushScheduled else { return } isFlushScheduled = true + scheduleGeneration &+= 1 + let generation = scheduleGeneration DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.flush() + self?.flush(generation: generation) } } - private func flush() { + private func flush(generation: UInt64) { precondition(Thread.isMainThread, "NotificationBurstCoalescer must be used on the main thread") + guard scheduleGeneration == generation else { return } isFlushScheduled = false guard let action = pendingAction else { return } pendingAction = nil @@ -486,6 +501,20 @@ struct RecentlyClosedBrowserStack { @MainActor class TabManager: ObservableObject { + struct LifecycleResourceSnapshot { + let isStopped: Bool + let observerCount: Int + let hasAgentPIDSweepTimer: Bool + let hasWorkspaceGitMetadataPollTimer: Bool + let hasSelectedWorkspaceGitMetadataPollTimer: Bool + let workspaceGitProbeTimerCount: Int + let workspaceGitProbeGenerationCount: Int + let workspaceGitTrackedDirectoryCount: Int + let hasWorkspaceCycleCooldownTask: Bool + let hasPendingPanelTitleCoalescerWork: Bool + let uiTestCancellableCount: Int + } + struct WorkspaceGitProbeKey: Hashable { let workspaceId: UUID let panelId: UUID @@ -500,6 +529,7 @@ class TabManager: ObservableObject { /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. weak var window: NSWindow? + var onWindowCloseTeardown: (() -> Void)? @Published var tabs: [Workspace] = [] @Published var isWorkspaceCycleHot: Bool = false @@ -573,7 +603,9 @@ class TabManager: ObservableObject { selectionSideEffectsGeneration &+= 1 let generation = selectionSideEffectsGeneration DispatchQueue.main.async { [weak self] in - guard let self, self.selectionSideEffectsGeneration == generation else { return } + guard let self, + !self.isStopped, + self.selectionSideEffectsGeneration == generation else { return } if let focusTransitionRequest { guard self.focusTransitionCoordinator.completeTransition(focusTransitionRequest) else { return @@ -601,6 +633,7 @@ class TabManager: ObservableObject { } } } + private(set) var isStopped = false private var observers: [NSObjectProtocol] = [] private var suppressFocusFlash = false var lastFocusedPanelByTab: [UUID: UUID] = [:] @@ -680,6 +713,28 @@ class TabManager: ObservableObject { var uiTestCancellables = Set() #endif + var lifecycleResourceSnapshot: LifecycleResourceSnapshot { +#if DEBUG + let uiTestCancellableCount = uiTestCancellables.count +#else + let uiTestCancellableCount = 0 +#endif + return LifecycleResourceSnapshot( + isStopped: isStopped, + observerCount: observers.count, + hasAgentPIDSweepTimer: agentPIDSweepTimer != nil, + hasWorkspaceGitMetadataPollTimer: workspaceGitMetadataPollTimer != nil, + hasSelectedWorkspaceGitMetadataPollTimer: selectedWorkspaceGitMetadataPollTimer != nil, + workspaceGitProbeTimerCount: workspaceGitProbeTimersByKey.values.reduce(0) { $0 + $1.count }, + workspaceGitProbeGenerationCount: workspaceGitProbeGenerationByKey.count, + workspaceGitTrackedDirectoryCount: workspaceGitTrackedDirectoryByKey.count, + hasWorkspaceCycleCooldownTask: workspaceCycleCooldownTask != nil, + hasPendingPanelTitleCoalescerWork: panelTitleUpdateCoalescer.hasPendingWork + || !pendingPanelTitleUpdates.isEmpty, + uiTestCancellableCount: uiTestCancellableCount + ) + } + init(initialWorkingDirectory: String? = nil) { addWorkspace(workingDirectory: initialWorkingDirectory) observers.append(NotificationCenter.default.addObserver( @@ -688,7 +743,7 @@ class TabManager: ObservableObject { queue: .main ) { [weak self] notification in MainActor.assumeIsolated { [weak self] in - guard let self else { return } + guard let self, !self.isStopped else { return } guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return } @@ -701,7 +756,7 @@ class TabManager: ObservableObject { queue: .main ) { [weak self] notification in MainActor.assumeIsolated { [weak self] in - guard let self else { return } + guard let self, !self.isStopped else { return } guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } dismissPanelNotificationOnFocusIfActive(tabId: tabId, panelId: surfaceId) @@ -721,9 +776,19 @@ class TabManager: ObservableObject { deinit { workspaceCycleCooldownTask?.cancel() - agentPIDSweepTimer?.cancel() - workspaceGitMetadataPollTimer?.cancel() - selectedWorkspaceGitMetadataPollTimer?.cancel() + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + for timer in [agentPIDSweepTimer, workspaceGitMetadataPollTimer, selectedWorkspaceGitMetadataPollTimer] { + timer?.setEventHandler {} + timer?.cancel() + } + for timers in workspaceGitProbeTimersByKey.values { + for timer in timers { + timer.setEventHandler {} + timer.cancel() + } + } } /// Wires both the browser-restore stack and the terminal close-undo callback for `workspace`. @@ -1216,6 +1281,7 @@ class TabManager: ObservableObject { delays: [TimeInterval], reason: String ) { + guard !isStopped else { return } let normalizedDirectory = normalizeDirectory(directory) let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) let generation = UUID() @@ -1237,7 +1303,8 @@ class TabManager: ObservableObject { timer.setEventHandler { [weak self] in let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) Task { @MainActor [weak self] in - self?.applyWorkspaceGitMetadataSnapshot( + guard let self, !self.isStopped else { return } + self.applyWorkspaceGitMetadataSnapshot( snapshot, generation: generation, probeKey: key, @@ -1286,6 +1353,7 @@ class TabManager: ObservableObject { expectedDirectory: String, isLastAttempt: Bool ) { + guard !isStopped else { return } defer { if shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt, workspaceGitProbeGenerationByKey[probeKey] == generation { @@ -1870,6 +1938,68 @@ class TabManager: ObservableObject { return trimmed } + /// Permanently tears down every workspace still owned by a closing window. Context removal, + /// rather than tab mutation, excludes the window from later session snapshots; no live panel, + /// remote session, undo transfer, or callback may outlive the context. + func teardownForWindowClose(notifyOwner: Bool = true) { + guard !isStopped else { return } + isStopped = true + + selectionSideEffectsGeneration &+= 1 + pendingWorkspaceUnfocusTarget = nil + workspaceCycleGeneration &+= 1 + workspaceCycleCooldownTask?.cancel() + workspaceCycleCooldownTask = nil + isWorkspaceCycleHot = false + + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + observers.removeAll() + + for timer in [agentPIDSweepTimer, workspaceGitMetadataPollTimer, selectedWorkspaceGitMetadataPollTimer] { + timer?.setEventHandler {} + timer?.cancel() + } + agentPIDSweepTimer = nil + workspaceGitMetadataPollTimer = nil + selectedWorkspaceGitMetadataPollTimer = nil + + for timers in workspaceGitProbeTimersByKey.values { + for timer in timers { + timer.setEventHandler {} + timer.cancel() + } + } + workspaceGitProbeTimersByKey.removeAll() + workspaceGitProbeGenerationByKey.removeAll() + workspaceGitTrackedDirectoryByKey.removeAll() + workspaceGitMetadataLastRefreshedAt.removeAll() + + panelTitleUpdateCoalescer.cancel() + pendingPanelTitleUpdates.removeAll() +#if DEBUG + for cancellable in uiTestCancellables { + cancellable.cancel() + } + uiTestCancellables.removeAll() +#endif + + closedTerminalUndoStore.expireAll() + for workspace in tabs { + workspace.teardownAllPanels() + workspace.teardownRemoteConnection() + unwireClosedBrowserTracking(for: workspace) + workspace.owningTabManager = nil + } + + let ownerCallback = onWindowCloseTeardown + onWindowCloseTeardown = nil + if notifyOwner { + ownerCallback?() + } + } + func closeWorkspace(_ workspace: Workspace) { // Guard against tearing down a workspace this manager doesn't own (e.g. a // stray/external Workspace instance never inserted into `tabs`). Without @@ -2729,6 +2859,7 @@ class TabManager: ObservableObject { } private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) { + guard !isStopped else { return } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let key = PanelTitleUpdateKey(tabId: tabId, panelId: panelId) @@ -2739,6 +2870,7 @@ class TabManager: ObservableObject { } private func flushPendingPanelTitleUpdates() { + guard !isStopped else { return } guard !pendingPanelTitleUpdates.isEmpty else { return } let updates = pendingPanelTitleUpdates pendingPanelTitleUpdates.removeAll(keepingCapacity: true) @@ -2906,6 +3038,7 @@ class TabManager: ObservableObject { } private func activateWorkspaceCycleHotWindow() { + guard !isStopped else { return } workspaceCycleGeneration &+= 1 let generation = workspaceCycleGeneration #if DEBUG @@ -2950,7 +3083,7 @@ class TabManager: ObservableObject { return } await MainActor.run { - guard let self else { return } + guard let self, !self.isStopped else { return } guard self.workspaceCycleGeneration == generation else { return } #if DEBUG let dtMs = self.debugWorkspaceSwitchStartTime > 0 diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 208d455f..38dd8006 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -608,6 +608,7 @@ struct TitlebarControlsView: View { struct HiddenTitlebarSidebarControlsView: View { @ObservedObject var notificationStore: TerminalNotificationStore + @ObservedObject var sidebarState: SidebarState @StateObject private var viewModel = TitlebarControlsViewModel() // Sized to fit all 3 titlebar buttons (sidebar toggle, notifications, new tab) @@ -621,7 +622,7 @@ struct HiddenTitlebarSidebarControlsView: View { TitlebarControlsView( notificationStore: notificationStore, viewModel: viewModel, - onToggleSidebar: { _ = AppDelegate.shared?.sidebarState?.toggle() }, + onToggleSidebar: { sidebarState.toggle() }, onToggleNotifications: { [viewModel] in AppDelegate.shared?.toggleNotificationsPopover( animated: true, diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index c11ef015..0f656b04 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -166,6 +166,7 @@ struct VerticalTabsSidebar: View { let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore + @EnvironmentObject var sidebarState: SidebarState @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -228,7 +229,10 @@ struct VerticalTabsSidebar: View { Spacer(minLength: 0) // Right-aligned: the controls view carries its own ~18pt trailing // inset (shortcut-hint clearance), which serves as the edge padding. - HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) + HiddenTitlebarSidebarControlsView( + notificationStore: notificationStore, + sidebarState: sidebarState + ) } .frame(height: trafficLightPadding) // Flush sidebar (no panel inset): keep the header aligned with @@ -331,20 +335,17 @@ struct VerticalTabsSidebar: View { .background(Color.clear) .modifier(ClearScrollBackground()) } - VStack(spacing: 0) { - SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .frame(maxWidth: .infinity, alignment: .leading) - SidebarQuotaFooter() - .frame(maxWidth: .infinity, alignment: .leading) - } + SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .frame(maxWidth: .infinity, alignment: .leading) // Clearance from the window's bottom-left corner curve so the last // footer row doesn't ride the radius. .padding(.bottom, 6) - // Empty footer space (below/around the help button, feedback row, and - // quota meters) drags the window; the buttons/rows above keep their own + // Empty footer space (below/around the help, usage, and feedback controls) + // drags the window; the buttons/rows above keep their own // clicks via the sibling hit-test walk in windowDragHandleShouldCaptureHit. .background(WindowDragHandleView()) } + .accessibilityElement(children: .contain) .accessibilityIdentifier("Sidebar") ZStack { diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index f1f429cc..e49e8943 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -29,6 +29,9 @@ enum SidebarTelemetryLimits { static let maxStatusEntries = 128 static let maxMetadataBlocks = 128 static let maxAgentPIDs = 128 + // With no protocol dimension, 1...65_535 exhausts the numeric port domain; + // duplicates count toward this raw ingress limit before canonicalization. + static let maxReportedPorts = 65_535 static func utf8ByteCount(_ value: String) -> Int { value.utf8.count @@ -273,9 +276,13 @@ extension Workspace { #endif } - func resetSidebarContext(reason: String = "unspecified") { + func resetSidebarContext( + reason: String = "unspecified", + portScanner: PortScanner = .shared + ) { statusEntries.removeAll() agentPIDs.removeAll() + portScanner.refreshAgentPorts(workspaceId: id, agentPIDs: []) agentListeningPorts.removeAll() logEntries.removeAll() progress = nil @@ -396,23 +403,63 @@ extension Workspace { } func pruneSurfaceMetadata(validSurfaceIds: Set) { - panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } - panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } - panelsWithLiveTitle = panelsWithLiveTitle.filter { validSurfaceIds.contains($0) } - panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } - pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } - manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } - panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } - manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } - surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } - surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } - remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } - panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } - panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } - panelAgentStates = panelAgentStates.filter { validSurfaceIds.contains($0.key) } - panelAgentStateSources = panelAgentStateSources.filter { validSurfaceIds.contains($0.key) } - syncRemotePortScanTTYs() - recomputeListeningPorts() + if panelDirectories.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } + } + if panelTitles.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } + } + if panelsWithLiveTitle.contains(where: { !validSurfaceIds.contains($0) }) { + panelsWithLiveTitle = panelsWithLiveTitle.filter { validSurfaceIds.contains($0) } + } + if panelCustomTitles.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } + } + if pinnedPanelIds.contains(where: { !validSurfaceIds.contains($0) }) { + pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } + } + if manualUnreadPanelIds.contains(where: { !validSurfaceIds.contains($0) }) { + manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } + } + if panelGitBranches.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } + } + if manualUnreadMarkedAt.keys.contains(where: { !validSurfaceIds.contains($0) }) { + manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } + } + let didPruneListeningPorts = surfaceListeningPorts.keys.contains(where: { + !validSurfaceIds.contains($0) + }) + if didPruneListeningPorts { + surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } + } + let didPruneTTYNames = surfaceTTYNames.keys.contains(where: { + !validSurfaceIds.contains($0) + }) + if didPruneTTYNames { + surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } + } + if remoteDetectedSurfaceIds.contains(where: { !validSurfaceIds.contains($0) }) { + remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } + } + if panelShellActivityStates.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } + } + if panelPullRequests.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } + } + if panelAgentStates.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelAgentStates = panelAgentStates.filter { validSurfaceIds.contains($0.key) } + } + if panelAgentStateSources.keys.contains(where: { !validSurfaceIds.contains($0) }) { + panelAgentStateSources = panelAgentStateSources.filter { validSurfaceIds.contains($0.key) } + } + if didPruneTTYNames { + syncRemotePortScanTTYs() + } + if didPruneListeningPorts { + recomputeListeningPorts() + } } func recomputeListeningPorts() { diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 1f9436b5..72528787 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -1,4 +1,5 @@ import XCTest +import Combine #if canImport(Programa_DEV) @testable import Programa_DEV @@ -101,6 +102,124 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { super.tearDown() } + func testOrphanReconciliationRetainsOneRecoveryWorkspacePerSuccessfulSessionOnly() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let candidates: [(sessionId: String, workingDirectory: String?)] = [ + ("failed-before-first-success", "/tmp/failed-before"), + ("first-success", "/tmp/first-success"), + ("failed-between-successes", "/tmp/failed-between"), + ("second-success", "/tmp/second-success"), + ] + let successfulSessionIds: Set = ["first-success", "second-success"] + var attemptedSessionIds: [String] = [] + var attemptedWorkspaceIds: [UUID] = [] + var attemptedWorkspaceDirectories: [String] = [] + var successfulWorkspaceIds: Set = [] + + let result = try XCTUnwrap(appDelegate.reconcileOrphanedEscrowedSessions( + candidates: candidates, + attemptRecovery: { candidate, workspace in + attemptedSessionIds.append(candidate.sessionId) + attemptedWorkspaceIds.append(workspace.id) + attemptedWorkspaceDirectories.append(workspace.currentDirectory) + guard successfulSessionIds.contains(candidate.sessionId) else { return false } + successfulWorkspaceIds.insert(workspace.id) + return true + } + )) + defer { closeWindow(withId: result.windowId) } + + let manager = try XCTUnwrap(appDelegate.tabManagerFor(windowId: result.windowId)) + XCTAssertEqual(result.recoveredCount, successfulSessionIds.count) + XCTAssertEqual( + attemptedSessionIds, + candidates.map(\.sessionId), + "Reconciliation must attempt every distinct orphan once and in enumeration order" + ) + XCTAssertEqual( + Set(attemptedSessionIds).count, + attemptedSessionIds.count, + "A session ID must not be attempted twice during one reconciliation pass" + ) + guard attemptedWorkspaceIds.count == candidates.count else { + XCTFail("Reconciliation must attempt all candidates before workspace-identity assertions") + return + } + XCTAssertEqual( + Set(attemptedWorkspaceIds).count, + candidates.count, + "Every orphan must get a fresh candidate workspace so failed-session metadata cannot leak into a later success" + ) + XCTAssertEqual( + attemptedWorkspaceDirectories, + candidates.compactMap(\.workingDirectory), + "Each recovery attempt must start in its own orphan session's working directory" + ) + XCTAssertEqual( + manager.tabs.count, + successfulSessionIds.count, + "Failed revive attempts must not leave empty workspaces in a partially successful recovery window" + ) + XCTAssertEqual( + Set(manager.tabs.map(\.id)), + successfulWorkspaceIds, + "The recovery window must retain exactly the workspaces whose revival succeeded" + ) + XCTAssertTrue( + manager.tabs.allSatisfy { !$0.panels.isEmpty }, + "Every retained recovery workspace must contain a recovered panel rather than an empty tab" + ) + } + + func testOrphanReconciliationRemovesRecoveryWindowWhenEverySessionFails() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let candidates: [(sessionId: String, workingDirectory: String?)] = [ + (sessionId: "first-failure", workingDirectory: "/tmp/first-failure"), + (sessionId: "second-failure", workingDirectory: "/tmp/second-failure"), + ] + var attemptedSessionIds: [String] = [] + var attemptedWorkspaceIds: [UUID] = [] + let result = try XCTUnwrap(appDelegate.reconcileOrphanedEscrowedSessions( + candidates: candidates, + attemptRecovery: { candidate, workspace in + attemptedSessionIds.append(candidate.sessionId) + attemptedWorkspaceIds.append(workspace.id) + return false + } + )) + + XCTAssertEqual(result.recoveredCount, 0) + XCTAssertEqual( + attemptedSessionIds, + candidates.map(\.sessionId), + "Even an all-fail pass must attempt each distinct orphan exactly once" + ) + XCTAssertEqual( + Set(attemptedWorkspaceIds).count, + candidates.count, + "Every failed orphan must get a distinct candidate workspace before that workspace is removed" + ) + waitUntil(description: "all-fail recovery window context to be removed") { + appDelegate.tabManagerFor(windowId: result.windowId) == nil + } + XCTAssertNil( + appDelegate.tabManagerFor(windowId: result.windowId), + "An all-fail reconciliation pass must close the recovery window and unregister its context" + ) + XCTAssertFalse( + window(withId: result.windowId)?.isVisible == true, + "An all-fail reconciliation pass must not leave an empty recovery window visible" + ) + } + func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -1468,6 +1587,328 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId), "Confirmed close should unregister the window's context") } + func testTabManagerWindowCloseTeardownStopsEveryLifecycleResourceIdempotently() throws { + let manager = TabManager() + defer { manager.teardownForWindowClose() } + + let firstWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let firstPanelId = try XCTUnwrap(firstWorkspace.panels.keys.first) + let probeKey = TabManager.WorkspaceGitProbeKey( + workspaceId: firstWorkspace.id, + panelId: firstPanelId + ) + manager.scheduleWorkspaceGitMetadataRefresh( + workspaceId: firstWorkspace.id, + panelId: firstPanelId, + directory: "/tmp", + delays: [60], + reason: "window-close-lifecycle-test" + ) + manager.workspaceGitTrackedDirectoryByKey[probeKey] = "/tmp" + + _ = manager.addTab(select: false) + manager.selectNextTab() + NotificationCenter.default.post( + name: .ghosttyDidSetTitle, + object: nil, + userInfo: [ + GhosttyNotificationKey.tabId: firstWorkspace.id, + GhosttyNotificationKey.surfaceId: firstPanelId, + GhosttyNotificationKey.title: "Pending lifecycle title" + ] + ) +#if DEBUG + manager.uiTestCancellables.insert(AnyCancellable {}) +#endif + + let primed = manager.lifecycleResourceSnapshot + XCTAssertFalse(primed.isStopped, "A live manager must not report a stopped lifecycle") + XCTAssertGreaterThan(primed.observerCount, 0, "The test must exercise installed global observers") + XCTAssertTrue(primed.hasAgentPIDSweepTimer, "The test must exercise the repeating PID sweep") + XCTAssertTrue(primed.hasWorkspaceGitMetadataPollTimer, "The test must exercise the workspace metadata poll") + XCTAssertTrue( + primed.hasSelectedWorkspaceGitMetadataPollTimer, + "The test must exercise the selected-workspace metadata poll" + ) + XCTAssertGreaterThan(primed.workspaceGitProbeTimerCount, 0, "The test must schedule a cancellable git probe") + XCTAssertGreaterThan(primed.workspaceGitProbeGenerationCount, 0, "The test must retain a live probe generation") + XCTAssertGreaterThan(primed.workspaceGitTrackedDirectoryCount, 0, "The test must retain tracked probe state") + XCTAssertTrue(primed.hasWorkspaceCycleCooldownTask, "The test must schedule workspace-cycle cooldown work") + XCTAssertTrue(primed.hasPendingPanelTitleCoalescerWork, "The test must queue coalesced title work") +#if DEBUG + XCTAssertGreaterThan(primed.uiTestCancellableCount, 0, "The test must retain a DEBUG cancellable") +#endif + + manager.teardownForWindowClose() + assertLifecycleResourcesStopped(manager, reason: "The first teardown must stop every window-owned resource") + + manager.teardownForWindowClose() + assertLifecycleResourcesStopped(manager, reason: "Repeated teardown must remain a safe no-op") + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.35)) + assertLifecycleResourcesStopped( + manager, + reason: "Queued title, selection, and cooldown work must not revive a stopped manager" + ) + } + + func testPrimaryManagerStoreReplacesStoppedManagerAfterRealWindowClose() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = UUID() + let initialManager = TabManager() + let initialWorkspace = try XCTUnwrap(initialManager.selectedWorkspace) + let store = PrimaryTabManagerStore(initialManager: initialManager) + let initialWindow = makeUnregisteredMainWindow(windowId: windowId) + var replacementWindow: NSWindow? + defer { + if appDelegate.tabManagerFor(windowId: windowId) != nil { + if let replacementWindow { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: replacementWindow) + } else { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: initialWindow) + } + } + initialWindow.close() + replacementWindow?.close() + initialManager.teardownForWindowClose() + store.manager.teardownForWindowClose() + } + + appDelegate.registerMainWindow( + initialWindow, + windowId: windowId, + tabManager: store.manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: initialWindow) + + XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId), "Closing the primary window must remove its context") + assertLifecycleResourcesStopped(initialManager, reason: "The closed primary manager must remain stopped") + XCTAssertTrue(initialWorkspace.panels.isEmpty, "The closed primary workspace must remain torn down") + + let replacementManager = store.manager + XCTAssertFalse(replacementManager === initialManager, "The primary owner must replace, not reactivate, a stopped manager") + assertLifecycleResourcesRunning( + replacementManager, + reason: "The replacement primary manager must own a fresh lifecycle" + ) + let freshWorkspace = try XCTUnwrap(replacementManager.selectedWorkspace) + XCTAssertFalse( + freshWorkspace === initialWorkspace, + "The replacement manager must not reuse the torn-down primary workspace" + ) + XCTAssertFalse(freshWorkspace.panels.isEmpty, "The replacement workspace must contain a viable panel") + let freshPanelId = try XCTUnwrap(freshWorkspace.panels.keys.first) + XCTAssertFalse(replacementManager.lifecycleResourceSnapshot.hasPendingPanelTitleCoalescerWork) + + let nextWindow = makeUnregisteredMainWindow(windowId: windowId) + replacementWindow = nextWindow + appDelegate.registerMainWindow( + nextWindow, + windowId: windowId, + tabManager: replacementManager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + NotificationCenter.default.post( + name: .ghosttyDidSetTitle, + object: nil, + userInfo: [ + GhosttyNotificationKey.tabId: freshWorkspace.id, + GhosttyNotificationKey.surfaceId: freshPanelId, + GhosttyNotificationKey.title: "Replacement lifecycle title" + ] + ) + + XCTAssertTrue( + replacementManager.lifecycleResourceSnapshot.hasPendingPanelTitleCoalescerWork, + "A title notification must reach the fresh primary manager's observers" + ) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: nextWindow) + + XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId), "Closing the replacement window must remove its context") + assertLifecycleResourcesStopped(replacementManager, reason: "The replacement manager must stop on close") + let nextPrimaryManager = store.manager + XCTAssertFalse(nextPrimaryManager === initialManager) + XCTAssertFalse(nextPrimaryManager === replacementManager, "Each primary close must advance to another fresh manager") + assertLifecycleResourcesRunning(nextPrimaryManager, reason: "The next primary manager must start live") + XCTAssertFalse(try XCTUnwrap(nextPrimaryManager.selectedWorkspace).panels.isEmpty) + } + + func testRebindingMainWindowReplacesAndRemovesCloseObserverWithoutStoppingLiveContext() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = UUID() + let manager = TabManager() + let sidebarState = SidebarState() + let sidebarSelectionState = SidebarSelectionState() + let firstWindow = makeUnregisteredMainWindow(windowId: windowId) + let replacementWindow = makeUnregisteredMainWindow(windowId: windowId) + var finalWindow: NSWindow? + var finalManager: TabManager? + defer { + if let finalWindow { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: finalWindow) + } else if appDelegate.tabManagerFor(windowId: windowId) != nil { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: replacementWindow) + } + firstWindow.close() + replacementWindow.close() + finalWindow?.close() + manager.teardownForWindowClose() + finalManager?.teardownForWindowClose() + } + + appDelegate.registerMainWindow( + firstWindow, + windowId: windowId, + tabManager: manager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + appDelegate.registerMainWindow( + replacementWindow, + windowId: windowId, + tabManager: manager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + + XCTAssertTrue(appDelegate.mainWindow(for: windowId) === replacementWindow) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: firstWindow) + + XCTAssertTrue( + appDelegate.mainWindow(for: windowId) === replacementWindow, + "Closing a retired NSWindow must not unregister its replacement's context" + ) + XCTAssertTrue( + appDelegate.tabManagerFor(windowId: windowId) === manager, + "Closing a retired NSWindow must not stop the manager still owned by its replacement" + ) + XCTAssertFalse(manager.lifecycleResourceSnapshot.isStopped) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: replacementWindow) + + XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId), "Closing the current window must unregister its context") + assertLifecycleResourcesStopped(manager, reason: "Closing the current window must stop its manager") + + let nextManager = TabManager() + let nextWindow = makeUnregisteredMainWindow(windowId: windowId) + finalManager = nextManager + finalWindow = nextWindow + appDelegate.registerMainWindow( + nextWindow, + windowId: windowId, + tabManager: nextManager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: replacementWindow) + + XCTAssertTrue( + appDelegate.mainWindow(for: windowId) === nextWindow, + "A close observer must be removed after teardown so it cannot unregister a later context with the same ID" + ) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === nextManager) + XCTAssertFalse(nextManager.lifecycleResourceSnapshot.isStopped) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: nextWindow) + XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId)) + assertLifecycleResourcesStopped(nextManager, reason: "The final current window must still own a working close observer") + } + + func testReindexingIntoOccupiedWindowTearsDownDisplacedContextAndPreservesMovedContext() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = UUID() + let displacedWindowId = UUID() + let firstManager = TabManager() + let displacedManager = TabManager() + let firstWindow = makeUnregisteredMainWindow(windowId: firstWindowId) + let occupiedWindow = makeUnregisteredMainWindow(windowId: displacedWindowId) + defer { + if appDelegate.tabManagerFor(windowId: firstWindowId) != nil { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: firstWindow) + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: occupiedWindow) + } + if appDelegate.tabManagerFor(windowId: displacedWindowId) != nil { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: occupiedWindow) + } + firstWindow.close() + occupiedWindow.close() + firstManager.teardownForWindowClose() + displacedManager.teardownForWindowClose() + } + + appDelegate.registerMainWindow( + firstWindow, + windowId: firstWindowId, + tabManager: firstManager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + appDelegate.registerMainWindow( + occupiedWindow, + windowId: displacedWindowId, + tabManager: displacedManager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + appDelegate.registerMainWindow( + occupiedWindow, + windowId: firstWindowId, + tabManager: firstManager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + XCTAssertNil( + appDelegate.tabManagerFor(windowId: displacedWindowId), + "Reindexing into an occupied NSWindow must remove the displaced context" + ) + assertLifecycleResourcesStopped( + displacedManager, + reason: "The manager displaced from an occupied NSWindow must be torn down" + ) + XCTAssertTrue(appDelegate.tabManagerFor(windowId: firstWindowId) === firstManager) + XCTAssertTrue( + appDelegate.mainWindow(for: firstWindowId) === occupiedWindow, + "The moved context must own the destination NSWindow under its original ID" + ) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: firstWindow) + + XCTAssertTrue( + appDelegate.tabManagerFor(windowId: firstWindowId) === firstManager, + "Closing the moved context's stale NSWindow must not remove its destination context" + ) + XCTAssertTrue(appDelegate.mainWindow(for: firstWindowId) === occupiedWindow) + XCTAssertFalse(firstManager.lifecycleResourceSnapshot.isStopped) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: occupiedWindow) + + XCTAssertNil(appDelegate.tabManagerFor(windowId: firstWindowId)) + assertLifecycleResourcesStopped(firstManager, reason: "Closing the destination NSWindow must stop the moved manager") + } + func testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -4612,6 +5053,54 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) } + private func makeUnregisteredMainWindow(windowId: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)") + return window + } + + private func assertLifecycleResourcesStopped( + _ manager: TabManager, + reason: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let snapshot = manager.lifecycleResourceSnapshot + XCTAssertTrue(snapshot.isStopped, reason, file: file, line: line) + XCTAssertEqual(snapshot.observerCount, 0, reason, file: file, line: line) + XCTAssertFalse(snapshot.hasAgentPIDSweepTimer, reason, file: file, line: line) + XCTAssertFalse(snapshot.hasWorkspaceGitMetadataPollTimer, reason, file: file, line: line) + XCTAssertFalse(snapshot.hasSelectedWorkspaceGitMetadataPollTimer, reason, file: file, line: line) + XCTAssertEqual(snapshot.workspaceGitProbeTimerCount, 0, reason, file: file, line: line) + XCTAssertEqual(snapshot.workspaceGitProbeGenerationCount, 0, reason, file: file, line: line) + XCTAssertEqual(snapshot.workspaceGitTrackedDirectoryCount, 0, reason, file: file, line: line) + XCTAssertFalse(snapshot.hasWorkspaceCycleCooldownTask, reason, file: file, line: line) + XCTAssertFalse(snapshot.hasPendingPanelTitleCoalescerWork, reason, file: file, line: line) +#if DEBUG + XCTAssertEqual(snapshot.uiTestCancellableCount, 0, reason, file: file, line: line) +#endif + } + + private func assertLifecycleResourcesRunning( + _ manager: TabManager, + reason: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let snapshot = manager.lifecycleResourceSnapshot + XCTAssertFalse(snapshot.isStopped, reason, file: file, line: line) + XCTAssertEqual(snapshot.observerCount, 2, reason, file: file, line: line) + XCTAssertTrue(snapshot.hasAgentPIDSweepTimer, reason, file: file, line: line) + XCTAssertTrue(snapshot.hasWorkspaceGitMetadataPollTimer, reason, file: file, line: line) + XCTAssertTrue(snapshot.hasSelectedWorkspaceGitMetadataPollTimer, reason, file: file, line: line) + } + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { var stack: [NSView] = [hostedView] while let current = stack.popLast() { diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index cfeefcd8..58a02906 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -7,6 +7,7 @@ import WebKit import ObjectiveC.runtime import Bonsplit import UserNotifications +import os #if canImport(Programa_DEV) @testable import Programa_DEV @@ -3203,7 +3204,158 @@ final class BrowserSearchSettingsTests: XCTestCase { } +private enum BrowserHistoryPersistenceHarnessError: Error { + case injectedLoadFailure + case injectedPersistFailure + case injectedRemoveFailure + case blockedPersistTimedOut +} + +private final class BrowserHistoryPersistenceHarness: Sendable { + private struct State: Sendable { + var loadCallCount = 0 + var remainingLoadFailures: Int + var persistCallCount = 0 + var completedPersistCallCount = 0 + var remainingPersistFailures: Int + var remainingRemoveFailures: Int + } + + private let state: OSAllocatedUnfairLock + private let blockFirstPersist: Bool + private let firstPersistStarted = DispatchSemaphore(value: 0) + private let releaseFirstPersist = DispatchSemaphore(value: 0) + private let persistCompleted = DispatchSemaphore(value: 0) + + init( + blockFirstPersist: Bool = false, + loadFailures: Int = 0, + persistFailures: Int = 0, + removeFailures: Int = 0 + ) { + self.blockFirstPersist = blockFirstPersist + state = OSAllocatedUnfairLock(initialState: State( + remainingLoadFailures: loadFailures, + remainingPersistFailures: persistFailures, + remainingRemoveFailures: removeFailures + )) + } + + var persistence: BrowserHistoryStore.Persistence { + BrowserHistoryStore.Persistence( + load: { [self] fileURL in + let shouldFail = state.withLock { state in + state.loadCallCount += 1 + guard state.remainingLoadFailures > 0 else { return false } + state.remainingLoadFailures -= 1 + return true + } + if shouldFail { + throw BrowserHistoryPersistenceHarnessError.injectedLoadFailure + } + return try Data(contentsOf: fileURL) + }, + persist: { [self] snapshot, fileURL in + try persist(snapshot, to: fileURL) + }, + remove: { [self] fileURL in + let shouldFail = state.withLock { state in + guard state.remainingRemoveFailures > 0 else { return false } + state.remainingRemoveFailures -= 1 + return true + } + if shouldFail { + throw BrowserHistoryPersistenceHarnessError.injectedRemoveFailure + } + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + try FileManager.default.removeItem(at: fileURL) + } + ) + } + + var persistCallCount: Int { + state.withLock { $0.persistCallCount } + } + + var loadCallCount: Int { + state.withLock { $0.loadCallCount } + } + + func waitForFirstPersistToStart(timeout: TimeInterval = 2) -> DispatchTimeoutResult { + firstPersistStarted.wait(timeout: .now() + timeout) + } + + func releaseBlockedPersist() { + releaseFirstPersist.signal() + } + + func waitForPersistCompletions(_ expectedCount: Int, timeout: TimeInterval = 2) -> Bool { + let deadline = DispatchTime.now() + timeout + while state.withLock({ $0.completedPersistCallCount }) < expectedCount { + guard persistCompleted.wait(timeout: deadline) == .success else { return false } + } + return true + } + + private func persist(_ snapshot: [BrowserHistoryStore.Entry], to fileURL: URL) throws { + let outcome = state.withLock { state -> (call: Int, shouldFail: Bool) in + state.persistCallCount += 1 + let shouldFail = state.remainingPersistFailures > 0 + if shouldFail { + state.remainingPersistFailures -= 1 + } + return (state.persistCallCount, shouldFail) + } + defer { + state.withLock { $0.completedPersistCallCount += 1 } + persistCompleted.signal() + } + + if blockFirstPersist, outcome.call == 1 { + firstPersistStarted.signal() + guard releaseFirstPersist.wait(timeout: .now() + 5) == .success else { + throw BrowserHistoryPersistenceHarnessError.blockedPersistTimedOut + } + } + + if outcome.shouldFail { + throw BrowserHistoryPersistenceHarnessError.injectedPersistFailure + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let data = try encoder.encode(snapshot) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: [.atomic]) + } +} + final class BrowserHistoryStoreTests: XCTestCase { + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func decodeEntries(at fileURL: URL) throws -> [BrowserHistoryStore.Entry] { + try JSONDecoder().decode([BrowserHistoryStore.Entry].self, from: Data(contentsOf: fileURL)) + } + + private func waitForSignal( + _ semaphore: DispatchSemaphore, + timeout: TimeInterval + ) async -> DispatchTimeoutResult { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: semaphore.wait(timeout: .now() + timeout)) + } + } + } + func testRecordVisitDedupesAndSuggests() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) @@ -3230,6 +3382,87 @@ final class BrowserHistoryStoreTests: XCTestCase { XCTAssertEqual(suggestions.first?.title, "Example Foo Updated") } + func testRecordVisitKeepsCaseSensitiveQueryValuesAsDistinctHistoryEntries() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) } + let uppercaseTokenURL = try XCTUnwrap(URL(string: "https://example.com/callback?token=AbC")) + let lowercaseTokenURL = try XCTUnwrap(URL(string: "https://example.com/callback?token=abc")) + + let initialEntries = await MainActor.run { () -> [BrowserHistoryStore.Entry] in + store.recordVisit(url: uppercaseTokenURL, title: "Uppercase Token") + store.recordVisit(url: lowercaseTokenURL, title: "Lowercase Token") + store.flushPendingSaves() + return store.entries + } + + XCTAssertEqual( + initialEntries.count, + 2, + "Case-sensitive query values can identify different resources and must not be merged" + ) + XCTAssertEqual( + initialEntries.first(where: { $0.url == uppercaseTokenURL.absoluteString })?.visitCount, + 1 + ) + XCTAssertEqual( + initialEntries.first(where: { $0.url == lowercaseTokenURL.absoluteString })?.visitCount, + 1 + ) + + let repeatedEntries = await MainActor.run { () -> [BrowserHistoryStore.Entry] in + store.recordVisit(url: uppercaseTokenURL, title: "Uppercase Token Revisited") + store.flushPendingSaves() + return store.entries + } + + XCTAssertEqual(repeatedEntries.count, 2) + XCTAssertEqual( + repeatedEntries.first(where: { $0.url == uppercaseTokenURL.absoluteString })?.visitCount, + 2, + "Revisiting a URL must increment only its exact query-value identity" + ) + XCTAssertEqual( + repeatedEntries.first(where: { $0.url == lowercaseTokenURL.absoluteString })?.visitCount, + 1, + "A differently cased query value must retain its independent visit count" + ) + } + + func testRecordVisitDedupesEquivalentPercentEscapeHexCase() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) } + let lowercaseEscapeURL = try XCTUnwrap( + URL(string: "https://example.com/callback?next=%2faccount") + ) + let uppercaseEscapeURL = try XCTUnwrap( + URL(string: "https://example.com/callback?next=%2Faccount") + ) + + let entries = await MainActor.run { () -> [BrowserHistoryStore.Entry] in + store.recordVisit(url: lowercaseEscapeURL, title: "Lowercase Escape") + store.recordVisit(url: uppercaseEscapeURL, title: "Uppercase Escape") + store.flushPendingSaves() + return store.entries + } + + XCTAssertEqual( + entries.count, + 1, + "Hexadecimal letter case in a percent escape must not create duplicate history entries" + ) + XCTAssertEqual( + entries.first?.visitCount, + 2, + "Equivalent percent-escape spellings must contribute to the same visit history" + ) + } + func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) @@ -3269,6 +3502,308 @@ final class BrowserHistoryStoreTests: XCTestCase { XCTAssertEqual(suggestions.first?.url, "https://go.dev/") XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" })) } + + func testNewerVisitCannotBeOverwrittenByAnOlderSaveThatFinishesLate() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let harness = BrowserHistoryPersistenceHarness(blockFirstPersist: true) + let store = await MainActor.run { + BrowserHistoryStore(fileURL: fileURL, persistence: harness.persistence) + } + let olderURL = try XCTUnwrap(URL(string: "https://example.com/older")) + let newerURL = try XCTUnwrap(URL(string: "https://example.com/newer")) + + await MainActor.run { + store.recordVisit(url: olderURL, title: "Older") + } + XCTAssertEqual( + harness.waitForFirstPersistToStart(), + .success, + "The test must control an active old save before creating the newer snapshot" + ) + + await MainActor.run { + store.recordVisit(url: newerURL, title: "Newer") + } + _ = harness.waitForPersistCompletions(1, timeout: 0.5) + harness.releaseBlockedPersist() + XCTAssertTrue( + harness.waitForPersistCompletions(2), + "The newer debounced snapshot must persist after the old writer is released" + ) + + let persistedURLs = Set(try decodeEntries(at: fileURL).map(\.url)) + XCTAssertEqual( + persistedURLs, + Set([olderURL.absoluteString, newerURL.absoluteString]), + "A late old save must not leave history at a snapshot that omits a newer visit" + ) + } + + func testClearHistoryWaitsForAnActiveSaveSoDeletedHistoryCannotReappear() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let harness = BrowserHistoryPersistenceHarness(blockFirstPersist: true) + let store = await MainActor.run { + BrowserHistoryStore(fileURL: fileURL, persistence: harness.persistence) + } + let visitedURL = try XCTUnwrap(URL(string: "https://example.com/private-history")) + + await MainActor.run { + store.recordVisit(url: visitedURL, title: "Private History") + } + XCTAssertEqual( + harness.waitForFirstPersistToStart(), + .success, + "The deletion test must begin while the old history save is active" + ) + + let clearStarted = DispatchSemaphore(value: 0) + let clearCompleted = DispatchSemaphore(value: 0) + let clearTask = Task { @MainActor in + clearStarted.signal() + store.clearHistory() + clearCompleted.signal() + } + let clearStartedResult = await waitForSignal(clearStarted, timeout: 2) + XCTAssertEqual(clearStartedResult, .success) + let clearCompletedBeforeRelease = await waitForSignal(clearCompleted, timeout: 0.5) == .success + XCTAssertFalse( + clearCompletedBeforeRelease, + "clearHistory must not return while an older history write can still recreate the file" + ) + + harness.releaseBlockedPersist() + XCTAssertTrue(harness.waitForPersistCompletions(1)) + if !clearCompletedBeforeRelease { + let clearCompletedResult = await waitForSignal(clearCompleted, timeout: 2) + XCTAssertEqual(clearCompletedResult, .success) + } + await clearTask.value + XCTAssertFalse( + FileManager.default.fileExists(atPath: fileURL.path), + "History cleared during an active save must remain deleted after clearHistory returns" + ) + } + + func testFailedClearCannotBeUndoneByTerminationFlush() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let seededEntry = BrowserHistoryStore.Entry( + id: UUID(), + url: "https://example.com/history-that-must-stay-cleared", + title: "History That Must Stay Cleared", + lastVisited: Date(), + visitCount: 1 + ) + try JSONEncoder().encode([seededEntry]).write(to: fileURL, options: [.atomic]) + + let harness = BrowserHistoryPersistenceHarness(removeFailures: 1) + let store = await MainActor.run { + BrowserHistoryStore(fileURL: fileURL, persistence: harness.persistence) + } + + let loadedEntries = await MainActor.run { () -> [BrowserHistoryStore.Entry] in + XCTAssertTrue(store.loadIfNeeded()) + return store.entries + } + XCTAssertEqual(loadedEntries, [seededEntry]) + + await MainActor.run { + store.clearHistory() + XCTAssertTrue(store.entries.isEmpty) + store.flushPendingSaves() + } + + let finalEntries = await MainActor.run { store.entries } + XCTAssertTrue( + finalEntries.isEmpty, + "A termination flush must not reload history that the user already cleared" + ) + let persistedEntries = FileManager.default.fileExists(atPath: fileURL.path) + ? try decodeEntries(at: fileURL) + : [] + XCTAssertTrue( + persistedEntries.isEmpty, + "A transient deletion failure must not let termination persist the cleared URL or title again" + ) + } + + func testFlushWaitsForAnActiveOldSaveAndPersistsTheNewestSnapshot() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let harness = BrowserHistoryPersistenceHarness(blockFirstPersist: true) + let store = await MainActor.run { + BrowserHistoryStore(fileURL: fileURL, persistence: harness.persistence) + } + let olderURL = try XCTUnwrap(URL(string: "https://example.com/before-flush")) + let newerURL = try XCTUnwrap(URL(string: "https://example.com/at-flush")) + + await MainActor.run { + store.recordVisit(url: olderURL, title: "Before Flush") + } + XCTAssertEqual( + harness.waitForFirstPersistToStart(), + .success, + "The flush test must hold the old save before mutating the in-memory history" + ) + await MainActor.run { + store.recordVisit(url: newerURL, title: "At Flush") + } + + let flushStarted = DispatchSemaphore(value: 0) + let flushCompleted = DispatchSemaphore(value: 0) + let flushTask = Task { @MainActor in + flushStarted.signal() + store.flushPendingSaves() + flushCompleted.signal() + } + let flushStartedResult = await waitForSignal(flushStarted, timeout: 2) + XCTAssertEqual(flushStartedResult, .success) + let flushCompletedBeforeRelease = await waitForSignal(flushCompleted, timeout: 0.5) == .success + XCTAssertFalse( + flushCompletedBeforeRelease, + "A flush must not return while an older save can still overwrite its snapshot" + ) + + harness.releaseBlockedPersist() + XCTAssertTrue(harness.waitForPersistCompletions(2)) + if !flushCompletedBeforeRelease { + let flushCompletedResult = await waitForSignal(flushCompleted, timeout: 2) + XCTAssertEqual(flushCompletedResult, .success) + } + await flushTask.value + let persistedURLs = Set(try decodeEntries(at: fileURL).map(\.url)) + XCTAssertEqual( + persistedURLs, + Set([olderURL.absoluteString, newerURL.absoluteString]), + "A flush must be a barrier that leaves the newest history snapshot on disk" + ) + } + + func testTransientLoadFailurePreservesHistoryAndRetriesBeforeTheNextMutation() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let seededEntry = BrowserHistoryStore.Entry( + id: UUID(), + url: "https://example.com/preserved", + title: "Preserved", + lastVisited: Date(), + visitCount: 4 + ) + let originalData = try JSONEncoder().encode([seededEntry]) + try originalData.write(to: fileURL, options: [.atomic]) + + let newURL = try XCTUnwrap(URL(string: "https://example.com/after-retry")) + let harness = BrowserHistoryPersistenceHarness(loadFailures: 1) + let store = await MainActor.run { + BrowserHistoryStore(fileURL: fileURL, persistence: harness.persistence) + } + await MainActor.run { + store.recordVisit(url: newURL, title: "After Retry") + store.flushPendingSaves() + } + + XCTAssertEqual( + try Data(contentsOf: fileURL), + originalData, + "A read failure must preserve the last known history bytes instead of treating the file as empty" + ) + XCTAssertEqual(try decodeEntries(at: fileURL).map(\.url), [seededEntry.url]) + XCTAssertEqual( + harness.persistCallCount, + 0, + "Flushing an unchanged store after a read failure must not write an empty history array" + ) + + await MainActor.run { + store.recordVisit(url: newURL, title: "After Retry") + store.flushPendingSaves() + } + + XCTAssertEqual(harness.persistCallCount, 1) + XCTAssertEqual( + Set(try decodeEntries(at: fileURL).map(\.url)), + Set([seededEntry.url, newURL.absoluteString]), + "A transient read failure must retry from the existing file before accepting a later mutation" + ) + } + + func testReadOnlySuggestionsDoNotRetryAFailedHistoryLoad() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let seededEntry = BrowserHistoryStore.Entry( + id: UUID(), + url: "https://example.com/seeded-history", + title: "Seeded History", + lastVisited: Date(), + visitCount: 1 + ) + try JSONEncoder().encode([seededEntry]).write(to: fileURL, options: [.atomic]) + + let harness = BrowserHistoryPersistenceHarness(loadFailures: 3) + let store = await MainActor.run { + BrowserHistoryStore(fileURL: fileURL, persistence: harness.persistence) + } + + let results = await MainActor.run { + [ + store.suggestions(for: "seeded"), + store.recentSuggestions(), + store.suggestions(for: "history"), + ] + } + + XCTAssertTrue(results.allSatisfy(\.isEmpty)) + XCTAssertEqual( + harness.loadCallCount, + 1, + "Read-only omnibar queries must not repeat a failed synchronous history load on every keystroke" + ) + } + + func testFailedSaveRemainsDirtyAndRetriesWithTheLatestHistory() async throws { + let tempDir = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let harness = BrowserHistoryPersistenceHarness(persistFailures: 1) + let store = await MainActor.run { + BrowserHistoryStore(fileURL: fileURL, persistence: harness.persistence) + } + let firstURL = try XCTUnwrap(URL(string: "https://example.com/failed-save")) + let latestURL = try XCTUnwrap(URL(string: "https://example.com/retry")) + + await MainActor.run { + store.recordVisit(url: firstURL, title: "Failed Save") + store.flushPendingSaves() + } + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + + await MainActor.run { + store.recordVisit(url: latestURL, title: "Retry") + store.flushPendingSaves() + } + + XCTAssertEqual(harness.persistCallCount, 2) + XCTAssertEqual( + Set(try decodeEntries(at: fileURL).map(\.url)), + Set([firstURL.absoluteString, latestURL.absoluteString]), + "A failed write must remain retryable, and the retry must persist the newest complete history" + ) + } } @@ -3608,3 +4143,112 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { ) } } + + +private final class FailingBrowserDownloadFileManager: FileManager, @unchecked Sendable { + let moveError = NSError( + domain: "BrowserDownloadFinalizationTests.Move", + code: 41 + ) + + override func moveItem(at srcURL: URL, to dstURL: URL) throws { + throw moveError + } +} + +final class BrowserDownloadFinalizationTests: XCTestCase { + func testReadyCallbackObservesTheDownloadAtItsFinalDestination() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("BrowserDownloadFinalizationTests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let sourceURL = root.appendingPathComponent("temporary-download", isDirectory: false) + let destinationURL = root.appendingPathComponent("saved-download", isDirectory: false) + try Data("download contents".utf8).write(to: sourceURL) + var readyCount = 0 + var failures: [Error] = [] + + BrowserDownloadDelegate.finalizeDownload( + from: sourceURL, + to: destinationURL, + fileManager: fileManager, + onReady: { + readyCount += 1 + XCTAssertTrue( + fileManager.fileExists(atPath: destinationURL.path), + "Download readiness must mean the file is already available at its final destination" + ) + XCTAssertFalse( + fileManager.fileExists(atPath: sourceURL.path), + "Download readiness must not expose the temporary file as an alternate copy" + ) + }, + onFailure: { failures.append($0) } + ) + + XCTAssertEqual(readyCount, 1) + XCTAssertTrue(failures.isEmpty) + } + + func testMoveFailureReportsFailureAndRetainsTheTemporaryDownload() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("BrowserDownloadFinalizationTests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let sourceURL = root.appendingPathComponent("temporary-download", isDirectory: false) + let destinationURL = root + .appendingPathComponent("missing-parent", isDirectory: true) + .appendingPathComponent("saved-download", isDirectory: false) + let sourceContents = Data("download contents".utf8) + try sourceContents.write(to: sourceURL) + var readyCount = 0 + var failures: [Error] = [] + + BrowserDownloadDelegate.finalizeDownload( + from: sourceURL, + to: destinationURL, + fileManager: fileManager, + onReady: { readyCount += 1 }, + onFailure: { failures.append($0) } + ) + + XCTAssertEqual(readyCount, 0, "A download that never reached its destination must not be announced as ready") + XCTAssertEqual(failures.count, 1, "A failed move must surface exactly one download failure") + XCTAssertTrue( + fileManager.fileExists(atPath: sourceURL.path), + "A failed move must retain the completed temporary download for recovery" + ) + XCTAssertEqual(try Data(contentsOf: sourceURL), sourceContents) + let failure = try XCTUnwrap(failures.first as? BrowserDownloadFinalizationError) + XCTAssertEqual(failure.retainedTempURL, sourceURL) + } + + func testMoveFailurePreservesTheOriginalErrorAndRetainedTemporaryURL() throws { + let fileManager = FailingBrowserDownloadFileManager() + let sourceURL = URL(fileURLWithPath: "/unused/temporary-download", isDirectory: false) + let destinationURL = URL(fileURLWithPath: "/unused/saved-download", isDirectory: false) + var readyCount = 0 + var failures: [Error] = [] + + BrowserDownloadDelegate.finalizeDownload( + from: sourceURL, + to: destinationURL, + fileManager: fileManager, + onReady: { readyCount += 1 }, + onFailure: { failures.append($0) } + ) + + XCTAssertEqual(readyCount, 0, "A failed move must never announce a download as ready") + XCTAssertEqual(failures.count, 1, "A failed move must be reported as one finalization failure") + let failure = try XCTUnwrap(failures.first as? BrowserDownloadFinalizationError) + XCTAssertTrue( + (failure.moveError as NSError) === fileManager.moveError, + "The finalization error must preserve the exact move failure for diagnostics" + ) + XCTAssertEqual(failure.retainedTempURL, sourceURL) + } +} diff --git a/programaTests/BrowserImportMappingTests.swift b/programaTests/BrowserImportMappingTests.swift index a0dde173..5466a9f4 100644 --- a/programaTests/BrowserImportMappingTests.swift +++ b/programaTests/BrowserImportMappingTests.swift @@ -271,6 +271,81 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(lines.contains("Created Programa profiles: You, austin")) } + func testDomainFiltersCanonicalizeAndDeduplicateEquivalentIDNForms() { + let filters = BrowserDataImporter.parseDomainFilters( + " bücher.de, xn--bcher-kva.de; *.BÜCHER.DE " + ) + + XCTAssertEqual( + filters, + ["bücher.de"], + "Unicode and Punycode spellings of one domain must produce one canonical import filter" + ) + } + + func testDomainMatchingTreatsUnicodeAndPunycodeHostsAsEquivalent() { + XCTAssertTrue( + BrowserDataImporter.domainMatches( + host: "xn--bcher-kva.de", + filters: ["bücher.de"] + ) + ) + XCTAssertTrue( + BrowserDataImporter.domainMatches( + host: "bücher.de", + filters: ["xn--bcher-kva.de"] + ) + ) + XCTAssertTrue( + BrowserDataImporter.domainMatches( + host: "shop.xn--bcher-kva.de", + filters: ["bücher.de"] + ), + "An IDN filter must include true subdomains after canonicalization" + ) + } + + func testDomainMatchingAllowsAnyHostWhenFiltersAreEmpty() { + XCTAssertTrue( + BrowserDataImporter.domainMatches( + host: "anything.example", + filters: [] + ), + "An empty domain filter list must preserve the import-all contract" + ) + } + + func testDomainMatchingRejectsLookalikesAndUnrelatedIDNs() { + XCTAssertFalse( + BrowserDataImporter.domainMatches( + host: "evilxn--bcher-kva.de", + filters: ["bücher.de"] + ), + "Suffix matching must require a dot boundary before the canonical IDN" + ) + XCTAssertFalse( + BrowserDataImporter.domainMatches( + host: "kücher.de", + filters: ["bücher.de"] + ), + "Canonicalization must not collapse distinct Unicode domains" + ) + XCTAssertFalse( + BrowserDataImporter.domainMatches( + host: "example.com", + filters: ["example.com/path"] + ), + "A malformed domain-only filter must not be parsed as a URL and broadened to its host" + ) + XCTAssertFalse( + BrowserDataImporter.domainMatches( + host: "example.com", + filters: ["example.com:443"] + ), + "A domain-only filter containing a port must preserve fail-closed comparison behavior" + ) + } + @MainActor func testImportWizardCanBeConstructedForSettingsChoosePath() { let destinationProfiles = [ diff --git a/programaTests/ClaudeQuotaSnapshotParserTests.swift b/programaTests/ClaudeQuotaSnapshotParserTests.swift index 02fa45fd..91982a0f 100644 --- a/programaTests/ClaudeQuotaSnapshotParserTests.swift +++ b/programaTests/ClaudeQuotaSnapshotParserTests.swift @@ -86,4 +86,362 @@ final class ClaudeQuotaSnapshotParserTests: XCTestCase { func testReturnsNilWhenResetTimestampIsNotNumeric() { XCTAssertNil(ClaudeQuotaSnapshotParser.parse(data: payload(fiveHourResets: "\"soon\""))) } + + func testClampsPercentagesFromTheExternalClaudeCache() throws { + let snapshot = try XCTUnwrap( + ClaudeQuotaSnapshotParser.parse( + data: payload(fiveHourPercent: "-12", sevenDayPercent: "145") + ) + ) + + XCTAssertEqual(snapshot.fiveHour.usedPercent, 0) + XCTAssertEqual(snapshot.sevenDay.usedPercent, 100) + } + + func testMapsTheExistingClaudeCacheIntoTheNormalizedProviderModel() { + let result = ClaudeUsageSnapshotParser.parse(data: payload()) + + guard case let .available(snapshot) = result else { + return XCTFail("A valid Claude cache must be available through the shared provider model, got \(result)") + } + XCTAssertEqual(snapshot.provider, .claude) + XCTAssertEqual(snapshot.windows.map(\.id), ["claude.five_hour", "claude.seven_day"]) + XCTAssertEqual(snapshot.windows.map(\.label), ["5h", "7d"]) + XCTAssertEqual(snapshot.windows.map(\.usedPercent), [17, 3]) + XCTAssertEqual( + snapshot.windows.map { $0.resetsAt.timeIntervalSince1970 }, + [1_785_171_600, 1_785_664_800] + ) + } + + func testNormalizedClaudeParserSurfacesMalformedCacheAsAProviderFailure() { + let result = ClaudeUsageSnapshotParser.parse(data: Data("{not json".utf8)) + + guard case let .failed(.claude, message) = result else { + return XCTFail("A malformed Claude cache must surface an explicit provider error, got \(result)") + } + XCTAssertFalse(message.isEmpty) + } +} + +/// Codex account state is intentionally read through the documented app-server RPCs. These +/// fixtures are complete JSON-RPC responses so the parser cannot accidentally become coupled to +/// auth files or to the private ChatGPT usage endpoint. +final class CodexUsageSnapshotParserTests: XCTestCase { + private func accountResponse(_ account: String) -> Data { + Data(""" + {"id":1,"result":{"account":\(account),"requiresOpenaiAuth":true}} + """.utf8) + } + + private var chatGPTAccountResponse: Data { + accountResponse(#"{"type":"chatgpt","email":"person@example.com","planType":"pro"}"#) + } + + private func rateLimitsResponse(_ result: String) -> Data { + Data(""" + {"id":2,"result":\(result)} + """.utf8) + } + + func testNullAccountIsExplicitlyUnavailable() { + let result = CodexUsageSnapshotParser.parse( + accountData: accountResponse("null"), + rateLimitsData: rateLimitsResponse(#"{"rateLimits":null,"rateLimitsByLimitId":{}}"#) + ) + + guard case .unavailable(.codex) = result else { + return XCTFail("A signed-out app-server account must be reported as unavailable, got \(result)") + } + } + + func testParsesPrimaryAndSecondaryWindowsAndClampsBackendPercentages() throws { + let result = CodexUsageSnapshotParser.parse( + accountData: chatGPTAccountResponse, + rateLimitsData: rateLimitsResponse( + #"{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":-4,"windowDurationMins":300,"resetsAt":1785171600},"secondary":{"usedPercent":124,"windowDurationMins":10080,"resetsAt":1785664800},"rateLimitReachedType":null},"rateLimitsByLimitId":{}}"# + ) + ) + + guard case let .available(snapshot) = result else { + return XCTFail("Expected an available Codex quota snapshot, got \(result)") + } + XCTAssertEqual(snapshot.provider, .codex) + XCTAssertEqual(snapshot.windows.map(\.id), ["codex.primary", "codex.secondary"]) + XCTAssertEqual(snapshot.windows.map(\.usedPercent), [0, 100]) + XCTAssertEqual( + snapshot.windows.map { $0.resetsAt.timeIntervalSince1970 }, + [1_785_171_600, 1_785_664_800] + ) + } + + func testIncludesNamedPerLimitBucketsWithoutDuplicatingTheAggregateBucket() throws { + let result = CodexUsageSnapshotParser.parse( + accountData: chatGPTAccountResponse, + rateLimitsData: rateLimitsResponse( + #"{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":10,"windowDurationMins":300,"resetsAt":1785171600},"secondary":null},"rateLimitsByLimitId":{"codex":{"limitId":"codex","limitName":null,"primary":{"usedPercent":10,"windowDurationMins":300,"resetsAt":1785171600},"secondary":null},"codex_bengalfox":{"limitId":"codex_bengalfox","limitName":"GPT-5.3-Codex-Spark","primary":{"usedPercent":35,"windowDurationMins":300,"resetsAt":1785175200},"secondary":null}}}"# + ) + ) + + guard case let .available(snapshot) = result else { + return XCTFail("Expected per-limit buckets to remain displayable, got \(result)") + } + XCTAssertEqual(snapshot.windows.map(\.id), ["codex.primary", "codex_bengalfox.primary"]) + XCTAssertEqual(snapshot.windows.map(\.label), ["5h", "GPT-5.3-Codex-Spark · 5h"]) + } + + func testAuthenticatedAccountWithNoQuotaWindowsIsExplicitlyUnavailable() { + let result = CodexUsageSnapshotParser.parse( + accountData: chatGPTAccountResponse, + rateLimitsData: rateLimitsResponse( + #"{"rateLimits":{"limitId":"codex","primary":null,"secondary":null},"rateLimitsByLimitId":{}}"# + ) + ) + + guard case .unavailable(.codex) = result else { + return XCTFail("An authenticated account without an authoritative quota must not render invented usage, got \(result)") + } + } + + func testMalformedOrPartialRPCResponsesBecomeFailuresInsteadOfCrashes() { + let malformedAccount = CodexUsageSnapshotParser.parse( + accountData: Data("{not json".utf8), + rateLimitsData: rateLimitsResponse(#"{"rateLimits":null}"#) + ) + let missingReset = CodexUsageSnapshotParser.parse( + accountData: chatGPTAccountResponse, + rateLimitsData: rateLimitsResponse( + #"{"rateLimits":{"limitId":"codex","primary":{"usedPercent":25,"windowDurationMins":300},"secondary":null}}"# + ) + ) + + for result in [malformedAccount, missingReset] { + guard case let .failed(.codex, message) = result else { + return XCTFail("Malformed or partial authoritative responses must surface a Codex error, got \(result)") + } + XCTAssertFalse(message.isEmpty) + } + } +} + +private actor CountingProviderUsageFetcher: ProviderUsageFetching { + nonisolated let provider: ProviderUsageProvider + private let result: ProviderUsageResult + private(set) var callCount = 0 + + init(provider: ProviderUsageProvider, result: ProviderUsageResult) { + self.provider = provider + self.result = result + } + + func fetch() async -> ProviderUsageResult { + callCount += 1 + return result + } +} + +private actor ControllableProviderUsageFetcher: ProviderUsageFetching { + nonisolated let provider: ProviderUsageProvider + private var nextCallID = 0 + private var continuations: [Int: CheckedContinuation] = [:] + private var callWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init(provider: ProviderUsageProvider) { + self.provider = provider + } + + func fetch() async -> ProviderUsageResult { + return await withCheckedContinuation { continuation in + let callID = nextCallID + nextCallID += 1 + continuations[callID] = continuation + resumeSatisfiedCallWaiters() + } + } + + func waitUntilCallCount(_ count: Int) async { + guard nextCallID < count else { return } + await withCheckedContinuation { continuation in + callWaiters.append((count, continuation)) + } + } + + func complete(callID: Int, with result: ProviderUsageResult) { + continuations.removeValue(forKey: callID)?.resume(returning: result) + } + + private func resumeSatisfiedCallWaiters() { + var pending: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in callWaiters { + if nextCallID >= waiter.count { + waiter.continuation.resume() + } else { + pending.append(waiter) + } + } + callWaiters = pending + } +} + +private actor CancellationObservingProviderUsageFetcher: ProviderUsageFetching { + nonisolated let provider: ProviderUsageProvider + private var hasStarted = false + private var observedCancellation = false + private var startWaiters: [CheckedContinuation] = [] + private var cancellationWaiters: [CheckedContinuation] = [] + + init(provider: ProviderUsageProvider) { + self.provider = provider + } + + func fetch() async -> ProviderUsageResult { + hasStarted = true + startWaiters.forEach { $0.resume() } + startWaiters.removeAll() + + do { + try await Task.sleep(for: .seconds(60)) + return .failed(provider, "Fetcher unexpectedly completed without cancellation") + } catch { + observedCancellation = Task.isCancelled + cancellationWaiters.forEach { $0.resume() } + cancellationWaiters.removeAll() + return .unavailable(provider) + } + } + + func waitUntilStarted() async { + guard !hasStarted else { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func waitUntilCancellationIsObserved() async { + guard !observedCancellation else { return } + await withCheckedContinuation { continuation in + cancellationWaiters.append(continuation) + } + } +} + +@MainActor +final class ProviderUsageStoreTests: XCTestCase { + func testConstructionDoesNoProviderWorkAndEachExplicitRefreshFetchesOnce() async { + let claude = CountingProviderUsageFetcher( + provider: .claude, + result: .unavailable(.claude) + ) + let codex = CountingProviderUsageFetcher( + provider: .codex, + result: .unavailable(.codex) + ) + let store = ProviderUsageStore(fetchers: [claude, codex]) + + let initialClaudeCallCount = await claude.callCount + let initialCodexCallCount = await codex.callCount + XCTAssertEqual(initialClaudeCallCount, 0) + XCTAssertEqual(initialCodexCallCount, 0) + XCTAssertTrue(store.results.isEmpty) + + await store.refresh() + + let firstClaudeCallCount = await claude.callCount + let firstCodexCallCount = await codex.callCount + XCTAssertEqual(firstClaudeCallCount, 1) + XCTAssertEqual(firstCodexCallCount, 1) + + await store.refresh() + + let secondClaudeCallCount = await claude.callCount + let secondCodexCallCount = await codex.callCount + XCTAssertEqual(secondClaudeCallCount, 2) + XCTAssertEqual(secondCodexCallCount, 2) + } + + func testRefreshPublishesStableProviderOrderAndDeduplicatesByProvider() async { + let codex = CountingProviderUsageFetcher( + provider: .codex, + result: .failed(.codex, "first Codex result") + ) + let firstClaude = CountingProviderUsageFetcher( + provider: .claude, + result: .failed(.claude, "stale Claude result") + ) + let lastClaude = CountingProviderUsageFetcher( + provider: .claude, + result: .unavailable(.claude) + ) + let store = ProviderUsageStore(fetchers: [codex, firstClaude, lastClaude]) + + await store.refresh() + + XCTAssertEqual(store.results.map(\.provider), [.claude, .codex]) + guard case .unavailable(.claude) = store.results.first else { + return XCTFail("The last result for a duplicate provider must win within one refresh") + } + } + + func testOlderRefreshCannotOverwriteANewerGeneration() async { + let fetcher = ControllableProviderUsageFetcher(provider: .codex) + let store = ProviderUsageStore(fetchers: [fetcher]) + + let olderRefresh = Task { await store.refresh() } + await fetcher.waitUntilCallCount(1) + let newerRefresh = Task { await store.refresh() } + await fetcher.waitUntilCallCount(2) + + await fetcher.complete(callID: 1, with: .unavailable(.codex)) + await newerRefresh.value + await fetcher.complete(callID: 0, with: .failed(.codex, "stale failure")) + await olderRefresh.value + + XCTAssertFalse(store.isRefreshing) + guard case .unavailable(.codex) = store.results.first else { + return XCTFail("A late completion from an older refresh must not overwrite newer provider state") + } + } + + func testCancellingRefreshStopsProviderWorkWithoutPublishingItsCancellationResult() async { + let fetcher = CancellationObservingProviderUsageFetcher(provider: .codex) + let store = ProviderUsageStore(fetchers: [fetcher]) + + let refresh = Task { await store.refresh() } + await fetcher.waitUntilStarted() + XCTAssertTrue(store.isRefreshing) + + store.cancelRefresh() + await fetcher.waitUntilCancellationIsObserved() + await refresh.value + + XCTAssertFalse(store.isRefreshing) + XCTAssertTrue( + store.results.isEmpty, + "A provider result produced while unwinding cancellation must not become visible after the popover closes" + ) + } +} + +final class SidebarQuotaPresentationTests: XCTestCase { + func testUnavailableProviderRemainsVisibleBesideAnotherProvidersUsage() { + let claude = ProviderUsageSnapshot( + provider: .claude, + windows: [ + ProviderUsageWindow( + id: "claude.five_hour", + label: "5h", + usedPercent: 17, + resetsAt: Date(timeIntervalSince1970: 1_785_171_600) + ), + ] + ) + + let presentation = SidebarQuotaPresentation( + results: [.available(claude), .unavailable(.codex)] + ) + + XCTAssertEqual(presentation.availableSnapshots.map(\.provider), [.claude]) + XCTAssertEqual(presentation.unavailableProviders, [.codex]) + XCTAssertTrue(presentation.failures.isEmpty) + } } diff --git a/programaTests/MobileBridgeConnectionRegistryTests.swift b/programaTests/MobileBridgeConnectionRegistryTests.swift new file mode 100644 index 00000000..dfa71216 --- /dev/null +++ b/programaTests/MobileBridgeConnectionRegistryTests.swift @@ -0,0 +1,1454 @@ +import Foundation +import IrohLib +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +private final class FakeConnection: @unchecked Sendable { + private let lock = NSLock() + private var storedCloseCount = 0 + + var connectionID: ObjectIdentifier { + ObjectIdentifier(self) + } + + var closeAction: MobileBridgeConnectionRegistry.CloseAction { + { [self] in + lock.lock() + storedCloseCount += 1 + lock.unlock() + } + } + + var closeCount: Int { + lock.lock() + defer { lock.unlock() } + return storedCloseCount + } +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withLock(_ body: (inout Value) -> Result) -> Result { + lock.lock() + defer { lock.unlock() } + return body(&value) + } +} + +private final class FragmentingRecvStream: RecvStream, @unchecked Sendable { + private let lock = NSLock() + private let data: Data + private let maximumBytesPerRead: Int + private var offset = 0 + + init(data: Data, maximumBytesPerRead: Int) { + precondition(maximumBytesPerRead > 0) + self.data = data + self.maximumBytesPerRead = maximumBytesPerRead + super.init(noHandle: RecvStream.NoHandle()) + } + + required init(unsafeFromHandle _: UInt64) { + fatalError("FragmentingRecvStream is test-only and never owns an FFI handle") + } + + override func read(sizeLimit: UInt32) async throws -> Data { + lock.withLock { + guard offset < data.count else { return Data() } + let byteCount = min( + Int(sizeLimit), + maximumBytesPerRead, + data.count - offset + ) + let end = offset + byteCount + let chunk = data.subdata(in: offset ..< end) + offset = end + return chunk + } + } +} + +private final class CancellationBlindOnlineEndpoint: Endpoint, @unchecked Sendable { + private let lock = NSLock() + private let onAcceptNext: () -> Void + private let fakeID: EndpointId + private let fakeAddress: EndpointAddr + private var onlineContinuation: CheckedContinuation? + private var isOnlineReleased = false + + init(onAcceptNext: @escaping () -> Void) throws { + let id = try EndpointId.fromBytes(bytes: Data([ + 0x52, 0x3c, 0x79, 0x96, 0xba, 0xd7, 0x74, 0x24, + 0xe9, 0x67, 0x86, 0xcf, 0x7a, 0x72, 0x05, 0x11, + 0x53, 0x37, 0xa5, 0xb4, 0x56, 0x5c, 0xd2, 0x55, + 0x06, 0xa0, 0xf2, 0x97, 0xb1, 0x91, 0xa5, 0xea, + ])) + self.onAcceptNext = onAcceptNext + fakeID = id + fakeAddress = EndpointAddr(id: id, relayUrl: nil, addresses: []) + super.init(noHandle: Endpoint.NoHandle()) + } + + required init(unsafeFromHandle _: UInt64) { + fatalError("CancellationBlindOnlineEndpoint is test-only and never owns an FFI handle") + } + + override func online() async { + await withCheckedContinuation { continuation in + let shouldResume = lock.withLock { + guard !isOnlineReleased else { return true } + onlineContinuation = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } + + override func addr() -> EndpointAddr { + fakeAddress + } + + override func id() -> EndpointId { + fakeID + } + + override func acceptNext() async -> Incoming? { + onAcceptNext() + return nil + } + + override func close() async throws {} + + func releaseOnlineForTest() { + let continuation = lock.withLock { + isOnlineReleased = true + let continuation = onlineContinuation + onlineContinuation = nil + return continuation + } + continuation?.resume() + } +} + +private enum PersistenceFailure: Error { + case expected +} + +private final class ParkingRelayLineSource: @unchecked Sendable { + private let lock = NSLock() + private let onFirstWait: () -> Void + private var didAnnounceWait = false + private var isFinished = false + private var continuations: [CheckedContinuation] = [] + + init(onFirstWait: @escaping () -> Void = {}) { + self.onFirstWait = onFirstWait + } + + func nextLine() async throws -> Data? { + try await withCheckedThrowingContinuation { continuation in + let shouldAnnounce: Bool = lock.withLock { + guard !isFinished else { + continuation.resume(returning: nil) + return false + } + continuations.append(continuation) + guard !didAnnounceWait else { return false } + didAnnounceWait = true + return true + } + if shouldAnnounce { + onFirstWait() + } + } + } + + func finish() { + let parkedContinuations: [CheckedContinuation] = lock.withLock { + guard !isFinished else { return [] } + isFinished = true + let parkedContinuations = continuations + continuations.removeAll() + return parkedContinuations + } + parkedContinuations.forEach { $0.resume(returning: nil) } + } +} + +private final class ParkingRelayPhoneReader: MobileBridgeRelayLineReading, @unchecked Sendable { + private let source: ParkingRelayLineSource + + init(source: ParkingRelayLineSource) { + self.source = source + } + + func nextLine() async throws -> Data? { + try await source.nextLine() + } +} + +private final class RecordingRelayWriter: MobileBridgeRelayFrameWriting, @unchecked Sendable { + func writeLine(_: Data) async throws {} +} + +private final class ParkingRelayLocalPipe: MobileBridgeRelayLocalPiping, @unchecked Sendable { + private let lock = NSLock() + private let source: ParkingRelayLineSource + private var storedShutdownCount = 0 + + init(source: ParkingRelayLineSource) { + self.source = source + } + + var shutdownCount: Int { + lock.withLock { storedShutdownCount } + } + + func nextLine() async throws -> Data? { + try await source.nextLine() + } + + func send(_: Data) async throws {} + + func shutdownLocalEnd() { + lock.withLock { + storedShutdownCount += 1 + } + source.finish() + } + + /// Test cleanup must be able to release a deliberately cancellation-blind + /// continuation without being counted as production shutdown behavior. + func forceUnblockForTest() { + source.finish() + } +} + +final class MobileBridgeConnectionRegistryTests: XCTestCase { + @discardableResult + private func assertRegistered( + _ result: MobileBridgeConnectionRegistry.RegistrationResult, + file: StaticString = #filePath, + line: UInt = #line + ) -> [MobileBridgeConnectionRegistry.CloseAction] { + guard case .registered(let superseded) = result else { + XCTFail("Expected the current admission ticket to register", file: file, line: line) + return [] + } + return superseded + } + + private func executeRejectedClose( + _ result: MobileBridgeConnectionRegistry.RegistrationResult, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .rejected(let close) = result else { + XCTFail("Expected a stale admission ticket to be rejected", file: file, line: line) + return + } + close() + } + + private func execute(_ actions: [MobileBridgeConnectionRegistry.CloseAction]) { + actions.forEach { $0() } + } + + @MainActor + func testBoundEndpointStartsAcceptingWithoutWaitingForRelayReadiness() async throws { + let acceptStarted = expectation(description: "bound endpoint started accepting connections") + let endpoint = try CancellationBlindOnlineEndpoint { + acceptStarted.fulfill() + } + let listener = MobileBridgeListener(endpointBinder: { endpoint }) + let previousTabManager = TerminalController.shared.tabManager + defer { + listener.stop() + endpoint.releaseOnlineForTest() + TerminalController.shared.tabManager = previousTabManager + } + + listener.start(tabManager: TabManager()) + + await fulfillment(of: [acceptStarted], timeout: 1) + } + + func testRevocationRejectsAnAdmissionThatStartedBeforeTrustWasRemoved() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + let revokedActions = registry.revoke(endpointId: "endpoint-E") + XCTAssertTrue(revokedActions.isEmpty) + + let result = registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + ) + executeRejectedClose(result) + + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) + } + + func testRevocationClaimsARegisteredConnectionExactlyOnce() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + )) + + let claimedActions = registry.revoke(endpointId: "endpoint-E") + XCTAssertEqual(claimedActions.count, 1) + execute(claimedActions) + XCTAssertEqual(connection.closeCount, 1) + + registry.unregister(connectionID: connection.connectionID, endpointId: "endpoint-E") + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) + XCTAssertEqual(connection.closeCount, 1) + } + + func testRetrustCreatesANewGenerationWithoutRevalidatingOldTickets() throws { + let registry = MobileBridgeConnectionRegistry() + let staleConnection = FakeConnection() + let currentConnection = FakeConnection() + + let lifecycle = registry.start() + let staleTicket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) + let currentTicket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + executeRejectedClose(registry.registerIfCurrent( + connectionID: staleConnection.connectionID, + ticket: staleTicket, + close: staleConnection.closeAction + )) + assertRegistered(registry.registerIfCurrent( + connectionID: currentConnection.connectionID, + ticket: currentTicket, + close: currentConnection.closeAction + )) + + XCTAssertEqual(staleConnection.closeCount, 1) + XCTAssertEqual(currentConnection.closeCount, 0) + let currentActions = registry.revoke(endpointId: "endpoint-E") + XCTAssertEqual(currentActions.count, 1) + execute(currentActions) + XCTAssertEqual(currentConnection.closeCount, 1) + } + + func testStopInvalidatesPendingAdmissionsUntilANewListenerGenerationStarts() throws { + let registry = MobileBridgeConnectionRegistry() + let staleConnection = FakeConnection() + let restartedConnection = FakeConnection() + + let staleLifecycle = registry.start() + let staleTicket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: staleLifecycle + )) + XCTAssertTrue(registry.stop().isEmpty) + + executeRejectedClose(registry.registerIfCurrent( + connectionID: staleConnection.connectionID, + ticket: staleTicket, + close: staleConnection.closeAction + )) + XCTAssertEqual(staleConnection.closeCount, 1) + XCTAssertNil(registry.beginAdmission(endpointId: "endpoint-E", lifecycle: staleLifecycle)) + + let restartedLifecycle = registry.start() + let restartedTicket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: restartedLifecycle + )) + assertRegistered(registry.registerIfCurrent( + connectionID: restartedConnection.connectionID, + ticket: restartedTicket, + close: restartedConnection.closeAction + )) + XCTAssertEqual(restartedConnection.closeCount, 0) + + let stoppedActions = registry.stop() + XCTAssertEqual(stoppedActions.count, 1) + execute(stoppedActions) + XCTAssertEqual(restartedConnection.closeCount, 1) + } + + func testHandlerFromStoppedLifecycleCannotBeginAdmissionAfterRestart() throws { + let registry = MobileBridgeConnectionRegistry() + let currentConnection = FakeConnection() + + let stoppedLifecycle = registry.start() + XCTAssertTrue(registry.stop().isEmpty) + let currentLifecycle = registry.start() + + XCTAssertNil(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: stoppedLifecycle + )) + let currentTicket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: currentLifecycle + )) + assertRegistered(registry.registerIfCurrent( + connectionID: currentConnection.connectionID, + ticket: currentTicket, + close: currentConnection.closeAction + )) + XCTAssertEqual(currentConnection.closeCount, 0) + + let stoppedActions = registry.stop() + XCTAssertEqual(stoppedActions.count, 1) + execute(stoppedActions) + XCTAssertEqual(currentConnection.closeCount, 1) + } + + func testDisconnectAndRevocationHaveExactlyOneClaimantInEitherOrder() throws { + let registry = MobileBridgeConnectionRegistry() + let disconnectedFirst = FakeConnection() + let revokedFirst = FakeConnection() + + let lifecycle = registry.start() + + let disconnectedTicket = try XCTUnwrap(registry.beginAdmission( + endpointId: "disconnect-first", + lifecycle: lifecycle + )) + assertRegistered(registry.registerIfCurrent( + connectionID: disconnectedFirst.connectionID, + ticket: disconnectedTicket, + close: disconnectedFirst.closeAction + )) + registry.unregister( + connectionID: disconnectedFirst.connectionID, + endpointId: "disconnect-first" + ) + XCTAssertTrue(registry.revoke(endpointId: "disconnect-first").isEmpty) + XCTAssertEqual(disconnectedFirst.closeCount, 0) + + let revokedTicket = try XCTUnwrap(registry.beginAdmission( + endpointId: "revoke-first", + lifecycle: lifecycle + )) + assertRegistered(registry.registerIfCurrent( + connectionID: revokedFirst.connectionID, + ticket: revokedTicket, + close: revokedFirst.closeAction + )) + let claimedActions = registry.revoke(endpointId: "revoke-first") + XCTAssertEqual(claimedActions.count, 1) + registry.unregister(connectionID: revokedFirst.connectionID, endpointId: "revoke-first") + execute(claimedActions) + XCTAssertEqual(revokedFirst.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "revoke-first").isEmpty) + } + + func testInvalidatedPairingWindowRejectsItsCapturedToken() { + let token = Data("single-use-secret".utf8) + let window = MobileBridgePairingWindow(token: token, duration: .seconds(60)) + + XCTAssertTrue(window.isOpen) + window.invalidate() + + XCTAssertFalse(window.isOpen) + XCTAssertFalse(window.attemptConsume(token)) + } + + func testPairingWindowConsumesMatchingTokenOnlyOnceAndSurvivesMismatch() { + let token = Data("single-use-secret".utf8) + let window = MobileBridgePairingWindow(token: token, duration: .seconds(60)) + + XCTAssertFalse(window.attemptConsume(Data("wrong-secret".utf8))) + XCTAssertTrue(window.isOpen) + XCTAssertTrue(window.attemptConsume(token)) + XCTAssertFalse(window.isOpen) + XCTAssertFalse(window.attemptConsume(token)) + } + + func testStaleRegistrationRejectsBeforeInvokingTrustCommit() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let commitCount = LockedValue(0) + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) + let result = registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction, + beforeRegister: { + commitCount.withLock { $0 += 1 } + return true + } + ) + executeRejectedClose(result) + + XCTAssertEqual(commitCount.withLock { $0 }, 0) + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) + } + + func testFailedTrustCommitRejectsWithoutRegisteringConnection() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let commitCount = LockedValue(0) + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + let result = registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction, + beforeRegister: { + commitCount.withLock { $0 += 1 } + return false + } + ) + executeRejectedClose(result) + + XCTAssertEqual(commitCount.withLock { $0 }, 1) + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) + } + + func testSuccessfulTrustCommitRegistersAndTransfersCloseOwnershipExactlyOnce() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let commitCount = LockedValue(0) + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction, + beforeRegister: { + commitCount.withLock { $0 += 1 } + return true + } + )) + + XCTAssertEqual(commitCount.withLock { $0 }, 1) + XCTAssertEqual(connection.closeCount, 0) + let claimedActions = registry.revoke(endpointId: "endpoint-E", beforeClaim: {}) + XCTAssertEqual(claimedActions.count, 1) + execute(claimedActions) + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) + XCTAssertEqual(connection.closeCount, 1) + } + + func testCommitBeforeRevokeRemovesTrustBeforeClaimingConnection() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let trustedEndpoints = LockedValue(Set()) + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction, + beforeRegister: { + trustedEndpoints.withLock { _ = $0.insert("endpoint-E") } + return true + } + )) + let claimedActions = registry.revoke( + endpointId: "endpoint-E", + beforeClaim: { + trustedEndpoints.withLock { _ = $0.remove("endpoint-E") } + } + ) + + XCTAssertFalse(trustedEndpoints.withLock { $0.contains("endpoint-E") }) + XCTAssertEqual(claimedActions.count, 1) + execute(claimedActions) + XCTAssertEqual(connection.closeCount, 1) + } + + func testRevokeBeforeCommitRejectsWithoutWritingTrust() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let trustedEndpoints = LockedValue(Set()) + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + XCTAssertTrue(registry.revoke( + endpointId: "endpoint-E", + beforeClaim: { + trustedEndpoints.withLock { _ = $0.remove("endpoint-E") } + } + ).isEmpty) + let result = registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction, + beforeRegister: { + trustedEndpoints.withLock { _ = $0.insert("endpoint-E") } + return true + } + ) + executeRejectedClose(result) + + XCTAssertFalse(trustedEndpoints.withLock { $0.contains("endpoint-E") }) + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) + } + + func testStopBeforeCommitRejectsWithoutWritingTrust() throws { + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let trustedEndpoints = LockedValue(Set()) + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + XCTAssertTrue(registry.stop().isEmpty) + let result = registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction, + beforeRegister: { + trustedEndpoints.withLock { _ = $0.insert("endpoint-E") } + return true + } + ) + executeRejectedClose(result) + + XCTAssertFalse(trustedEndpoints.withLock { $0.contains("endpoint-E") }) + XCTAssertEqual(connection.closeCount, 1) + } + + func testPersistenceFailureLeavesNoTrustAndNoLiveConnection() async throws { + let persistenceCount = LockedValue(0) + let store = MobileBridgeTrustedDeviceStore( + fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("mobile-bridge-persistence-failure-\(UUID().uuidString).json"), + persistence: { _, _ in + persistenceCount.withLock { $0 += 1 } + throw PersistenceFailure.expected + } + ) + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: "endpoint-E", + lifecycle: lifecycle + )) + + let result = await store.registerPairedIfCurrent( + endpointId: "endpoint-E", + label: "Test Phone", + registry: registry, + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + ) + executeRejectedClose(result) + let isTrusted = await store.isTrusted("endpoint-E") + + XCTAssertEqual(persistenceCount.withLock { $0 }, 1) + XCTAssertFalse(isTrusted) + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) + } + + func testRevokePersistenceFailurePreservesTrustAndLiveConnection() async throws { + let endpointId = "endpoint-revoke-failure" + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("mobile-bridge-revoke-failure-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let device = MobileBridgeTrustedDevice( + endpointId: endpointId, + label: "Failure Test Phone", + pairedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + try JSONEncoder().encode([device]).write(to: fileURL, options: .atomic) + + let store = MobileBridgeTrustedDeviceStore( + fileURL: fileURL, + persistence: { _, _ in throw PersistenceFailure.expected } + ) + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: endpointId, + lifecycle: lifecycle + )) + assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + )) + + let result = await store.revokeAndClaimConnections( + endpointId: endpointId, + registry: registry + ) + let remainsTrusted = await store.isTrusted(endpointId) + + XCTAssertNotNil(result.persistenceFailure) + XCTAssertTrue(result.closeActions.isEmpty) + XCTAssertTrue(remainsTrusted) + XCTAssertEqual(connection.closeCount, 0) + + let retainedActions = registry.revoke(endpointId: endpointId) + XCTAssertEqual(retainedActions.count, 1) + execute(retainedActions) + XCTAssertEqual(connection.closeCount, 1) + } + + func testSuccessfulRevokePersistsRemovalAndClosesLiveConnection() async throws { + let endpointId = "endpoint-revoke-success" + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("mobile-bridge-revoke-success-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let device = MobileBridgeTrustedDevice( + endpointId: endpointId, + label: "Success Test Phone", + pairedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + try JSONEncoder().encode([device]).write(to: fileURL, options: .atomic) + + let persistedData = LockedValue(nil) + let store = MobileBridgeTrustedDeviceStore( + fileURL: fileURL, + persistence: { data, _ in + persistedData.withLock { $0 = data } + } + ) + let registry = MobileBridgeConnectionRegistry() + let connection = FakeConnection() + let lifecycle = registry.start() + let ticket = try XCTUnwrap(registry.beginAdmission( + endpointId: endpointId, + lifecycle: lifecycle + )) + assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + )) + + let result = await store.revokeAndClaimConnections( + endpointId: endpointId, + registry: registry + ) + let remainsTrusted = await store.isTrusted(endpointId) + + XCTAssertNil(result.persistenceFailure) + XCTAssertFalse(remainsTrusted) + XCTAssertEqual(result.closeActions.count, 1) + execute(result.closeActions) + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: endpointId).isEmpty) + + let writtenData = try XCTUnwrap(persistedData.withLock { $0 }) + let persistedDevices = try JSONDecoder().decode( + [MobileBridgeTrustedDevice].self, + from: writtenData + ) + XCTAssertFalse(persistedDevices.contains { $0.endpointId == endpointId }) + } + + func testAnonymousAdmissionsCannotExceedTheListenerResourceBudget() throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + var leases: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] + + for _ in 0 ..< 10 { + leases.append(try XCTUnwrap(registry.reservePending(lifecycle: lifecycle))) + } + XCTAssertNil( + registry.reservePending(lifecycle: lifecycle), + "An eleventh unauthenticated peer must not allocate another admission task" + ) + + registry.abandonPending(leases.removeLast()) + let replacement = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + XCTAssertNil( + registry.reservePending(lifecycle: lifecycle), + "Replacing an abandoned lease must consume exactly one released slot" + ) + + leases.forEach { registry.abandonPending($0) } + registry.abandonPending(replacement) + } + + func testAnonymousAdmissionDeadlineReleasesStalledPreIdentificationCapacityExactlyOnce() async throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + let timedOutLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + var heldLeases: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] + for _ in 0 ..< 9 { + heldLeases.append(try XCTUnwrap(registry.reservePending(lifecycle: lifecycle))) + } + XCTAssertNil(registry.reservePending(lifecycle: lifecycle)) + + let timeoutClose = FakeConnection() + let timeoutFired = expectation(description: "stalled anonymous admission timed out") + let deadline = MobileBridgeListener.startPendingAdmissionDeadline( + registry: registry, + lease: timedOutLease, + timeout: .milliseconds(10) + ) { + timeoutClose.closeAction() + timeoutFired.fulfill() + } + + await fulfillment(of: [timeoutFired], timeout: 1) + XCTAssertEqual(timeoutClose.closeCount, 1) + XCTAssertFalse( + registry.abandonPending(timedOutLease), + "Timeout must own and release the stalled anonymous lease exactly once" + ) + + let replacement = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + XCTAssertNil( + registry.reservePending(lifecycle: lifecycle), + "A single timeout must release exactly one admission slot" + ) + deadline.cancel() + XCTAssertEqual(timeoutClose.closeCount, 1) + + heldLeases.forEach { registry.abandonPending($0) } + registry.abandonPending(replacement) + } + + func testIdentifiedAdmissionsLimitEachEndpointWithoutBlockingOtherDevices() throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + let firstConnection = FakeConnection() + let duplicateConnection = FakeConnection() + let otherConnection = FakeConnection() + + let firstLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let firstTicket = try XCTUnwrap(registry.identifyPending( + firstLease, + endpointId: "endpoint-E", + close: firstConnection.closeAction + )) + + let duplicateLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + XCTAssertNil(registry.identifyPending( + duplicateLease, + endpointId: "endpoint-E", + close: duplicateConnection.closeAction + )) + duplicateConnection.closeAction() + + let otherLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let otherTicket = try XCTUnwrap(registry.identifyPending( + otherLease, + endpointId: "endpoint-F", + close: otherConnection.closeAction + )) + + XCTAssertEqual(firstConnection.closeCount, 0) + XCTAssertEqual(duplicateConnection.closeCount, 1) + XCTAssertEqual(otherConnection.closeCount, 0) + + let firstClose = try XCTUnwrap(registry.abandonAdmission(firstTicket)) + let otherClose = try XCTUnwrap(registry.abandonAdmission(otherTicket)) + firstClose() + otherClose() + XCTAssertEqual(firstConnection.closeCount, 1) + XCTAssertEqual(duplicateConnection.closeCount, 1) + XCTAssertEqual(otherConnection.closeCount, 1) + } + + func testExpiredAdmissionReleasesCapacityWithoutRestoringCloseOwnership() throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + let expiredConnection = FakeConnection() + + let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let ticket = try XCTUnwrap(registry.identifyPending( + lease, + endpointId: "endpoint-E", + close: expiredConnection.closeAction + )) + let expiredClose = try XCTUnwrap(registry.expireAdmission(ticket)) + expiredClose() + XCTAssertEqual(expiredConnection.closeCount, 1) + + let registration = registry.registerIfCurrent( + connectionID: expiredConnection.connectionID, + ticket: ticket, + close: expiredConnection.closeAction + ) + executeRejectedClose(registration) + XCTAssertNil(registry.abandonAdmission(ticket)) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) + XCTAssertEqual( + expiredConnection.closeCount, + 1, + "Every path retaining a stale ticket must share the admission's exactly-once close ownership" + ) + + var replacements: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] + for _ in 0 ..< 10 { + replacements.append(try XCTUnwrap(registry.reservePending(lifecycle: lifecycle))) + } + XCTAssertNil(registry.reservePending(lifecycle: lifecycle)) + replacements.forEach { registry.abandonPending($0) } + } + + func testRegistrationTransfersIdentifiedAdmissionOwnershipToTheLiveConnection() throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + let connection = FakeConnection() + + let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let ticket = try XCTUnwrap(registry.identifyPending( + lease, + endpointId: "endpoint-E", + close: connection.closeAction + )) + assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + )) + + XCTAssertNil(registry.expireAdmission(ticket)) + XCTAssertNil(registry.abandonAdmission(ticket)) + let revokedActions = registry.revoke(endpointId: "endpoint-E") + XCTAssertEqual(revokedActions.count, 1) + execute(revokedActions) + XCTAssertEqual(connection.closeCount, 1) + XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) + XCTAssertEqual(connection.closeCount, 1) + } + + func testStopClaimsOwnedConnectionsAndInvalidatesEveryPendingLease() throws { + let registry = MobileBridgeConnectionRegistry() + let stoppedLifecycle = registry.start() + let anonymousConnection = FakeConnection() + let pendingConnection = FakeConnection() + let liveConnection = FakeConnection() + + let anonymousLease = try XCTUnwrap(registry.reservePending(lifecycle: stoppedLifecycle)) + + let pendingLease = try XCTUnwrap(registry.reservePending(lifecycle: stoppedLifecycle)) + _ = try XCTUnwrap(registry.identifyPending( + pendingLease, + endpointId: "pending-endpoint", + close: pendingConnection.closeAction + )) + + let liveLease = try XCTUnwrap(registry.reservePending(lifecycle: stoppedLifecycle)) + let liveTicket = try XCTUnwrap(registry.identifyPending( + liveLease, + endpointId: "live-endpoint", + close: liveConnection.closeAction + )) + assertRegistered(registry.registerIfCurrent( + connectionID: liveConnection.connectionID, + ticket: liveTicket, + close: liveConnection.closeAction + )) + + let stoppedActions = registry.stop() + XCTAssertEqual(stoppedActions.count, 2) + execute(stoppedActions) + XCTAssertEqual(pendingConnection.closeCount, 1) + XCTAssertEqual(liveConnection.closeCount, 1) + + XCTAssertNil(registry.identifyPending( + anonymousLease, + endpointId: "anonymous-endpoint", + close: anonymousConnection.closeAction + )) + anonymousConnection.closeAction() + XCTAssertEqual(anonymousConnection.closeCount, 1) + XCTAssertNil(registry.reservePending(lifecycle: stoppedLifecycle)) + + let restartedLifecycle = registry.start() + let restartedLease = try XCTUnwrap(registry.reservePending(lifecycle: restartedLifecycle)) + registry.abandonPending(restartedLease) + XCTAssertTrue(registry.stop().isEmpty) + XCTAssertEqual(pendingConnection.closeCount, 1) + XCTAssertEqual(liveConnection.closeCount, 1) + } + + func testStaleListenerLifecycleCannotAcquireOrPromoteAdmissionCapacity() throws { + let registry = MobileBridgeConnectionRegistry() + let anonymousConnection = FakeConnection() + let identifiedConnection = FakeConnection() + let currentConnection = FakeConnection() + let staleLifecycle = registry.start() + let staleLease = try XCTUnwrap(registry.reservePending(lifecycle: staleLifecycle)) + let identifiedLease = try XCTUnwrap(registry.reservePending(lifecycle: staleLifecycle)) + let staleTicket = try XCTUnwrap(registry.identifyPending( + identifiedLease, + endpointId: "identified-endpoint", + close: identifiedConnection.closeAction + )) + + let stoppedActions = registry.stop() + XCTAssertEqual(stoppedActions.count, 1) + execute(stoppedActions) + let currentLifecycle = registry.start() + + XCTAssertNil(registry.reservePending(lifecycle: staleLifecycle)) + XCTAssertNil(registry.identifyPending( + staleLease, + endpointId: "anonymous-endpoint", + close: anonymousConnection.closeAction + )) + anonymousConnection.closeAction() + executeRejectedClose(registry.registerIfCurrent( + connectionID: identifiedConnection.connectionID, + ticket: staleTicket, + close: identifiedConnection.closeAction + )) + XCTAssertEqual(anonymousConnection.closeCount, 1) + XCTAssertEqual(identifiedConnection.closeCount, 1) + + let currentLease = try XCTUnwrap(registry.reservePending(lifecycle: currentLifecycle)) + let currentTicket = try XCTUnwrap(registry.identifyPending( + currentLease, + endpointId: "endpoint-E", + close: currentConnection.closeAction + )) + let currentClose = try XCTUnwrap(registry.abandonAdmission(currentTicket)) + currentClose() + XCTAssertEqual(currentConnection.closeCount, 1) + XCTAssertTrue(registry.stop().isEmpty) + XCTAssertEqual(identifiedConnection.closeCount, 1) + } + + func testDistinctEndpointsCannotExceedTheLiveConnectionBudget() throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + var liveConnections: [FakeConnection] = [] + + for index in 0 ..< 10 { + let connection = FakeConnection() + let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let ticket = try XCTUnwrap(registry.identifyPending( + lease, + endpointId: "endpoint-\(index)", + close: connection.closeAction + )) + XCTAssertTrue(assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + )).isEmpty) + liveConnections.append(connection) + } + + let overflow = FakeConnection() + let overflowCommitCount = LockedValue(0) + let overflowLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let overflowTicket = try XCTUnwrap(registry.identifyPending( + overflowLease, + endpointId: "endpoint-overflow", + close: overflow.closeAction + )) + executeRejectedClose(registry.registerIfCurrent( + connectionID: overflow.connectionID, + ticket: overflowTicket, + close: overflow.closeAction, + beforeRegister: { + overflowCommitCount.withLock { $0 += 1 } + return true + } + )) + + XCTAssertEqual(overflowCommitCount.withLock { $0 }, 0) + XCTAssertEqual(overflow.closeCount, 1) + XCTAssertTrue(liveConnections.allSatisfy { $0.closeCount == 0 }) + + var reusablePendingCapacity: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] + for _ in 0 ..< 10 { + reusablePendingCapacity.append(try XCTUnwrap( + registry.reservePending(lifecycle: lifecycle) + )) + } + XCTAssertNil(registry.reservePending(lifecycle: lifecycle)) + reusablePendingCapacity.forEach { registry.abandonPending($0) } + + let stoppedActions = registry.stop() + XCTAssertEqual(stoppedActions.count, 10) + execute(stoppedActions) + XCTAssertTrue(liveConnections.allSatisfy { $0.closeCount == 1 }) + XCTAssertEqual(overflow.closeCount, 1) + } + + func testAuthenticatedReconnectAtCapacitySupersedesOnlyItsPriorConnection() throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + var originalConnections: [FakeConnection] = [] + + for index in 0 ..< 10 { + let connection = FakeConnection() + let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let ticket = try XCTUnwrap(registry.identifyPending( + lease, + endpointId: "endpoint-\(index)", + close: connection.closeAction + )) + XCTAssertTrue(assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + )).isEmpty) + originalConnections.append(connection) + } + + let replacement = FakeConnection() + let commitCount = LockedValue(0) + let replacementLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let replacementTicket = try XCTUnwrap(registry.identifyPending( + replacementLease, + endpointId: "endpoint-0", + close: replacement.closeAction + )) + let superseded = assertRegistered(registry.registerIfCurrent( + connectionID: replacement.connectionID, + ticket: replacementTicket, + close: replacement.closeAction, + beforeRegister: { + commitCount.withLock { $0 += 1 } + return true + } + )) + + XCTAssertEqual(commitCount.withLock { $0 }, 1) + XCTAssertEqual(superseded.count, 1) + XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 0 }) + XCTAssertEqual(replacement.closeCount, 0) + + execute(superseded) + XCTAssertEqual(originalConnections[0].closeCount, 1) + XCTAssertTrue(originalConnections.dropFirst().allSatisfy { $0.closeCount == 0 }) + XCTAssertEqual(replacement.closeCount, 0) + + let revokedActions = registry.revoke(endpointId: "endpoint-0") + XCTAssertEqual(revokedActions.count, 1) + execute(revokedActions) + XCTAssertEqual(originalConnections[0].closeCount, 1) + XCTAssertEqual(replacement.closeCount, 1) + + let stoppedActions = registry.stop() + XCTAssertEqual(stoppedActions.count, 9) + execute(stoppedActions) + XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 1 }) + XCTAssertEqual(replacement.closeCount, 1) + } + + func testFailedReconnectAtCapacityCannotDisruptTheActiveConnection() throws { + let registry = MobileBridgeConnectionRegistry() + let lifecycle = registry.start() + var originalConnections: [FakeConnection] = [] + + for index in 0 ..< 10 { + let connection = FakeConnection() + let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let ticket = try XCTUnwrap(registry.identifyPending( + lease, + endpointId: "endpoint-\(index)", + close: connection.closeAction + )) + XCTAssertTrue(assertRegistered(registry.registerIfCurrent( + connectionID: connection.connectionID, + ticket: ticket, + close: connection.closeAction + )).isEmpty) + originalConnections.append(connection) + } + + let failedReplacement = FakeConnection() + let failedCommitCount = LockedValue(0) + let replacementLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) + let replacementTicket = try XCTUnwrap(registry.identifyPending( + replacementLease, + endpointId: "endpoint-0", + close: failedReplacement.closeAction + )) + executeRejectedClose(registry.registerIfCurrent( + connectionID: failedReplacement.connectionID, + ticket: replacementTicket, + close: failedReplacement.closeAction, + beforeRegister: { + failedCommitCount.withLock { $0 += 1 } + return false + } + )) + + XCTAssertEqual(failedCommitCount.withLock { $0 }, 1) + XCTAssertEqual(failedReplacement.closeCount, 1) + XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 0 }) + + let revokedActions = registry.revoke(endpointId: "endpoint-0") + XCTAssertEqual(revokedActions.count, 1) + execute(revokedActions) + XCTAssertEqual(originalConnections[0].closeCount, 1) + XCTAssertEqual(failedReplacement.closeCount, 1) + + let stoppedActions = registry.stop() + XCTAssertEqual(stoppedActions.count, 9) + execute(stoppedActions) + XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 1 }) + XCTAssertEqual(failedReplacement.closeCount, 1) + } + + func testLineReaderReassemblesAFrameFromSingleByteReads() async throws { + let stream = FragmentingRecvStream( + data: Data("fragmented payload\nnext frame\n".utf8), + maximumBytesPerRead: 1 + ) + let reader = MobileBridgeStreamLineReader(stream: stream) + + let first = try await reader.nextLine() + let second = try await reader.nextLine() + let end = try await reader.nextLine() + + XCTAssertEqual(first, Data("fragmented payload".utf8)) + XCTAssertEqual(second, Data("next frame".utf8)) + XCTAssertNil(end) + } + + func testLineReaderAcceptsExactlyEightMiBWithoutTruncatingTheFrame() async throws { + let maximumLineByteCount = 8 * 1024 * 1024 + var framed = Data(repeating: 0x61, count: maximumLineByteCount) + framed.append(0x0A) + let reader = MobileBridgeStreamLineReader(stream: FragmentingRecvStream( + data: framed, + maximumBytesPerRead: 65_536 + )) + + let receivedLine = try await reader.nextLine() + let line = try XCTUnwrap(receivedLine) + + XCTAssertEqual(line.count, maximumLineByteCount) + XCTAssertEqual(line.first, 0x61) + XCTAssertEqual(line.last, 0x61) + let end = try await reader.nextLine() + XCTAssertNil(end) + } + + func testLineReaderRejectsAFrameOneByteBeyondEightMiB() async throws { + let maximumLineByteCount = 8 * 1024 * 1024 + var framed = Data(repeating: 0x61, count: maximumLineByteCount + 1) + framed.append(0x0A) + let reader = MobileBridgeStreamLineReader(stream: FragmentingRecvStream( + data: framed, + maximumBytesPerRead: 65_536 + )) + + do { + _ = try await reader.nextLine() + XCTFail("A frame above the bridge's memory bound must be rejected") + } catch MobileBridgeStreamLineReaderError.frameTooLarge { + // Expected: an unauthenticated peer cannot grow the framing buffer beyond its cap. + } catch { + XCTFail("Expected frameTooLarge, got \(error)") + } + } + + func testLineReaderReturnsAnUnterminatedFinalFrameThenStableEOF() async throws { + let reader = MobileBridgeStreamLineReader(stream: FragmentingRecvStream( + data: Data("final frame without newline".utf8), + maximumBytesPerRead: 3 + )) + + let finalFrame = try await reader.nextLine() + let firstEOF = try await reader.nextLine() + let secondEOF = try await reader.nextLine() + + XCTAssertEqual(finalFrame, Data("final frame without newline".utf8)) + XCTAssertNil(firstEOF) + XCTAssertNil(secondEOF) + } + + func testRelayPumpClosesBothBlockingDirectionsWhenLocalControlReachesEOF() async { + let phoneParked = expectation(description: "phone read parked") + let localParked = expectation(description: "local read parked") + let pumpCompleted = expectation(description: "relay pump completed") + let phoneSource = ParkingRelayLineSource { phoneParked.fulfill() } + let localSource = ParkingRelayLineSource { localParked.fulfill() } + let pipe = ParkingRelayLocalPipe(source: localSource) + let remoteCloseCount = LockedValue(0) + + let task = Task { + await MobileBridgeSession.pump( + reader: ParkingRelayPhoneReader(source: phoneSource), + writer: RecordingRelayWriter(), + pipe: pipe, + closeRemote: { + remoteCloseCount.withLock { $0 += 1 } + phoneSource.finish() + } + ) + pumpCompleted.fulfill() + } + + await fulfillment(of: [phoneParked, localParked], timeout: 1) + localSource.finish() + await fulfillment(of: [pumpCompleted], timeout: 1) + + // Keep a broken implementation from retaining parked continuations + // after XCTest records the bounded timeout failure. + phoneSource.finish() + pipe.forceUnblockForTest() + task.cancel() + _ = await task.result + + XCTAssertEqual( + remoteCloseCount.withLock { $0 }, + 1, + "Local EOF must close the cancellation-blind phone read exactly once" + ) + XCTAssertEqual( + pipe.shutdownCount, + 1, + "Local EOF cleanup must not race the pump into shutting down its local end twice" + ) + } + + func testRelayPumpClosesBothBlockingDirectionsWhenPhoneReachesEOF() async { + let phoneParked = expectation(description: "phone read parked") + let localParked = expectation(description: "local read parked") + let pumpCompleted = expectation(description: "relay pump completed") + let phoneSource = ParkingRelayLineSource { phoneParked.fulfill() } + let localSource = ParkingRelayLineSource { localParked.fulfill() } + let pipe = ParkingRelayLocalPipe(source: localSource) + let remoteCloseCount = LockedValue(0) + + let task = Task { + await MobileBridgeSession.pump( + reader: ParkingRelayPhoneReader(source: phoneSource), + writer: RecordingRelayWriter(), + pipe: pipe, + closeRemote: { + remoteCloseCount.withLock { $0 += 1 } + phoneSource.finish() + } + ) + pumpCompleted.fulfill() + } + + await fulfillment(of: [phoneParked, localParked], timeout: 1) + phoneSource.finish() + await fulfillment(of: [pumpCompleted], timeout: 1) + + phoneSource.finish() + pipe.forceUnblockForTest() + task.cancel() + _ = await task.result + + XCTAssertEqual( + remoteCloseCount.withLock { $0 }, + 1, + "Phone EOF cleanup must not race the pump into closing the remote side twice" + ) + XCTAssertEqual( + pipe.shutdownCount, + 1, + "Phone EOF must unblock the cancellation-blind local read exactly once" + ) + } + + func testCancellingRelayPumpClosesBothBlockingDirectionsAndCompletes() async { + let phoneParked = expectation(description: "phone read parked") + let localParked = expectation(description: "local read parked") + let pumpCompleted = expectation(description: "cancelled relay pump completed") + let phoneSource = ParkingRelayLineSource { phoneParked.fulfill() } + let localSource = ParkingRelayLineSource { localParked.fulfill() } + let pipe = ParkingRelayLocalPipe(source: localSource) + let remoteCloseCount = LockedValue(0) + + let task = Task { + await MobileBridgeSession.pump( + reader: ParkingRelayPhoneReader(source: phoneSource), + writer: RecordingRelayWriter(), + pipe: pipe, + closeRemote: { + remoteCloseCount.withLock { $0 += 1 } + phoneSource.finish() + } + ) + pumpCompleted.fulfill() + } + + await fulfillment(of: [phoneParked, localParked], timeout: 1) + task.cancel() + await fulfillment(of: [pumpCompleted], timeout: 1) + + phoneSource.finish() + pipe.forceUnblockForTest() + task.cancel() + _ = await task.result + + XCTAssertEqual( + remoteCloseCount.withLock { $0 }, + 1, + "Cancellation cleanup and normal pump cleanup must share remote close ownership" + ) + XCTAssertEqual( + pipe.shutdownCount, + 1, + "Cancellation cleanup and normal pump cleanup must share local shutdown ownership" + ) + } +} diff --git a/programaUITests/SidebarHelpMenuUITests.swift b/programaUITests/SidebarHelpMenuUITests.swift index 3d1c1f5f..a0e01317 100644 --- a/programaUITests/SidebarHelpMenuUITests.swift +++ b/programaUITests/SidebarHelpMenuUITests.swift @@ -23,6 +23,32 @@ final class SidebarHelpMenuUITests: XCTestCase { continueAfterFailure = false } + func testVisibleSidebarHeaderToggleHidesItsOwningSidebar() { + let app = XCUIApplication() + app.launchEnvironment["PROGRAMA_UI_TEST_MODE"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let sidebar = app.descendants(matching: .any).matching(identifier: "Sidebar").firstMatch + XCTAssertTrue(sidebar.waitForExistence(timeout: 6.0), "Expected the main window sidebar to start visible") + + let toggle = sidebar.descendants(matching: .button) + .matching(identifier: "titlebarControl.toggleSidebar") + .firstMatch + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { toggle.exists && toggle.isHittable }, + "Expected the visible sidebar header to expose its own sidebar toggle" + ) + + toggle.click() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { !sidebar.exists || !sidebar.isHittable }, + "The sidebar header toggle must hide the SidebarState that rendered it" + ) + } + func testHelpMenuOpensKeyboardShortcutsSection() { let app = XCUIApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MODE"] = "1" @@ -54,6 +80,7 @@ final class SidebarHelpMenuUITests: XCTestCase { app.launchEnvironment["PROGRAMA_UI_TEST_FEED_MODE"] = "available" app.launchEnvironment["PROGRAMA_UI_TEST_UPDATE_VERSION"] = "9.9.9" app.launchEnvironment["PROGRAMA_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1" + app.launchEnvironment["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] = "1" launchAndActivate(app) XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) @@ -72,7 +99,7 @@ final class SidebarHelpMenuUITests: XCTestCase { ) checkForUpdatesItem.click() - let updatePill = app.buttons["UpdatePill"] + let updatePill = app.buttons["Update Available: 9.9.9"] XCTAssertTrue(updatePill.waitForExistence(timeout: 6.0)) XCTAssertEqual(updatePill.label, "Update Available: 9.9.9") } diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/DiagnosticsLog.swift b/vendor/bonsplit/Sources/Bonsplit/Public/DiagnosticsLog.swift index 4b3eb8d2..8e6f4c91 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Public/DiagnosticsLog.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Public/DiagnosticsLog.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation /// Always-on, local-only diagnostics log for Release builds. @@ -11,15 +12,28 @@ import Foundation /// the public `log` call never does file I/O on the caller's thread — it only formats a /// timestamp and enqueues the write onto a dedicated serial `.utility` queue. public final class DiagnosticsLog: @unchecked Sendable { + typealias FileHandleOpener = (URL) -> FileHandle? + typealias FileRotator = (URL, URL) -> Bool + public static let shared = DiagnosticsLog() /// 2 MB cap before rotation to `.1`. private static let maxBytes: UInt64 = 2 * 1024 * 1024 + /// Bounds records written to a handle whose path was removed externally. + static let pathValidationRecordInterval = 64 + /// Avoids a failed filesystem rotation becoming per-record close/open churn. + private static let rotationRetryRecordInterval = 64 private let queue = DispatchQueue(label: "programa.diagnostics-log", qos: .utility) private let fileURL: URL private let rotatedURL: URL + private let fileHandleOpener: FileHandleOpener + private let fileRotator: FileRotator private var currentBytes: UInt64 + private var activeHandle: FileHandle? + private var directoryIsReady = false + private var recordsSincePathValidation = 0 + private var rotationRetryRecordCountdown = 0 private static let formatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() @@ -41,10 +55,24 @@ public final class DiagnosticsLog: @unchecked Sendable { } /// Testable initializer: point the log at an arbitrary file path. - public init(fileURL: URL) { + public convenience init(fileURL: URL) { + self.init( + fileURL: fileURL, + fileHandleOpener: { FileHandle(forWritingAtPath: $0.path) }, + fileRotator: Self.replaceFileAtomically + ) + } + + init( + fileURL: URL, + fileHandleOpener: @escaping FileHandleOpener, + fileRotator: @escaping FileRotator = DiagnosticsLog.replaceFileAtomically + ) { self.fileURL = fileURL self.rotatedURL = fileURL.deletingPathExtension() .appendingPathExtension(fileURL.pathExtension.isEmpty ? "1" : fileURL.pathExtension + ".1") + self.fileHandleOpener = fileHandleOpener + self.fileRotator = fileRotator let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path) self.currentBytes = (attrs?[.size] as? UInt64) ?? 0 } @@ -60,41 +88,121 @@ public final class DiagnosticsLog: @unchecked Sendable { queue.async { [self] in guard let data = line.data(using: .utf8) else { return } - let dir = fileURL.deletingLastPathComponent() - if !FileManager.default.fileExists(atPath: dir.path) { - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } + validateActivePathIfNeeded() - if currentBytes + UInt64(data.count) > Self.maxBytes { + if currentBytes + UInt64(data.count) > Self.maxBytes, + rotationRetryRecordCountdown == 0 { rotate() } - if let handle = FileHandle(forWritingAtPath: fileURL.path) { - _ = try? handle.seekToEnd() - try? handle.write(contentsOf: data) - try? handle.close() - } else { - FileManager.default.createFile( - atPath: fileURL.path, - contents: data, - attributes: [.posixPermissions: 0o600] - ) + guard let handle = openHandleIfNeeded() else { return } + do { + try handle.write(contentsOf: data) + currentBytes += UInt64(data.count) + recordsSincePathValidation += 1 + if rotationRetryRecordCountdown > 0 { + rotationRetryRecordCountdown -= 1 + } + } catch { + closeActiveHandle() + directoryIsReady = false } - currentBytes += UInt64(data.count) } } - /// Blocks until every enqueued write has hit the file. Test seam; also safe - /// to call before collecting the log for a bug report. + /// Blocks until every queued best-effort write has been attempted, then asks the + /// active handle to synchronize. Test seam; also safe before collecting a bug report. public func flush() { - queue.sync {} + queue.sync { + try? activeHandle?.synchronize() + } } /// Runs on `queue`. Overwrites the previous rotated file and resets the byte counter. private func rotate() { - try? FileManager.default.removeItem(at: rotatedURL) - try? FileManager.default.moveItem(at: fileURL, to: rotatedURL) - currentBytes = 0 + closeActiveHandle() + if fileRotator(fileURL, rotatedURL) { + currentBytes = 0 + rotationRetryRecordCountdown = 0 + } else { + directoryIsReady = false + let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + currentBytes = (attrs?[.size] as? UInt64) ?? 0 + rotationRetryRecordCountdown = Self.rotationRetryRecordInterval + } + } + + /// POSIX rename replaces the prior `.1` path atomically. A failed rename leaves + /// both the current log and the last readable backup untouched. + private static func replaceFileAtomically(source: URL, destination: URL) -> Bool { + source.withUnsafeFileSystemRepresentation { sourcePath in + destination.withUnsafeFileSystemRepresentation { destinationPath in + guard let sourcePath, let destinationPath else { return false } + return Darwin.rename(sourcePath, destinationPath) == 0 + } + } + } + + /// Runs on `queue`. The directory check, file creation, and seek happen once per + /// active file rather than once per log record. + private func openHandleIfNeeded() -> FileHandle? { + if let activeHandle { return activeHandle } + + if !directoryIsReady { + let directory = fileURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + directoryIsReady = true + } catch { + directoryIsReady = false + return nil + } + } + + if !FileManager.default.fileExists(atPath: fileURL.path) { + guard FileManager.default.createFile( + atPath: fileURL.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { return nil } + } + + guard let handle = fileHandleOpener(fileURL) else { + directoryIsReady = false + return nil + } + do { + try handle.seekToEnd() + activeHandle = handle + recordsSincePathValidation = 0 + return handle + } catch { + try? handle.close() + directoryIsReady = false + return nil + } + } + + /// Runs on `queue`. A periodic path check catches external directory deletion + /// without putting a filesystem lookup back on every log record. + private func validateActivePathIfNeeded() { + guard activeHandle != nil, + recordsSincePathValidation + 1 >= Self.pathValidationRecordInterval else { return } + recordsSincePathValidation = 0 + guard FileManager.default.fileExists(atPath: fileURL.path) else { + closeActiveHandle() + directoryIsReady = false + currentBytes = 0 + rotationRetryRecordCountdown = 0 + return + } + } + + /// Runs on `queue` before rotation or after a write failure. + private func closeActiveHandle() { + guard let activeHandle else { return } + try? activeHandle.close() + self.activeHandle = nil } } diff --git a/vendor/bonsplit/Tests/BonsplitTests/DiagnosticsLogTests.swift b/vendor/bonsplit/Tests/BonsplitTests/DiagnosticsLogTests.swift index 9ef01a94..b417f2c3 100644 --- a/vendor/bonsplit/Tests/BonsplitTests/DiagnosticsLogTests.swift +++ b/vendor/bonsplit/Tests/BonsplitTests/DiagnosticsLogTests.swift @@ -1,6 +1,51 @@ import XCTest @testable import Bonsplit +private final class DiagnosticsFileHandleProbe: @unchecked Sendable { + private let lock = NSLock() + private var openedHandles: [FileHandle] = [] + + func open(_ url: URL) -> FileHandle? { + lock.lock() + defer { lock.unlock() } + + if !FileManager.default.fileExists(atPath: url.path) { + guard FileManager.default.createFile( + atPath: url.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { return nil } + } + guard let handle = FileHandle(forWritingAtPath: url.path) else { return nil } + openedHandles.append(handle) + return handle + } + + var handles: [FileHandle] { + lock.lock() + defer { lock.unlock() } + return openedHandles + } +} + +private final class DiagnosticsRotationFailureProbe: @unchecked Sendable { + private let lock = NSLock() + private var attemptedRotations = 0 + + func fail(source: URL, destination: URL) -> Bool { + lock.lock() + attemptedRotations += 1 + lock.unlock() + return false + } + + var attemptCount: Int { + lock.lock() + defer { lock.unlock() } + return attemptedRotations + } +} + final class DiagnosticsLogTests: XCTestCase { private func tempFileURL() -> URL { FileManager.default.temporaryDirectory @@ -51,4 +96,153 @@ final class DiagnosticsLogTests: XCTestCase { let currentSize = (attrs?[.size] as? UInt64) ?? 0 XCTAssertLessThan(currentSize, 2 * 1024 * 1024, "current log file should be smaller than the cap after rotation") } + + func testQueuedRecordsReuseOneOpenFileHandleBeforeRotation() { + let url = tempFileURL() + defer { try? FileManager.default.removeItem(at: url) } + let probe = DiagnosticsFileHandleProbe() + + // Package-internal test seam: production owns handle reuse and accepts + // only an opener for observing real file-handle lifetimes. + let log = DiagnosticsLog(fileURL: url, fileHandleOpener: { probe.open($0) }) + for i in 0..<20 { + log.log("test.reuse", "record-\(i)") + } + waitForQueue(log) + + XCTAssertEqual( + probe.handles.count, + 1, + "Queued records below the rotation cap must reuse one open file handle instead of reopening the file per record" + ) + let contents = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + XCTAssertEqual(contents.split(separator: "\n").count, 20) + } + + func testRotationClosesAndReplacesHandleWithoutLosingRecords() throws { + let url = tempFileURL() + let rotatedURL = url.deletingPathExtension().appendingPathExtension("log.1") + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: rotatedURL) + } + let probe = DiagnosticsFileHandleProbe() + let log = DiagnosticsLog(fileURL: url, fileHandleOpener: { probe.open($0) }) + + // Three records fit below 2 MB. The fourth crosses the cap exactly once, + // so it must land through a replacement handle in the new current file. + let padding = String(repeating: "x", count: 600 * 1024) + for i in 0..<4 { + log.log("test.rotation", "record-\(i) \(padding)") + } + waitForQueue(log) + + let handles = probe.handles + guard handles.count == 2 else { + XCTFail("Rotation must replace the active handle exactly once; opened \(handles.count) handles") + return + } + XCTAssertThrowsError( + try handles[0].seekToEnd(), + "The pre-rotation handle must be closed before its file is moved" + ) + XCTAssertNoThrow( + try handles[1].seekToEnd(), + "The post-rotation current file must remain attached to the replacement handle" + ) + + let current = try String(contentsOf: url, encoding: .utf8) + let rotated = try String(contentsOf: rotatedURL, encoding: .utf8) + let combined = current + rotated + for i in 0..<4 { + XCTAssertEqual( + combined.components(separatedBy: "record-\(i)").count - 1, + 1, + "Rotation must preserve record-\(i) exactly once across the current and .1 files" + ) + } + XCTAssertTrue(current.contains("record-3"), "The record that crosses the cap must be written to the new current file") + XCTAssertFalse(rotated.contains("record-3"), "The post-rotation record must not be appended to the rotated file") + } + + func testFailedRotationPreservesBackupAndKeepsCurrentHandleUsable() throws { + let url = tempFileURL() + let rotatedURL = url.deletingPathExtension().appendingPathExtension("log.1") + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: rotatedURL) + } + + let existingBackup = Data("existing-backup-must-survive".utf8) + try existingBackup.write(to: rotatedURL) + try Data(repeating: 0x78, count: 2 * 1024 * 1024 - 512).write(to: url) + + let handles = DiagnosticsFileHandleProbe() + let rotation = DiagnosticsRotationFailureProbe() + let log = DiagnosticsLog( + fileURL: url, + fileHandleOpener: { handles.open($0) }, + fileRotator: { rotation.fail(source: $0, destination: $1) } + ) + + log.log("test.rotation-failure", "before-failure") + log.log("test.rotation-failure", "cross-cap \(String(repeating: "y", count: 1024))") + for i in 0..<10 { + log.log("test.rotation-failure", "after-failure-\(i)") + } + waitForQueue(log) + + XCTAssertEqual(try Data(contentsOf: rotatedURL), existingBackup, "A failed rotation must not destroy the last readable backup") + XCTAssertEqual(rotation.attemptCount, 1, "A failed rotation must not close and reopen the current file for every later record") + + let openedHandles = handles.handles + guard openedHandles.count == 2 else { + XCTFail("A failed rotation must reopen the current log once; opened \(openedHandles.count) handles") + return + } + XCTAssertThrowsError(try openedHandles[0].seekToEnd(), "The handle closed for rotation must stay closed") + XCTAssertNoThrow(try openedHandles[1].seekToEnd(), "The current log must remain writable through its replacement handle") + + let current = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(current.contains("before-failure")) + XCTAssertTrue(current.contains("cross-cap")) + XCTAssertTrue(current.contains("after-failure-9"), "Records after a failed rotation must remain visible at the configured path") + } + + func testDeletedLogDirectoryIsRecreatedWithinBoundedRecordInterval() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("DiagnosticsLogTests-\(UUID().uuidString)", isDirectory: true) + let url = directory.appendingPathComponent("diagnostics.log") + defer { try? FileManager.default.removeItem(at: directory) } + + let probe = DiagnosticsFileHandleProbe() + let log = DiagnosticsLog(fileURL: url, fileHandleOpener: { probe.open($0) }) + log.log("test.directory", "before-directory-removal") + waitForQueue(log) + XCTAssertEqual(probe.handles.count, 1, "The precondition requires one active handle before external deletion") + + try FileManager.default.removeItem(at: directory) + let validationBound = DiagnosticsLog.pathValidationRecordInterval + XCTAssertGreaterThan(validationBound, 0, "Path validation must use a positive bounded record interval") + if validationBound > 1 { + for i in 1..