diff --git a/.circleci/config.yml b/.circleci/config.yml index e766b4d5a447..c5efc1e45057 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -55,6 +55,9 @@ commands: name: Install zig no_output_timeout: 35m command: | + export HOMEBREW_NO_AUTO_UPDATE=1 + export HOMEBREW_NO_INSTALL_CLEANUP=1 + export HOMEBREW_NO_ENV_HINTS=1 ./scripts/install-zig-ci.sh print-xcode: @@ -223,6 +226,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_contract_help.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_layout_focus_contract.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_clear_running_status.py - save-swift-package-cache: prefix: unit - save-zig-cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6db70293b865..d56a723ceebe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,6 +320,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py python3 tests/test_claude_wrapper_hooks.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_stop_last_assistant.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_clear_running_status.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py tests-build-and-lag: diff --git a/.github/workflows/perf-activation.yml b/.github/workflows/perf-activation.yml index 3130c327017c..1dd3fc1d3242 100644 --- a/.github/workflows/perf-activation.yml +++ b/.github/workflows/perf-activation.yml @@ -70,6 +70,10 @@ jobs: run: ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + HOMEBREW_NO_ENV_HINTS: "1" run: | ./scripts/install-zig-ci.sh diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 07519bd5fc07..9a9a020ae8dd 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -39,7 +39,7 @@ extension CMUXCLI { } enum PostInstallAction { - case codexConfigToml // write hooks = true to config.toml on install, remove on uninstall + case codexConfigToml // write codex_hooks = true to config.toml on install, remove on uninstall } /// Resolves the config directory, respecting env override if set. diff --git a/CLI/CMUXCLI+CodexConfigToml.swift b/CLI/CMUXCLI+CodexConfigToml.swift deleted file mode 100644 index 25b4317d4cd1..000000000000 --- a/CLI/CMUXCLI+CodexConfigToml.swift +++ /dev/null @@ -1,239 +0,0 @@ -import Foundation - -extension CMUXCLI { - private static let cmuxCodexHooksFeatureBegin = - "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin" - private static let cmuxCodexHooksFeatureEnd = - "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df end" - private static let cmuxCodexHooksFeaturePreviousLinePrefix = - "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df previous line: " - private static let legacyCmuxCodexHooksFeatureBegin = "# cmux hooks codex feature begin" - private static let legacyCmuxCodexHooksFeatureEnd = "# cmux hooks codex feature end" - private static let legacyCmuxCodexHooksFeaturePreviousLinePrefix = "# cmux hooks codex feature previous line: " - - static func codexConfigTomlInstallingHooksFeature(in existingContent: String) -> String { - var lines = tomlLines(from: existingContent) - removeCmuxCodexHooksFeatureBlock(from: &lines) - lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) } - lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) } - - let insertedLines = [ - cmuxCodexHooksFeatureBegin, - "hooks = true", - cmuxCodexHooksFeatureEnd, - ] - let insertedDottedLines = [ - cmuxCodexHooksFeatureBegin, - "features.hooks = true", - cmuxCodexHooksFeatureEnd, - ] - - if let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) { - let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart) - if featuresStart + 1 < featuresEnd, - let hooksIndex = (featuresStart + 1.. [String] { - var lines = [cmuxCodexHooksFeatureBegin] - if let previousLine { - lines.append(cmuxCodexHooksFeaturePreviousLinePrefix + previousLine) - } - lines.append(settingLine) - lines.append(cmuxCodexHooksFeatureEnd) - return lines - } - - static func codexConfigTomlUninstallingHooksFeature(from existingContent: String) -> String { - var lines = tomlLines(from: existingContent) - removeCmuxCodexHooksFeatureBlock(from: &lines) - lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) } - lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) } - removeEmptyFeaturesTable(from: &lines) - return tomlContent(from: lines) - } - - private static func tomlLines(from content: String) -> [String] { - guard !content.isEmpty else { return [] } - var lines = content.components(separatedBy: "\n") - if content.hasSuffix("\n"), lines.last == "" { - lines.removeLast() - } - return lines - } - - private static func tomlContent(from lines: [String]) -> String { - guard !lines.isEmpty else { return "" } - return lines.joined(separator: "\n") + "\n" - } - - private static func tomlLineDefinesKey(_ key: String, line: String) -> Bool { - let escapedKey = NSRegularExpression.escapedPattern(for: key) - return line.range( - of: #"^\s*"# + escapedKey + #"\s*="#, - options: .regularExpression - ) != nil - } - - private static func tomlLineDefinesTrueKey(_ key: String, line: String) -> Bool { - let escapedKey = NSRegularExpression.escapedPattern(for: key) - return line.range( - of: #"^\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#, - options: .regularExpression - ) != nil - } - - private static func tomlLineDefinesDottedFeaturesKey(_ key: String, line: String) -> Bool { - let escapedKey = NSRegularExpression.escapedPattern(for: key) - return line.range( - of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*="#, - options: .regularExpression - ) != nil - } - - private static func tomlLineDefinesDottedFeaturesTrueKey(_ key: String, line: String) -> Bool { - let escapedKey = NSRegularExpression.escapedPattern(for: key) - return line.range( - of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#, - options: .regularExpression - ) != nil - } - - private static func tomlLineDefinesAnyDottedFeaturesKey(_ line: String) -> Bool { - line.range( - of: #"^\s*features\s*\.\s*[^=\s]+\s*="#, - options: .regularExpression - ) != nil - } - - private static func tomlLineIsTable(_ name: String, line: String) -> Bool { - let escapedName = NSRegularExpression.escapedPattern(for: name) - return line.range( - of: #"^\s*\[\s*"# + escapedName + #"\s*\]\s*(#.*)?$"#, - options: .regularExpression - ) != nil - } - - private static func tomlLineIsAnyTableHeader(_ line: String) -> Bool { - let tomlKey = "(?:[A-Za-z0-9_-]+|\"[^\"\\n]*\"|'[^'\\n]*')" - let tomlKeyPath = tomlKey + "(?:\\s*\\.\\s*" + tomlKey + ")*" - let pattern = "^\\s*(?:\\[\\s*" + tomlKeyPath + "\\s*\\]|\\[\\[\\s*" + tomlKeyPath - + "\\s*\\]\\])\\s*(#.*)?$" - return line.range( - of: pattern, - options: .regularExpression - ) != nil - } - - private static func tomlTableEndIndex(in lines: [String], after tableStart: Int) -> Int { - var index = tableStart + 1 - while index < lines.count { - if tomlLineIsAnyTableHeader(lines[index]) { - return index - } - index += 1 - } - return lines.count - } - - private static func removeCmuxCodexHooksFeatureBlock(from lines: inout [String]) { - var index = 0 - while index < lines.count { - guard tomlLineIsCodexHooksFeatureBegin(lines[index]) else { - index += 1 - continue - } - - if let endIndex = lines[index...].firstIndex(where: { - tomlLineIsCodexHooksFeatureEnd($0) - }) { - let previousLines = lines[index...endIndex].compactMap { line -> String? in - tomlCodexHooksFeaturePreviousLine(from: line) - } - lines.replaceSubrange(index...endIndex, with: previousLines) - } else { - var blockEnd = index + 1 - var previousLines: [String] = [] - if blockEnd < lines.count, - let previousLine = tomlCodexHooksFeaturePreviousLine(from: lines[blockEnd]) - { - previousLines.append(previousLine) - blockEnd += 1 - } - if blockEnd < lines.count, tomlLineIsCodexHooksFeatureSetting(lines[blockEnd]) { - blockEnd += 1 - } - lines.replaceSubrange(index.. Bool { - line == cmuxCodexHooksFeatureBegin || line == legacyCmuxCodexHooksFeatureBegin - } - - private static func tomlLineIsCodexHooksFeatureEnd(_ line: String) -> Bool { - line == cmuxCodexHooksFeatureEnd || line == legacyCmuxCodexHooksFeatureEnd - } - - private static func tomlCodexHooksFeaturePreviousLine(from line: String) -> String? { - if line.hasPrefix(cmuxCodexHooksFeaturePreviousLinePrefix) { - return String(line.dropFirst(cmuxCodexHooksFeaturePreviousLinePrefix.count)) - } - if line.hasPrefix(legacyCmuxCodexHooksFeaturePreviousLinePrefix) { - return String(line.dropFirst(legacyCmuxCodexHooksFeaturePreviousLinePrefix.count)) - } - return nil - } - - private static func tomlLineIsCodexHooksFeatureSetting(_ line: String) -> Bool { - tomlLineDefinesTrueKey("hooks", line: line) - || tomlLineDefinesDottedFeaturesTrueKey("hooks", line: line) - } - - private static func removeEmptyFeaturesTable(from lines: inout [String]) { - guard let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) else { - return - } - let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart) - let bodyRange = featuresStart + 1.. Bool { + guard let normalizedSessionId = normalizeOptional(sessionId), + let normalizedWorkspace = normalizeOptional(workspaceId) else { + return true + } + return try withLockedState { state in + guard let active = state.activeSessionsByWorkspace[normalizedWorkspace] else { + return true + } + guard active.sessionId == normalizedSessionId else { + return false + } + guard let activeTurnId = normalizeOptional(active.turnId), + let normalizedTurnId = normalizeOptional(turnId) else { + return true + } + return activeTurnId == normalizedTurnId + } + } + + func canReplaceActiveSession(sessionId: String?, workspaceId: String) throws -> Bool { + guard let normalizedSessionId = normalizeOptional(sessionId), + let normalizedWorkspace = normalizeOptional(workspaceId) else { + return false + } + return try withLockedState { state in + guard let active = state.activeSessionsByWorkspace[normalizedWorkspace], + active.sessionId != normalizedSessionId else { + return false + } + return active.allowsNewSessionReplacement == true } } func consume( sessionId: String?, workspaceId: String?, - surfaceId: String? + surfaceId: String?, + turnId: String? = nil ) throws -> ClaudeHookSessionRecord? { let normalizedSessionId = normalizeOptional(sessionId) let normalizedWorkspace = normalizeOptional(workspaceId) let normalizedSurface = normalizeOptional(surfaceId) return try withLockedState { state in if let normalizedSessionId, - let removed = state.sessions.removeValue(forKey: normalizedSessionId) { + let existing = state.sessions[normalizedSessionId] { + guard !hasActiveTurnMismatch(state, record: existing, turnId: turnId) else { + return nil + } + let removed = state.sessions.removeValue(forKey: normalizedSessionId) ?? existing + clearActiveSessionIfMatching(&state, removed: removed, turnId: turnId) return removed } @@ -522,11 +607,48 @@ private final class ClaudeHookSessionStore { ) else { return nil } + guard !hasActiveTurnMismatch(state, record: fallback, turnId: turnId) else { + return nil + } state.sessions.removeValue(forKey: fallback.sessionId) + clearActiveSessionIfMatching(&state, removed: fallback, turnId: turnId) return fallback } } + private func hasActiveTurnMismatch( + _ state: ClaudeHookSessionStoreFile, + record: ClaudeHookSessionRecord, + turnId: String? + ) -> Bool { + guard let workspaceId = normalizeOptional(record.workspaceId), + let active = state.activeSessionsByWorkspace[workspaceId], + active.sessionId == record.sessionId, + let activeTurnId = normalizeOptional(active.turnId), + let incomingTurnId = normalizeOptional(turnId) else { + return false + } + return activeTurnId != incomingTurnId + } + + private func clearActiveSessionIfMatching( + _ state: inout ClaudeHookSessionStoreFile, + removed: ClaudeHookSessionRecord, + turnId: String? + ) { + guard let workspaceId = normalizeOptional(removed.workspaceId), + let active = state.activeSessionsByWorkspace[workspaceId], + active.sessionId == removed.sessionId else { + return + } + if let activeTurnId = normalizeOptional(active.turnId), + let incomingTurnId = normalizeOptional(turnId), + activeTurnId != incomingTurnId { + return + } + state.activeSessionsByWorkspace.removeValue(forKey: workspaceId) + } + private func fallbackRecord( sessions: [ClaudeHookSessionRecord], workspaceId: String?, @@ -590,6 +712,9 @@ private final class ClaudeHookSessionStore { state.sessions = state.sessions.filter { _, record in record.updatedAt >= cutoff } + state.activeSessionsByWorkspace = state.activeSessionsByWorkspace.filter { _, active in + active.updatedAt >= cutoff && state.sessions[active.sessionId] != nil + } } private func normalizeSessionId(_ value: String) -> String { @@ -14720,26 +14845,59 @@ struct CMUXCLI { fallbackKind: "claude", cwd: parsedInput.cwd ) + let isClearSessionStart = isClaudeClearSessionStart(parsedInput) + let canReplaceStoppedSession = shouldReplaceStoppedClaudeSession( + sessionStore: sessionStore, + parsedInput: parsedInput, + workspaceId: workspaceId, + telemetry: telemetry + ) + let shouldPromoteActiveSession = isClearSessionStart || canReplaceStoppedSession if let sessionId = parsedInput.sessionId { + // Non-clear SessionStart can arrive late from startup/resume/compact + // after /clear, so only /clear or replacement of a stopped owner + // establishes a new active boundary. try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, pid: claudePid, - launchCommand: launchCommand + launchCommand: launchCommand, + markActive: shouldPromoteActiveSession, + turnId: parsedInput.turnId ) } - // Register PID for stale-session detection and OSC suppression, - // but don't set a visible status. "Running" only appears when the - // user submits a prompt (UserPromptSubmit) or Claude starts working - // (PreToolUse). - if let claudePid { + // Register PID for stale-session detection and OSC suppression. + // Startup/resume SessionStart remains non-visible; /clear is a + // new active boundary and must keep the sidebar Running before + // any late pre-clear Stop can write Idle. + let shouldRegisterPID = + shouldPromoteActiveSession || + shouldApplyClaudeHookVisibleMutation( + sessionStore: sessionStore, + parsedInput: parsedInput, + workspaceId: workspaceId, + telemetry: telemetry + ) + if shouldRegisterPID, let claudePid { _ = try? sendV1Command( "set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client ) } + if isClearSessionStart { + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + surfaceId: surfaceId, + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF", + pid: claudePid + ) + } print("OK") case "stop", "idle": @@ -14761,19 +14919,32 @@ struct CMUXCLI { ) sendClaudeFeedTelemetry(workspaceId: workspaceId) + guard shouldApplyClaudeHookVisibleMutation( + sessionStore: sessionStore, + parsedInput: parsedInput, + workspaceId: workspaceId, + telemetry: telemetry + ) else { + telemetry.breadcrumb("claude-hook.stop.stale") + print("OK") + return + } + // Update session with transcript summary and send completion notification. let completion = summarizeClaudeHookStop( parsedInput: parsedInput, sessionRecord: mappedSession ) - if let sessionId = parsedInput.sessionId, let completion { + if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, - lastSubtitle: completion.subtitle, - lastBody: completion.body + lastSubtitle: completion?.subtitle, + lastBody: completion?.body, + markActive: true, + allowsNewSessionReplacement: true ) } @@ -14818,6 +14989,34 @@ struct CMUXCLI { client: client ) sendClaudeFeedTelemetry(workspaceId: workspaceId) + let shouldApplyPromptSubmit = + shouldApplyClaudeHookVisibleMutation( + sessionStore: sessionStore, + parsedInput: parsedInput, + workspaceId: workspaceId, + telemetry: telemetry + ) || + shouldReplaceStoppedClaudeSession( + sessionStore: sessionStore, + parsedInput: parsedInput, + workspaceId: workspaceId, + telemetry: telemetry + ) + guard shouldApplyPromptSubmit else { + telemetry.breadcrumb("claude-hook.prompt-submit.stale") + print("OK") + return + } + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + markActive: true, + turnId: parsedInput.turnId + ) + } _ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) try setClaudeStatus( client: client, @@ -14840,6 +15039,16 @@ struct CMUXCLI { client: client ) sendClaudeFeedTelemetry(workspaceId: workspaceId) + guard shouldApplyClaudeHookVisibleMutation( + sessionStore: sessionStore, + parsedInput: parsedInput, + workspaceId: workspaceId, + telemetry: telemetry + ) else { + telemetry.breadcrumb("claude-hook.notification.stale") + print("OK") + return + } if let mappedSession, let savedBody = mappedSession.lastBody, !savedBody.isEmpty, summary.body.contains("needs your attention") || summary.body.contains("needs your input") { @@ -14905,16 +15114,31 @@ struct CMUXCLI { let consumedSession = try? sessionStore.consume( sessionId: parsedInput.sessionId, workspaceId: fallbackWorkspaceId, - surfaceId: fallbackSurfaceId + surfaceId: fallbackSurfaceId, + turnId: parsedInput.turnId ) + // consume() calls clearActiveSessionIfMatching before returning + // consumedSession, so isCurrent can treat consumedSession.sessionId + // as current only when the consumed session was the active one. if let consumedSession { let workspaceId = consumedSession.workspaceId sendClaudeFeedTelemetry(workspaceId: workspaceId) - _ = try? sendV1Command( - "clear_agent_pid claude_code --tab=\(workspaceId)\(socketPanelOption(consumedSession.surfaceId)) --clear-status", - client: client + let shouldClearVisibleState = shouldApplyClaudeHookVisibleMutation( + sessionStore: sessionStore, + sessionId: consumedSession.sessionId, + turnId: parsedInput.turnId, + workspaceId: workspaceId, + telemetry: telemetry ) - _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + if shouldClearVisibleState { + _ = try? sendV1Command( + "clear_agent_pid claude_code --tab=\(workspaceId)\(socketPanelOption(consumedSession.surfaceId)) --clear-status", + client: client + ) + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + } else { + telemetry.breadcrumb("claude-hook.session-end.stale") + } } print("OK") @@ -14943,6 +15167,16 @@ struct CMUXCLI { ) sendClaudeFeedTelemetry(workspaceId: workspaceId) let claudePid = mappedSession?.pid + guard shouldApplyClaudeHookVisibleMutation( + sessionStore: sessionStore, + parsedInput: parsedInput, + workspaceId: workspaceId, + telemetry: telemetry + ) else { + telemetry.breadcrumb("claude-hook.pre-tool-use.stale") + print("OK") + return + } // AskUserQuestion means Claude is about to ask the user something. // Save question text in session so the Notification handler can use it @@ -15052,6 +15286,79 @@ struct CMUXCLI { _ = try client.send(command: cmd) } + private func shouldApplyClaudeHookVisibleMutation( + sessionStore: ClaudeHookSessionStore, + parsedInput: ClaudeHookParsedInput, + workspaceId: String, + telemetry: CLISocketSentryTelemetry + ) -> Bool { + shouldApplyClaudeHookVisibleMutation( + sessionStore: sessionStore, + sessionId: parsedInput.sessionId, + turnId: parsedInput.turnId, + workspaceId: workspaceId, + telemetry: telemetry + ) + } + + private func shouldApplyClaudeHookVisibleMutation( + sessionStore: ClaudeHookSessionStore, + sessionId: String?, + turnId: String?, + workspaceId: String, + telemetry: CLISocketSentryTelemetry + ) -> Bool { + do { + return try sessionStore.isCurrent( + sessionId: sessionId, + workspaceId: workspaceId, + turnId: turnId + ) + } catch { + telemetry.breadcrumb( + "claude-hook.is-current.error", + data: [ + "error": String(describing: error), + "session_id": sessionId ?? "", + "workspace_id": workspaceId, + "turn_id": turnId ?? "", + ] + ) + return true + } + } + + private func shouldReplaceStoppedClaudeSession( + sessionStore: ClaudeHookSessionStore, + parsedInput: ClaudeHookParsedInput, + workspaceId: String, + telemetry: CLISocketSentryTelemetry + ) -> Bool { + do { + return try sessionStore.canReplaceActiveSession( + sessionId: parsedInput.sessionId, + workspaceId: workspaceId + ) + } catch { + telemetry.breadcrumb( + "claude-hook.can-replace-active.error", + data: [ + "error": String(describing: error), + "session_id": parsedInput.sessionId ?? "", + "workspace_id": workspaceId, + ] + ) + return false + } + } + + private func isClaudeClearSessionStart(_ parsedInput: ClaudeHookParsedInput) -> Bool { + guard let source = parsedInput.object?["source"] as? String else { + return false + } + return source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "clear" + } + private func socketPanelOption(_ surfaceId: String?) -> String { guard let surfaceId = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines), !surfaceId.isEmpty, @@ -15342,7 +15649,15 @@ struct CMUXCLI { normalizedSingleLine(redactClaudeSensitiveSpans(trimmed)), maxLength: 180 ) - return ClaudeHookParsedInput(rawObject: nil, object: nil, rawFallback: fallback, sessionId: nil, turnId: nil, cwd: nil, transcriptPath: nil) + return ClaudeHookParsedInput( + rawObject: nil, + object: nil, + rawFallback: fallback, + sessionId: nil, + turnId: nil, + cwd: nil, + transcriptPath: nil + ) } let sessionId = extractClaudeHookSessionId(from: object) @@ -15367,7 +15682,7 @@ struct CMUXCLI { for key in [ "tool_name", "turn_id", "turnId", "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", - "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", + "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", "source", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description", ] { @@ -15445,7 +15760,7 @@ struct CMUXCLI { private func claudeHookCompactFieldLimit(for key: String) -> Int { switch key { - case "tool_name", "turn_id", "turnId", "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason": + case "tool_name", "turn_id", "turnId", "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", "source": return 80 case "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description": return 240 @@ -17649,7 +17964,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } try newContent.write(toFile: configPath, atomically: true, encoding: .utf8) - print("Enabled hooks in \(configPath)") + print("Enabled codex_hooks in \(configPath)") } } } @@ -17733,17 +18048,234 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { switch action { case .codexConfigToml: let configPath = "\(configDir)/config.toml" - guard fm.fileExists(atPath: configPath) else { return } - let content = try String(contentsOfFile: configPath, encoding: .utf8) + guard fm.fileExists(atPath: configPath), + let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return } let newContent = Self.codexConfigTomlUninstallingHooksFeature(from: content) if newContent != content { try newContent.write(toFile: configPath, atomically: true, encoding: .utf8) - print("Updated hooks in \(configPath)") + print("Removed Codex hooks feature from \(configPath)") + } + } + } + } + + private static let cmuxCodexHooksFeatureBegin = + "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin" + private static let cmuxCodexHooksFeatureEnd = + "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df end" + private static let cmuxCodexHooksFeaturePreviousLinePrefix = + "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df previous line: " + private static let legacyCmuxCodexHooksFeatureBegin = "# cmux hooks codex feature begin" + private static let legacyCmuxCodexHooksFeatureEnd = "# cmux hooks codex feature end" + private static let legacyCmuxCodexHooksFeaturePreviousLinePrefix = + "# cmux hooks codex feature previous line: " + + private static func codexConfigTomlInstallingHooksFeature(in existingContent: String) -> String { + var lines = tomlLines(from: existingContent) + removeLegacyCodexHooksFeatureBlock(from: &lines) + var currentTable: String? + for index in lines.indices { + let line = lines[index] + if let tableName = tomlTableName(in: line) { + currentTable = tableName + continue + } + if (currentTable == nil || currentTable == "features"), + tomlLineDefinesCodexHooksKey(line) { + lines[index] = "codex_hooks = true" + return tomlContent(from: lines) + } + if currentTable == nil, tomlLineDefinesDottedCodexHooksKey(line) { + lines[index] = "features.codex_hooks = true" + return tomlContent(from: lines) + } + } + + if let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) { + lines.insert("codex_hooks = true", at: featuresStart + 1) + } else { + if !lines.isEmpty, lines.last?.isEmpty == false { + lines.append("") + } + lines.append("[features]") + lines.append("codex_hooks = true") + } + return tomlContent(from: lines) + } + + private static func codexConfigTomlUninstallingHooksFeature(from existingContent: String) -> String { + var lines = tomlLines(from: existingContent) + removeLegacyCodexHooksFeatureBlock(from: &lines) + var filteredLines: [String] = [] + var currentTable: String? + for line in lines { + if let tableName = tomlTableName(in: line) { + currentTable = tableName + filteredLines.append(line) + continue + } + if (currentTable == nil || currentTable == "features"), + tomlLineDefinesCodexHooksKey(line) { + continue + } + if currentTable == nil, tomlLineDefinesDottedCodexHooksKey(line) { + continue + } + filteredLines.append(line) + } + removeEmptyFeaturesTable(from: &filteredLines) + return tomlContent(from: filteredLines) + } + + private static func removeEmptyFeaturesTable(from lines: inout [String]) { + var index = 0 + while index < lines.count { + guard tomlLineIsTable("features", line: lines[index]) else { + index += 1 + continue + } + + let bodyStart = index + 1 + var bodyEnd = bodyStart + while bodyEnd < lines.count, tomlTableName(in: lines[bodyEnd]) == nil { + bodyEnd += 1 + } + + let bodyIsEmpty = lines[bodyStart.. 0, + lines[index - 1].trimmingCharacters(in: .whitespaces).isEmpty + { + lines.remove(at: index - 1) + } else if index > 0, index < lines.count, + lines[index - 1].trimmingCharacters(in: .whitespaces).isEmpty, + lines[index].trimmingCharacters(in: .whitespaces).isEmpty + { + lines.remove(at: index) + } + } + } + + private static func removeLegacyCodexHooksFeatureBlock(from lines: inout [String]) { + var index = 0 + while index < lines.count { + guard tomlLineIsLegacyCodexHooksFeatureBegin(lines[index]) else { + index += 1 + continue + } + + if let endIndex = lines[index...].firstIndex(where: { + tomlLineIsLegacyCodexHooksFeatureEnd($0) + }) { + let restoredLines = lines[index...endIndex].compactMap { line -> String? in + tomlLegacyCodexHooksFeaturePreviousLine(from: line) + } + lines.replaceSubrange(index...endIndex, with: restoredLines) + } else { + var blockEnd = index + 1 + var restoredLines: [String] = [] + if blockEnd < lines.count, + let previousLine = tomlLegacyCodexHooksFeaturePreviousLine(from: lines[blockEnd]) + { + restoredLines.append(previousLine) + blockEnd += 1 } + if blockEnd < lines.count, tomlLineIsLegacyCodexHooksFeatureSetting(lines[blockEnd]) { + blockEnd += 1 + } + lines.replaceSubrange(index.. [String] { + guard !content.isEmpty else { return [] } + var lines = content.components(separatedBy: "\n") + if content.hasSuffix("\n"), lines.last == "" { + lines.removeLast() + } + return lines + } + + private static func tomlContent(from lines: [String]) -> String { + guard !lines.isEmpty else { return "" } + return lines.joined(separator: "\n") + "\n" + } + + private static func tomlLineDefinesCodexHooksKey(_ line: String) -> Bool { + line.range( + of: #"^\s*codex_hooks\s*="#, + options: .regularExpression + ) != nil + } + + private static func tomlLineDefinesDottedCodexHooksKey(_ line: String) -> Bool { + line.range( + of: #"^\s*features\s*\.\s*codex_hooks\s*="#, + options: .regularExpression + ) != nil + } + + private static func tomlLineIsLegacyCodexHooksFeatureBegin(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed == cmuxCodexHooksFeatureBegin || trimmed == legacyCmuxCodexHooksFeatureBegin + } + + private static func tomlLineIsLegacyCodexHooksFeatureEnd(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed == cmuxCodexHooksFeatureEnd || trimmed == legacyCmuxCodexHooksFeatureEnd + } + + private static func tomlLegacyCodexHooksFeaturePreviousLine(from line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix(cmuxCodexHooksFeaturePreviousLinePrefix) { + return String(trimmed.dropFirst(cmuxCodexHooksFeaturePreviousLinePrefix.count)) + } + if trimmed.hasPrefix(legacyCmuxCodexHooksFeaturePreviousLinePrefix) { + return String(trimmed.dropFirst(legacyCmuxCodexHooksFeaturePreviousLinePrefix.count)) + } + return nil + } + + private static func tomlLineIsLegacyCodexHooksFeatureSetting(_ line: String) -> Bool { + line.range( + of: #"^\s*hooks\s*=\s*true\s*(#.*)?$"#, + options: .regularExpression + ) != nil || line.range( + of: #"^\s*features\s*\.\s*hooks\s*=\s*true\s*(#.*)?$"#, + options: .regularExpression + ) != nil + } + + private static func tomlLineIsTable(_ name: String, line: String) -> Bool { + let escapedName = NSRegularExpression.escapedPattern(for: name) + return line.range( + of: #"^\s*\[\s*"# + escapedName + #"\s*\]\s*(#.*)?$"#, + options: .regularExpression + ) != nil + } + + private static func tomlTableName(in line: String) -> String? { + let pattern = #"^\s*\[\s*([A-Za-z0-9_.-]+)\s*\]\s*(#.*)?$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: line, + range: NSRange(line.startIndex..., in: line) + ), + match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: line) else { + return nil + } + return String(line[range]) + } + // MARK: Generic hook handler private func resolvedAgentHookSessionId( diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 098ad7aa2365..34b9779e5ad0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -334,7 +334,6 @@ B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */; }; B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */; }; B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */; }; - B9000066A1B2C3D4E5F60719 /* CMUXCLI+CodexConfigToml.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000067A1B2C3D4E5F60719 /* CMUXCLI+CodexConfigToml.swift */; }; B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */; }; @@ -361,7 +360,6 @@ C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */; }; D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */; }; - D3622010A1B2C3D4E5F60718 /* AuthManagerBrowserSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3622011A1B2C3D4E5F60718 /* AuthManagerBrowserSignInTests.swift */; }; E30750000000000000000006 /* CmuxConfigNamedColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; @@ -568,7 +566,6 @@ 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserArrowKeyForwardingTests.swift; sourceTree = ""; }; - D3622011A1B2C3D4E5F60718 /* AuthManagerBrowserSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManagerBrowserSignInTests.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMClient.swift; sourceTree = ""; }; 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMClientSocketCommands.swift; sourceTree = ""; }; @@ -829,7 +826,6 @@ B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+InstallPreview.swift"; sourceTree = ""; }; B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+HermesAgentHooks.swift"; sourceTree = ""; }; B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookDefinitions.swift"; sourceTree = ""; }; - B9000067A1B2C3D4E5F60719 /* CMUXCLI+CodexConfigToml.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexConfigToml.swift"; sourceTree = ""; }; B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AmpExtension.swift"; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Themes.swift"; sourceTree = ""; }; @@ -1355,7 +1351,6 @@ B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */, B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */, B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */, - B9000067A1B2C3D4E5F60719 /* CMUXCLI+CodexConfigToml.swift */, B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */, B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */, B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */, @@ -1413,7 +1408,6 @@ A5008382 /* CommandPaletteSearchEngineTests.swift */, A50019B3 /* SettingsSearchIndexTests.swift */, D36090010000000000000004 /* SettingsWindowPresenterTests.swift */, - D3622011A1B2C3D4E5F60718 /* AuthManagerBrowserSignInTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, @@ -1994,7 +1988,6 @@ B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */, B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */, B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */, - B9000066A1B2C3D4E5F60719 /* CMUXCLI+CodexConfigToml.swift in Sources */, B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */, B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */, @@ -2093,7 +2086,6 @@ A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, A50019B2 /* SettingsSearchIndexTests.swift in Sources */, D36090010000000000000003 /* SettingsWindowPresenterTests.swift in Sources */, - D3622010A1B2C3D4E5F60718 /* AuthManagerBrowserSignInTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, diff --git a/Resources/bin/claude b/Resources/bin/claude index 0e25bff84eab..44431169f0ec 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -340,6 +340,37 @@ claude_interactive_entry_flag() { return 1 } +claude_passthrough_option_flag() { + case "$1" in + --help|-h|--version|-v) + return 0 + ;; + esac + return 1 +} + +claude_builtin_command_name() { + case "$1" in + agents|auth|auto-mode|config|api-key|daemon|doctor|install|mcp|\ + experimental-next|plugin|plugins|project|rc|remote-control|setup-token|\ + ultrareview|update|upgrade) + return 0 + ;; + esac + return 1 +} + +claude_command_like_invocation() { + local candidate="$1" + [[ -n "$candidate" ]] || return 1 + case "$candidate" in + *[![:alnum:]_-]*) + return 1 + ;; + esac + claude_builtin_command_name "$candidate" +} + should_inject_claude_hooks() { (( $# == 0 )) && return 0 @@ -355,6 +386,9 @@ should_inject_claude_hooks() { return 0 ;; -*) + if claude_passthrough_option_flag "$arg"; then + return 1 + fi if claude_interactive_entry_flag "$arg"; then return 0 fi @@ -364,7 +398,10 @@ should_inject_claude_hooks() { continue ;; *) - return 1 + if claude_command_like_invocation "$arg"; then + return 1 + fi + return 0 ;; esac done @@ -372,8 +409,8 @@ should_inject_claude_hooks() { return 0 } -# Only inject hooks for Claude session entrypoints. Other command-like -# invocations pass through so new Claude subcommands do not need cmux changes. +# Only inject hooks for Claude session entrypoints. Command-like invocations +# pass through so Claude subcommands do not receive session/hook flags. if ! should_inject_claude_hooks "$@"; then clear_inherited_claude_auth_selection_env exec_real_claude_passthrough "$@" diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 02c71a3f234e..04901bbc7eba 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -27,46 +27,6 @@ final class MainWindowHostingView: NSHostingView { } } -class FirstMouseGatedHostingView: NSHostingView { - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) - } - - override func hitTest(_ point: NSPoint) -> NSView? { - if shouldCaptureInactiveFirstMouse(at: point) { - return self - } - return super.hitTest(point) - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - PaneFirstClickFocusSettings.isEnabled() - } - - func shouldCaptureInactiveFirstMouse(at point: NSPoint) -> Bool { - let localPoint = superview.map { convert(point, from: $0) } ?? point - return window?.isKeyWindow != true && - !PaneFirstClickFocusSettings.isEnabled() && - bounds.contains(localPoint) - } -} - -final class FirstMouseGatedPassThroughHostingView: FirstMouseGatedHostingView { - override func hitTest(_ point: NSPoint) -> NSView? { - shouldCaptureInactiveFirstMouse(at: point) ? self : nil - } -} - -struct FirstMouseGatedHostingOverlay: NSViewRepresentable { - func makeNSView(context: Context) -> FirstMouseGatedPassThroughHostingView { - FirstMouseGatedPassThroughHostingView(rootView: AnyView(EmptyView())) - } - - func updateNSView(_ nsView: FirstMouseGatedPassThroughHostingView, context: Context) { - nsView.rootView = AnyView(EmptyView()) - } -} - @MainActor final class CmuxMainWindow: NSWindow { private var isSoftHiddenForVisibilityController = false diff --git a/Sources/App/SessionSnapshotDebugBenchmark.swift b/Sources/App/SessionSnapshotDebugBenchmark.swift index 53375a3fe9aa..8a145653d811 100644 --- a/Sources/App/SessionSnapshotDebugBenchmark.swift +++ b/Sources/App/SessionSnapshotDebugBenchmark.swift @@ -7,7 +7,8 @@ enum SessionSnapshotDebugBenchmark { includeScrollback: Bool, persist: Bool, buildSnapshot: (Bool) -> AppSessionSnapshot?, - persistSnapshot: (AppSessionSnapshot?) -> Void + persistedGeometryData: (AppSessionSnapshot?) -> Data?, + persistSnapshot: (AppSessionSnapshot?, Data?) -> Void ) -> [String: Any] { let buildStart = ProcessInfo.processInfo.systemUptime let snapshot = buildSnapshot(includeScrollback) @@ -15,8 +16,9 @@ enum SessionSnapshotDebugBenchmark { var persistMs: Double? if persist { + let geometryData = persistedGeometryData(snapshot) let persistStart = ProcessInfo.processInfo.systemUptime - persistSnapshot(snapshot) + persistSnapshot(snapshot, geometryData) persistMs = elapsedMs(since: persistStart) } diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index d1ac4455ba9c..8cdd60bdc85b 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -58,10 +58,6 @@ func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { return false } -func isBrowserReturnOrEnterKeyCode(_ keyCode: UInt16) -> Bool { - keyCode == 36 || keyCode == 76 -} - func shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: UInt16, firstResponderIsBrowser: Bool, @@ -70,7 +66,7 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown( ) -> Bool { guard firstResponderIsBrowser else { return false } guard !firstResponderHasMarkedText else { return false } - guard isBrowserReturnOrEnterKeyCode(keyCode) else { return false } + guard keyCode == 36 || keyCode == 76 else { return false } // Keep browser Return forwarding narrow: only plain/Shift Return should be // treated as submit-intent. Command-modified Return is reserved for app shortcuts // like Toggle Pane Zoom (Cmd+Shift+Enter). diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 283e00303ec1..a3c88b14e13a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -654,25 +654,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let display: SessionDisplaySnapshot? } - private nonisolated static let primaryMainWindowFrameAutosaveName: NSWindow.FrameAutosaveName = - "cmux.mainWindow.primary" - private nonisolated static let mainWindowFrameAutosaveNamePrefix = "cmux.mainWindow" nonisolated static let persistedWindowGeometrySchemaVersion = 2 private nonisolated static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v2" #if DEBUG - nonisolated static var debugPrimaryMainWindowFrameAutosaveName: NSWindow.FrameAutosaveName { - primaryMainWindowFrameAutosaveName - } nonisolated static var debugPersistedWindowGeometryDefaultsKey: String { persistedWindowGeometryDefaultsKey } - nonisolated static func debugRemoveLegacyPersistedWindowGeometry( - defaults: UserDefaults = .standard - ) { - removeLegacyPersistedWindowGeometry(defaults: defaults) - } #endif private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ - "cmux.session.lastWindowGeometry.v1", - persistedWindowGeometryDefaultsKey, + "cmux.session.lastWindowGeometry.v1" ] weak var tabManager: TabManager? @@ -2525,6 +2513,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent startupSessionSnapshot = SessionPersistenceStore.load() } + private func persistedWindowGeometry(defaults: UserDefaults = .standard) -> PersistedWindowGeometry? { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { + return nil + } + guard let payload = Self.decodedPersistedWindowGeometryData(data) else { + defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) + return nil + } + return payload + } + + private func persistWindowGeometry( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot?, + defaults: UserDefaults = .standard + ) { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { + return + } + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) + } + + private nonisolated static func encodedPersistedWindowGeometryData( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot? + ) -> Data? { + guard let frame else { return nil } + let payload = PersistedWindowGeometry( + version: persistedWindowGeometrySchemaVersion, + frame: frame, + display: display + ) + return try? JSONEncoder().encode(payload) + } + nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), payload.version == persistedWindowGeometrySchemaVersion else { @@ -2539,6 +2564,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } } + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) + ) + } + private func currentDisplayGeometries() -> (available: [SessionDisplayGeometry], fallback: SessionDisplayGeometry?) { let available = NSScreen.screens.map { screen in SessionDisplayGeometry( @@ -2557,6 +2590,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return (available, fallback) } + private func resolvedPersistedWindowGeometryFrame() -> NSRect? { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + return Self.resolvedWindowFrame( + from: fallbackGeometry?.frame, + display: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) + } + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { guard !didAttemptStartupSessionRestore else { return } didAttemptStartupSessionRestore = true @@ -2579,6 +2623,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent to: primaryContext, window: primaryWindow ) + } else { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackGeometry?.frame, + fallbackDisplaySnapshot: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) { + primaryWindow.setFrame(restoredFrame, display: true) + } } if let startupSnapshot { @@ -3265,17 +3321,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent persistSessionSnapshot( nil, removeWhenEmpty: removeWhenEmpty, + persistedGeometryData: nil, synchronously: writeSynchronously ) return false } + let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in + Self.encodedPersistedWindowGeometryData( + frame: primaryWindow.frame, + display: primaryWindow.display + ) + } + #if DEBUG debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) #endif persistSessionSnapshot( snapshot, removeWhenEmpty: false, + persistedGeometryData: persistedGeometryData, synchronously: writeSynchronously ) return true @@ -3292,10 +3357,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent buildSnapshot: { [self] includeScrollback in buildSessionSnapshot(includeScrollback: includeScrollback) }, - persistSnapshot: { [self] snapshot in + persistedGeometryData: { snapshot in + snapshot?.windows.first.flatMap { primaryWindow in + Self.encodedPersistedWindowGeometryData( + frame: primaryWindow.frame, + display: primaryWindow.display + ) + } + }, + persistSnapshot: { [self] snapshot, persistedGeometryData in persistSessionSnapshot( snapshot, removeWhenEmpty: false, + persistedGeometryData: persistedGeometryData, synchronously: true ) } @@ -3497,12 +3571,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func persistSessionSnapshot( _ snapshot: AppSessionSnapshot?, removeWhenEmpty: Bool, + persistedGeometryData: Data?, synchronously: Bool ) { - guard snapshot != nil || removeWhenEmpty else { return } + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } let writeBlock = { Self.removeLegacyPersistedWindowGeometry() + if let persistedGeometryData { + UserDefaults.standard.set( + persistedGeometryData, + forKey: Self.persistedWindowGeometryDefaultsKey + ) + } if let snapshot { _ = SessionPersistenceStore.save(snapshot) } else if removeWhenEmpty { @@ -6862,61 +6943,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - private func mainWindowFrameAutosaveName(windowId: UUID) -> NSWindow.FrameAutosaveName { - if !hasLivePrimaryMainWindowFrameAutosaveOwner() { - return Self.primaryMainWindowFrameAutosaveName - } - return "\(Self.mainWindowFrameAutosaveNamePrefix).\(windowId.uuidString)" - } - - private func hasLivePrimaryMainWindowFrameAutosaveOwner(excluding excludedWindow: NSWindow? = nil) -> Bool { - NSApp.windows.contains { window in - if let excludedWindow, window === excludedWindow { - return false - } - return window.frameAutosaveName == Self.primaryMainWindowFrameAutosaveName - } - } - - private func promotePrimaryMainWindowFrameAutosaveNameIfNeeded(excluding excludedWindow: NSWindow? = nil) { - guard !hasLivePrimaryMainWindowFrameAutosaveOwner(excluding: excludedWindow) else { return } - guard let survivor = sortedMainWindowContextsForSessionSnapshot() - .compactMap({ resolvedWindow(for: $0) }) - .first(where: { window in - guard let excludedWindow else { return true } - return window !== excludedWindow - }) else { - return - } - - let previousAutosaveName = survivor.frameAutosaveName - guard previousAutosaveName != Self.primaryMainWindowFrameAutosaveName else { return } - let survivorFrame = survivor.frame - // Registering an autosave name can reload its saved frame; seed it with the survivor first. - survivor.saveFrame(usingName: Self.primaryMainWindowFrameAutosaveName) - guard survivor.setFrameAutosaveName(Self.primaryMainWindowFrameAutosaveName) else { return } - if survivor.frame != survivorFrame { - survivor.setFrame(survivorFrame, display: false) - } - survivor.saveFrame(usingName: Self.primaryMainWindowFrameAutosaveName) - - if !previousAutosaveName.isEmpty, - previousAutosaveName.hasPrefix("\(Self.mainWindowFrameAutosaveNamePrefix).") { - NSWindow.removeFrame(usingName: previousAutosaveName) - } - } - - private func removeEphemeralMainWindowFrameAutosaveNameIfNeeded(_ window: NSWindow) { - let autosaveName = window.frameAutosaveName - guard !autosaveName.isEmpty, - autosaveName != Self.primaryMainWindowFrameAutosaveName, - autosaveName.hasPrefix("\(Self.mainWindowFrameAutosaveNamePrefix).") else { - return - } - _ = window.setFrameAutosaveName("") - NSWindow.removeFrame(usingName: autosaveName) - } - @discardableResult func createMainWindow( initialWorkspaceTitle: String? = nil, @@ -7003,17 +7029,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let shouldTemporarilyDisallowFullScreenTiling = sessionWindowSnapshot == nil && sourceWindowIsNativeFullScreen let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) + let persistedGeometryFrame = (restoredFrame == nil && sourceWindow == nil) + ? resolvedPersistedWindowGeometryFrame() + : nil let initialRect: NSRect if restoredFrame == nil, let existingFrame { // Convert frame rect to content rect so the new window matches the // source window's actual size (frame includes titlebar insets). initialRect = NSWindow.contentRect(forFrameRect: existingFrame, styleMask: styleMask) - } else if let explicitInitialFrame = restoredFrame { + } else if let explicitInitialFrame = restoredFrame ?? persistedGeometryFrame { initialRect = NSWindow.contentRect(forFrameRect: explicitInitialFrame, styleMask: styleMask) } else { initialRect = CmuxMainWindow.defaultContentRect(styleMask: styleMask) } - let frameAutosaveName = mainWindowFrameAutosaveName(windowId: windowId) let window = CmuxMainWindow( contentRect: initialRect, @@ -7041,16 +7069,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // a movable/resizable window. Empty titlebar drags are routed through // WindowDragHandleView instead of background dragging. window.isMovable = true - let didRegisterFrameAutosaveName = window.setFrameAutosaveName(frameAutosaveName) - let appKitSavedFrameApplied = didRegisterFrameAutosaveName - && restoredFrame == nil - && sourceWindow == nil - && window.setFrameUsingName(frameAutosaveName, force: true) - let explicitInitialFrame = restoredFrame + let explicitInitialFrame = restoredFrame ?? persistedGeometryFrame if let explicitInitialFrame { window.setFrame(explicitInitialFrame, display: false) - } else if appKitSavedFrameApplied { - lastCascadePoint = NSPoint(x: window.frame.minX, y: window.frame.maxY) } else if let sourceWindow { positionNewMainWindow(window, relativeTo: sourceWindow) } else { @@ -7113,7 +7134,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.setFrame(explicitInitialFrame, display: true) #if DEBUG cmuxDebugLog( - "mainWindow.initialFrameApplied source=sessionSnapshot window=\(windowId.uuidString.prefix(8)) " + + "mainWindow.initialFrameApplied source=\(restoredFrame == nil ? "persistedGeometry" : "sessionSnapshot") window=\(windowId.uuidString.prefix(8)) " + "applied={\(debugNSRectDescription(window.frame))}" ) #endif @@ -13533,15 +13554,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let frame = window.frame lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - let shouldReleasePrimaryFrameAutosaveName = - window.frameAutosaveName == Self.primaryMainWindowFrameAutosaveName - removeEphemeralMainWindowFrameAutosaveNameIfNeeded(window) + // Keep geometry available as a fallback for the next window placement. + if !isTerminatingApp { + persistWindowGeometry(from: window) + } guard let removed = unregisterMainWindowContext(for: window) else { return } - if shouldReleasePrimaryFrameAutosaveName { - _ = window.setFrameAutosaveName("") - } - promotePrimaryMainWindowFrameAutosaveNameIfNeeded(excluding: window) publishCmuxWindowLifecycle(name: "window.closed", windowId: removed.windowId, origin: "appkit_close") commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) @@ -13888,24 +13906,11 @@ private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? private var cmuxFirstResponderGuardHitViewContext: NSView? private var cmuxFirstResponderGuardContextWindowNumber: Int? private var cmuxBrowserReturnForwardingDepth = 0 -private var cmuxBrowserForwardedReturnEvent: CmuxForwardedBrowserReturnEvent? private var cmuxBrowserArrowForwardingDepth = 0 private var cmuxCommandPaletteArrowForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 -private struct CmuxForwardedBrowserReturnEvent: Equatable { - let keyCode: UInt16 - let timestamp: TimeInterval - let charactersIgnoringModifiers: String? - - init(_ event: NSEvent) { - keyCode = event.keyCode - timestamp = event.timestamp - charactersIgnoringModifiers = event.charactersIgnoringModifiers - } -} - @discardableResult func cmuxWithWindowFirstResponderBypass(_ body: () -> T) -> T { cmuxWindowFirstResponderBypassDepth += 1 @@ -14377,12 +14382,6 @@ private extension NSWindow { let firstResponderWebView = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0, in: self, event: event) } - let firstResponderEmbeddedWebView = self.firstResponder.flatMap { - Self.cmuxOwningEmbeddedWebView(for: $0) - } - let browserKeyDownTarget: NSResponder? = firstResponderWebView - ?? firstResponderEmbeddedWebView - ?? self.firstResponder let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) let firstResponderIsCommandPaletteFieldEditor = Self.cmuxCommandPaletteOwnsFieldEditor( self.firstResponder as? NSTextView, @@ -14415,14 +14414,6 @@ private extension NSWindow { } if let ghosttyView = firstResponderGhosttyView { - if ghosttyView.shouldRouteTextInputKeyEquivalentToKeyDown(event) { - ghosttyView.keyDown(with: event) -#if DEBUG - cmuxDebugLog(" → terminal text-input key equivalent routed to keyDown") -#endif - return true - } - // If the IME is composing and the key has no Cmd modifier, don't intercept — // let it flow through normal AppKit event dispatch so the input method can // process it. Cmd-based shortcuts should still work during composition since @@ -14474,41 +14465,29 @@ private extension NSWindow { } // Web forms rely on Return/Enter flowing through keyDown. If the original - // NSWindow.performKeyEquivalent handles Enter as an unclaimed key equivalent, - // AppKit can play the alert sound even though WebKit submits the form. Route - // Return/Enter directly to the current WebKit first responder and mark handled. + // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches + // WebKit. Route Return/Enter directly to the current first responder and + // mark handled to avoid the AppKit alert sound path. if shouldDispatchBrowserReturnViaFirstResponderKeyDown( keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil || firstResponderEmbeddedWebView != nil, + firstResponderIsBrowser: firstResponderWebView != nil, firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. - // Consume only the exact forwarded Return/Enter event. A different nested - // Return should continue through normal dispatch so AppKit can keep its own - // invalid-input feedback. - let forwardedReturnEvent = CmuxForwardedBrowserReturnEvent(event) + // On re-entry, fall back to normal dispatch to avoid an infinite loop. if cmuxBrowserReturnForwardingDepth > 0 { - guard cmuxBrowserForwardedReturnEvent == forwardedReturnEvent else { - return false - } #if DEBUG - cmuxDebugLog(" → browser Return/Enter reentry; consumed during forwarded keyDown") + cmuxDebugLog(" → browser Return/Enter reentry; using normal dispatch") #endif - return true + return cmux_performKeyEquivalent(with: event) } cmuxBrowserReturnForwardingDepth += 1 - cmuxBrowserForwardedReturnEvent = forwardedReturnEvent - defer { - cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) - if cmuxBrowserReturnForwardingDepth == 0 { - cmuxBrowserForwardedReturnEvent = nil - } - } + defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) } #if DEBUG - cmuxDebugLog(" → browser Return/Enter routed to browser keyDown target") + cmuxDebugLog(" → browser Return/Enter routed to firstResponder.keyDown") #endif - browserKeyDownTarget?.keyDown(with: event) + self.firstResponder?.keyDown(with: event) return true } @@ -14517,7 +14496,7 @@ private extension NSWindow { // keyDown. Route those arrows directly to the first responder instead. if shouldDispatchBrowserArrowViaFirstResponderKeyDown( keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil || firstResponderEmbeddedWebView != nil, + firstResponderIsBrowser: firstResponderWebView != nil, firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { @@ -14527,14 +14506,14 @@ private extension NSWindow { #if DEBUG cmuxDebugLog(" → browser arrow reentry; using normal dispatch") #endif - return false + return cmux_performKeyEquivalent(with: event) } cmuxBrowserArrowForwardingDepth += 1 defer { cmuxBrowserArrowForwardingDepth = max(0, cmuxBrowserArrowForwardingDepth - 1) } #if DEBUG - cmuxDebugLog(" → browser arrow routed to browser keyDown target") + cmuxDebugLog(" → browser arrow routed to firstResponder.keyDown") #endif - browserKeyDownTarget?.keyDown(with: event) + self.firstResponder?.keyDown(with: event) return true } @@ -14712,84 +14691,6 @@ private extension NSWindow { return nil } - private static func cmuxOwningEmbeddedWebView(for responder: NSResponder) -> WKWebView? { - if let webView = responder as? WKWebView { - return webView - } - - if let webView = cmuxOwningEmbeddedWebViewFromFieldEditorOwner(responder) { - return webView - } - - if let view = responder as? NSView, - let webView = cmuxOwningEmbeddedWebView(for: view) { - return webView - } - - // WebKit's active text responder is often an internal descendant or next - // responder rather than the WKWebView itself. Walk the AppKit responder - // chain so ASWebAuthenticationSession-hosted forms get the same Return - // handling as cmux-owned browser panels. - var current = responder.nextResponder - while let next = current { - if let webView = next as? WKWebView { - return webView - } - if let view = next as? NSView, - let webView = cmuxOwningEmbeddedWebView(for: view) { - return webView - } - current = next.nextResponder - } - - return nil - } - - private static func cmuxOwningEmbeddedWebViewFromFieldEditorOwner(_ responder: NSResponder) -> WKWebView? { - guard let fieldEditor = responder as? NSTextView, - fieldEditor.isFieldEditor, - let ownerView = cmuxFieldEditorOwnerView(fieldEditor) else { - return nil - } - - if let webView = cmuxOwningEmbeddedWebView(for: ownerView) { - return webView - } - - var current = ownerView.nextResponder - while let next = current { - if next === ownerView { - break - } - if let webView = next as? WKWebView { - return webView - } - if let view = next as? NSView, - let webView = cmuxOwningEmbeddedWebView(for: view) { - return webView - } - current = next.nextResponder - } - - return nil - } - - private static func cmuxOwningEmbeddedWebView(for view: NSView) -> WKWebView? { - if let webView = view as? WKWebView { - return webView - } - - var current: NSView? = view.superview - while let candidate = current { - if let webView = candidate as? WKWebView { - return webView - } - current = candidate.superview - } - - return nil - } - private static func cmuxAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { var current: NSView? = view while let candidate = current { diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 3428c167fc21..842940a29404 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -8,11 +8,6 @@ import StackAuth import Security #endif -nonisolated private let authLogger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.cmuxterm.app", - category: "AuthManager" -) - private final class AuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { static let shared = AuthPresentationContext() @@ -68,6 +63,8 @@ protocol StackAuthTokenStoreProtocol: TokenStoreProtocol, Sendable { func clear() async func currentAccessToken() async -> String? func currentRefreshToken() async -> String? + @discardableResult + func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool } extension StackAuthTokenStoreProtocol { @@ -86,6 +83,34 @@ extension StackAuthTokenStoreProtocol { func currentRefreshToken() async -> String? { await getStoredRefreshToken() } + + @discardableResult + func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool { + let storedAccessToken = await currentAccessToken() + let storedRefreshToken = await currentRefreshToken() + guard authTokenSnapshotMatches( + currentAccessToken: storedAccessToken, + currentRefreshToken: storedRefreshToken, + expectedAccessToken: accessToken, + expectedRefreshToken: refreshToken + ) else { + return false + } + await clear() + return true + } +} + +private func authTokenSnapshotMatches( + currentAccessToken: String?, + currentRefreshToken: String?, + expectedAccessToken: String?, + expectedRefreshToken: String? +) -> Bool { + if let expectedRefreshToken { + return currentRefreshToken == expectedRefreshToken + } + return currentRefreshToken == nil && currentAccessToken == expectedAccessToken } protocol AuthClientProtocol: Sendable { @@ -151,7 +176,6 @@ final class AuthManager: ObservableObject { private let tokenStore: any StackAuthTokenStoreProtocol private let settingsStore: AuthSettingsStore private let urlOpener: (URL) -> Void - private let usesSystemWebAuthenticationSession: () -> Bool /// Resolves when the on-launch session restoration finishes (success or failure). /// Any probe that needs a definitive `isAuthenticated` value must `await` this @@ -164,16 +188,12 @@ final class AuthManager: ObservableObject { client: (any AuthClientProtocol)? = nil, tokenStore: any StackAuthTokenStoreProtocol = KeychainStackTokenStore(), settingsStore: AuthSettingsStore = AuthSettingsStore(), - urlOpener: ((URL) -> Void)? = nil, - usesSystemWebAuthenticationSession: (() -> Bool)? = nil + urlOpener: ((URL) -> Void)? = nil ) { self.tokenStore = tokenStore self.settingsStore = settingsStore self.client = client ?? Self.makeDefaultClient(tokenStore: tokenStore) self.urlOpener = urlOpener ?? Self.defaultURLOpener - self.usesSystemWebAuthenticationSession = usesSystemWebAuthenticationSession ?? { - Self.shouldUseSystemWebAuthenticationSession() - } let cachedUser = settingsStore.cachedUser() self.currentUser = cachedUser self.selectedTeamID = settingsStore.selectedTeamID @@ -193,52 +213,32 @@ final class AuthManager: ObservableObject { await bootstrapTask.value } + private var loginPollTask: Task? private var webAuthSession: ASWebAuthenticationSession? + private var nextBrowserSignInAttemptID: UInt64 = 0 + private var activeBrowserSignInAttemptID: UInt64? + private var signOutCancelledBrowserSignInAttemptID: UInt64? + private var authMutationGeneration: UInt64 = 0 + private var currentAuthMutationKind: AuthMutationKind? - func beginSignIn() { - beginSignIn(keepLoadingForExternalBrowser: false) + private enum AuthMutationKind { + case restore + case signIn + case signOut } - private func beginSignIn(keepLoadingForExternalBrowser: Bool) { - webAuthSession?.cancel() - webAuthSession = nil - - if usesSystemWebAuthenticationSession() { - isLoading = true - beginSystemWebAuthenticationSession() - return - } - - let signInURL = AuthEnvironment.signInURL() - authLog("beginSignIn: opening external browser url=\(signInURL.absoluteString)") - urlOpener(signInURL) - - if keepLoadingForExternalBrowser { - isLoading = true - } else if isLoading { - isLoading = false - } + #if DEBUG + func markBrowserSignInLoadingForTesting() { + _ = startBrowserSignInAttempt() } + #endif - static func shouldUseSystemWebAuthenticationSession( - environment: [String: String] = ProcessInfo.processInfo.environment - ) -> Bool { - let value = environment["CMUX_AUTH_USE_ASWEB_AUTH_SESSION"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - switch value { - case "0", "false", "no": - return false - case "1", "true", "yes": - return true - default: - // ASWebAuthenticationSession scopes callbacks to the initiating - // session even when parallel debug apps share the same URL scheme. - return true - } - } + func beginSignIn() { + loginPollTask?.cancel() + webAuthSession?.cancel() + webAuthSession = nil + let attemptID = startBrowserSignInAttempt() - private func beginSystemWebAuthenticationSession() { let signInURL = AuthEnvironment.signInURL() let callbackScheme = AuthEnvironment.callbackScheme @@ -248,19 +248,32 @@ final class AuthManager: ObservableObject { ) { [weak self] callbackURL, error in Task { @MainActor [weak self] in guard let self else { return } + guard self.isCurrentBrowserSignInAttempt(attemptID) else { return } defer { - self.isLoading = false - self.webAuthSession = nil + self.finishBrowserSignInAttempt(attemptID) } if let error { - authLogger.error("ASWebAuthenticationSession failed: \(String(describing: error), privacy: .private)") + self.authLog("auth.webauth failed: \(error)") return } guard let callbackURL else { return } + let callbackPayload = AuthCallbackRouter.callbackPayload(from: callbackURL) do { try await self.handleCallbackURL(callbackURL) + if self.signOutCancelledBrowserSignInAttemptID == attemptID, + self.activeBrowserSignInAttemptID == nil, + let callbackPayload { + let didClear = await self.tokenStore.clearTokensIfCurrent( + accessToken: callbackPayload.accessToken, + refreshToken: callbackPayload.refreshToken + ) + if didClear { + self.clearSessionState(clearSelectedTeam: true) + } + self.signOutCancelledBrowserSignInAttemptID = nil + } } catch { - authLogger.error("ASWebAuthenticationSession callback failed: \(String(describing: error), privacy: .private)") + self.authLog("auth.webauth callback failed: \(error)") } } } @@ -270,24 +283,20 @@ final class AuthManager: ObservableObject { if session.start() { webAuthSession = session } else { - authLogger.warning("ASWebAuthenticationSession start returned false") - isLoading = false + authLog("auth.webauth: session.start() returned false") + finishBrowserSignInAttempt(attemptID) } } - /// Starts sign-in and awaits the user's + /// Starts the ASWebAuthenticationSession popup and awaits the user's /// completion by observing isAuthenticated AND isLoading. Resolves when - /// authenticated, when the sign-in attempt settles unsuccessfully - /// (popup dismissed/cancelled/error), or when the deadline elapses. No polling + /// authenticated, when the sign-in attempt settles unsuccessfully (popup + /// dismissed/cancelled/error), or when the deadline elapses. No polling /// — the $isAuthenticated / $isLoading AsyncPublishers drive the wait. func beginSignInAndAwait(timeout: TimeInterval) async -> Bool { if isAuthenticated { return true } - beginSignIn(keepLoadingForExternalBrowser: true) - let signedIn = await waitForSignInSettled(timeout: timeout) - if !signedIn && isLoading && !isAuthenticated { - isLoading = false - } - return signedIn + beginSignIn() + return await waitForSignInSettled(timeout: timeout) } /// Signs out and awaits the state to flip. signOut() is already async and @@ -449,12 +458,8 @@ final class AuthManager: ObservableObject { guard let payload = AuthCallbackRouter.callbackPayload(from: url) else { throw AuthManagerError.invalidCallback } + let mutationGeneration = beginAuthMutation(.signIn) - // System web-auth callbacks arrive from the session completion handler, - // so there is no active presentation to cancel on this shared callback path. - // The external-browser path never creates an ASWebAuthenticationSession. - webAuthSession = nil - lastKnownAccessToken = nil isLoading = true defer { isLoading = false } @@ -462,8 +467,31 @@ final class AuthManager: ObservableObject { accessToken: payload.accessToken, refreshToken: payload.refreshToken ) - try await refreshSession() + guard await keepAuthMutationIfCurrent( + mutationGeneration, + accessToken: payload.accessToken, + refreshToken: payload.refreshToken + ) else { + return + } lastKnownAccessToken = payload.accessToken + do { + try await refreshSession(expectedAuthMutation: mutationGeneration) + } catch AuthManagerError.invalidCallback where !isCurrentAuthMutation(mutationGeneration) { + _ = await keepAuthMutationIfCurrent( + mutationGeneration, + accessToken: payload.accessToken, + refreshToken: payload.refreshToken + ) + return + } + guard await keepAuthMutationIfCurrent( + mutationGeneration, + accessToken: payload.accessToken, + refreshToken: payload.refreshToken + ) else { + return + } didCompleteBrowserSignIn = true } @@ -489,6 +517,7 @@ final class AuthManager: ObservableObject { } await tokenStore.setTokens(accessToken: resolvedAccess, refreshToken: refreshToken) + lastKnownAccessToken = resolvedAccess do { try await refreshSession() authLog("seedTokensFromCLI: success user=\(currentUser?.primaryEmail ?? "nil")") @@ -584,6 +613,7 @@ final class AuthManager: ObservableObject { throw AuthManagerError.invalidCallback } await tokenStore.setTokens(accessToken: accessToken, refreshToken: refreshToken) + lastKnownAccessToken = accessToken // Fetch user info directly with the access token let userJSON = try await Self.stackAPIRequest( @@ -631,22 +661,35 @@ final class AuthManager: ObservableObject { } func signOut() async { - webAuthSession?.cancel() - webAuthSession = nil - isLoading = false - lastKnownAccessToken = nil + let signOutGeneration = beginAuthMutation(.signOut) + cancelBrowserSignInForSignOut() + let accessTokenAtSignOut = await tokenStore.currentAccessToken() + let refreshTokenAtSignOut = await tokenStore.currentRefreshToken() try? await client.signOut() - await tokenStore.clear() + guard isCurrentAuthMutation(signOutGeneration) else { return } + await tokenStore.clearTokensIfCurrent( + accessToken: accessTokenAtSignOut, + refreshToken: refreshTokenAtSignOut + ) + guard isCurrentAuthMutation(signOutGeneration) else { return } clearSessionState(clearSelectedTeam: true) } - /// Cached access token for fast synchronous reads (no actor hops). + /// Cached access token for fast reads after sign-in or session restoration. private var lastKnownAccessToken: String? func getAccessToken() async throws -> String { + await awaitBootstrapped() + guard isAuthenticated else { + throw AuthManagerError.missingAccessToken + } if let cached = lastKnownAccessToken, !cached.isEmpty { return cached } + if let stored = await tokenStore.currentAccessToken(), !stored.isEmpty { + lastKnownAccessToken = stored + return stored + } throw AuthManagerError.missingAccessToken } @@ -672,6 +715,7 @@ final class AuthManager: ObservableObject { } private func restoreStoredSessionIfNeeded() async { + let mutationGeneration = beginAuthMutation(.restore) let accessToken = await tokenStore.currentAccessToken() let refreshToken = await tokenStore.currentRefreshToken() let hasAccessToken = accessToken != nil && !(accessToken?.isEmpty ?? true) @@ -688,7 +732,7 @@ final class AuthManager: ObservableObject { defer { isRestoringSession = false } do { - try await refreshSession() + try await refreshSession(expectedAuthMutation: mutationGeneration) authLog("restore: success user=\(currentUser?.primaryEmail ?? "nil") auth=\(isAuthenticated)") } catch { authLog("restore: failed error=\(error)") @@ -699,14 +743,14 @@ final class AuthManager: ObservableObject { } /// DEBUG-only append to /tmp/cmux-auth-debug.log. In Release builds this - /// is a no-op so token-derived material and user emails never land in a - /// world-traversable file. Call sites still pass PII-bearing strings - /// because redacting at the call site is a lot of churn; keeping the - /// #if DEBUG guard here is the single bottleneck that makes that safe. + /// mirrors the sanitized unified log entry so token-derived material and + /// user emails never land in a world-traversable file. nonisolated static func authLog(_ message: String) { + let redactedMessage = redactedAuthLogMessage(message) + authLogger.log(level: authLogType(for: redactedMessage), "\(redactedMessage, privacy: .public)") #if DEBUG - let line = "[\(Self.logTimestampFormatter.string(from: Date()))] auth: \(message)\n" - let path = "/tmp/cmux-auth-debug.log" + let line = "[\(Self.logTimestampFormatter.string(from: Date()))] auth: \(redactedMessage)\n" + let path = authDebugLogPath if let handle = FileHandle(forWritingAtPath: path) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8)!) @@ -714,9 +758,49 @@ final class AuthManager: ObservableObject { } else { FileManager.default.createFile(atPath: path, contents: line.data(using: .utf8)) } + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path) #endif } + private nonisolated static let authLogger = Logger(subsystem: "com.cmuxterm.app", category: "auth") + private nonisolated static let authDebugLogPath = "/tmp/cmux-auth-debug.log" + + private nonisolated static func authLogType(for message: String) -> OSLogType { + let lowercased = message.lowercased() + if lowercased.contains("failed") + || lowercased.contains("error") + || lowercased.contains("invalid") + || lowercased.contains("status=") { + return .error + } + return .debug + } + + private nonisolated static func redactedAuthLogMessage(_ message: String) -> String { + var redacted = message + let replacements: [(pattern: String, replacement: String)] = [ + (#"(?i)\b(stack_access|stack_refresh|access_token|refresh_token|id_token|token|login_code|polling_code|code|state)=([^\s&#,)]+)"#, "$1="), + (#"(?i)\b(access|refresh)=([^\s,;)]+)"#, "$1="), + (#"(?i)\b(authorization|x-stack-access-token|x-stack-refresh-token)\s*[:=]\s*(?:Bearer\s+)?([^\s,;)]+)"#, "$1="), + (#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#, ""), + (#"[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"#, ""), + ] + for replacement in replacements { + redacted = redacted.replacingOccurrences( + of: replacement.pattern, + with: replacement.replacement, + options: .regularExpression + ) + } + return redacted + } + + #if DEBUG + nonisolated static func redactedAuthLogMessageForTesting(_ message: String) -> String { + redactedAuthLogMessage(message) + } + #endif + // ISO8601DateFormatter is expensive to construct (calendar + locale + // time zone). Reuse one instance across the high-frequency authLog path. private static let logTimestampFormatter: ISO8601DateFormatter = { @@ -729,7 +813,7 @@ final class AuthManager: ObservableObject { Self.authLog(message) } - private func refreshSession() async throws { + private func refreshSession(expectedAuthMutation: UInt64? = nil) async throws { let user: CMUXAuthUser? do { user = try await client.currentUser() @@ -745,7 +829,13 @@ final class AuthManager: ObservableObject { throw error } let hasRefreshToken = await tokenStore.currentRefreshToken() != nil + try requireCurrentAuthMutation(expectedAuthMutation) authLog("refreshSession: user=\(user?.primaryEmail ?? "nil") teams=\(teams.count) hasRefresh=\(hasRefreshToken)") + if let accessToken = await tokenStore.currentAccessToken(), !accessToken.isEmpty { + try requireCurrentAuthMutation(expectedAuthMutation) + lastKnownAccessToken = accessToken + } + try requireCurrentAuthMutation(expectedAuthMutation) currentUser = user settingsStore.saveCachedUser(user) availableTeams = teams @@ -754,6 +844,7 @@ final class AuthManager: ObservableObject { } private func clearSessionState(clearSelectedTeam: Bool) { + lastKnownAccessToken = nil availableTeams = [] currentUser = nil isAuthenticated = false @@ -764,6 +855,79 @@ final class AuthManager: ObservableObject { settingsStore.saveCachedUser(nil) } + @discardableResult + private func beginAuthMutation(_ kind: AuthMutationKind) -> UInt64 { + authMutationGeneration &+= 1 + currentAuthMutationKind = kind + return authMutationGeneration + } + + private func isCurrentAuthMutation(_ generation: UInt64) -> Bool { + authMutationGeneration == generation + } + + private func requireCurrentAuthMutation(_ generation: UInt64?) throws { + guard let generation else { return } + guard isCurrentAuthMutation(generation) else { + throw AuthManagerError.invalidCallback + } + } + + private func keepAuthMutationIfCurrent( + _ generation: UInt64, + accessToken: String, + refreshToken: String? = nil + ) async -> Bool { + guard !isCurrentAuthMutation(generation) else { + return true + } + let cachedMatches = lastKnownAccessToken == accessToken + let storedCleared = await tokenStore.clearTokensIfCurrent( + accessToken: accessToken, + refreshToken: refreshToken + ) + if cachedMatches || storedCleared || currentAuthMutationKind == .signOut { + clearSessionState(clearSelectedTeam: true) + } + return false + } + + private func startBrowserSignInAttempt() -> UInt64 { + nextBrowserSignInAttemptID &+= 1 + let attemptID = nextBrowserSignInAttemptID + activeBrowserSignInAttemptID = attemptID + if signOutCancelledBrowserSignInAttemptID == attemptID { + signOutCancelledBrowserSignInAttemptID = nil + } + isLoading = true + return attemptID + } + + private func isCurrentBrowserSignInAttempt(_ attemptID: UInt64) -> Bool { + activeBrowserSignInAttemptID == attemptID + && signOutCancelledBrowserSignInAttemptID != attemptID + } + + private func finishBrowserSignInAttempt(_ attemptID: UInt64) { + guard activeBrowserSignInAttemptID == attemptID else { return } + isLoading = false + webAuthSession = nil + activeBrowserSignInAttemptID = nil + if signOutCancelledBrowserSignInAttemptID == attemptID { + signOutCancelledBrowserSignInAttemptID = nil + } + } + + private func cancelBrowserSignInForSignOut() { + if let attemptID = activeBrowserSignInAttemptID { + signOutCancelledBrowserSignInAttemptID = attemptID + } + activeBrowserSignInAttemptID = nil + webAuthSession?.cancel() + webAuthSession = nil + isLoading = false + } + private static func makeDefaultClient( tokenStore: any StackAuthTokenStoreProtocol ) -> any AuthClientProtocol { @@ -875,6 +1039,16 @@ private actor FallbackTokenStore: StackAuthTokenStoreProtocol { await file.clearTokens() } + @discardableResult + func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool { + if keychainWorks { + let keychainCleared = await keychain.clearTokensIfCurrent(accessToken: accessToken, refreshToken: refreshToken) + let fileCleared = await file.clearTokensIfCurrent(accessToken: accessToken, refreshToken: refreshToken) + return keychainCleared || fileCleared + } + return await file.clearTokensIfCurrent(accessToken: accessToken, refreshToken: refreshToken) + } + func compareAndSet( compareRefreshToken: String, newRefreshToken: String?, @@ -943,6 +1117,23 @@ private actor FileStackTokenStore: StackAuthTokenStoreProtocol { write(Snapshot(accessToken: nil, refreshToken: nil)) } + @discardableResult + func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool { + let snapshot = loadIfNeeded() + guard authTokenSnapshotMatches( + currentAccessToken: snapshot.accessToken, + currentRefreshToken: snapshot.refreshToken, + expectedAccessToken: accessToken, + expectedRefreshToken: refreshToken + ) else { + AuthManager.authLog("file.clearTokensIfCurrent: skipped stale clear") + return false + } + AuthManager.authLog("file.clearTokensIfCurrent: cleared matching tokens") + write(Snapshot(accessToken: nil, refreshToken: nil)) + return true + } + func compareAndSet( compareRefreshToken: String, newRefreshToken: String?, @@ -1050,6 +1241,24 @@ private actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { keychainDelete(account: Self.refreshTokenAccount) } + @discardableResult + func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool { + let currentAccessToken = keychainRead(account: Self.accessTokenAccount) + let currentRefreshToken = keychainRead(account: Self.refreshTokenAccount) + guard authTokenSnapshotMatches( + currentAccessToken: currentAccessToken, + currentRefreshToken: currentRefreshToken, + expectedAccessToken: accessToken, + expectedRefreshToken: refreshToken + ) else { + AuthManager.authLog("keychain.clearTokensIfCurrent: skipped stale clear") + return false + } + AuthManager.authLog("keychain.clearTokensIfCurrent: cleared matching tokens") + await clearTokens() + return true + } + func compareAndSet( compareRefreshToken: String, newRefreshToken: String?, diff --git a/Sources/CmuxLifecycleEventPublishing.swift b/Sources/CmuxLifecycleEventPublishing.swift index ed4ed89ec962..621a1edad113 100644 --- a/Sources/CmuxLifecycleEventPublishing.swift +++ b/Sources/CmuxLifecycleEventPublishing.swift @@ -216,43 +216,15 @@ extension Workspace { } } -@MainActor -private enum MainWindowKeyRegainRefresh { - static func refresh(window: NSWindow, context: AppDelegate.MainWindowContext) { - // Window focus regain owns the redraw invariant. Cursor tracking and - // focused subviews can update themselves only after this invalidation. - invalidateContentDisplayTree(window: window) - _ = context.keyboardFocusCoordinator.restoreTargetAfterWindowBecameKey() - } - - private static func invalidateContentDisplayTree(window: NSWindow) { - guard let contentView = window.contentView else { return } - invalidateDisplayTree(rootedAt: contentView) - window.invalidateCursorRects(for: contentView) - } - - private static func invalidateDisplayTree(rootedAt view: NSView) { - guard !view.isHidden else { return } - view.needsDisplay = true - view.layer?.setNeedsDisplay() - for subview in view.subviews { - invalidateDisplayTree(rootedAt: subview) - } - } -} - extension AppDelegate { func handleCmuxWindowBecameKey(_ note: Notification) { guard let window = note.object as? NSWindow else { return } MainActor.assumeIsolated { - let context = contextForMainTerminalWindow(window) setActiveMainWindow(window) if let windowId = mainWindowId(from: window) { publishCmuxWindowLifecycle(name: "window.keyed", windowId: windowId, origin: "appkit_key") } - if let context { - MainWindowKeyRegainRefresh.refresh(window: window, context: context) - } + _ = contextForMainTerminalWindow(window)?.keyboardFocusCoordinator.restoreTargetAfterWindowBecameKey() } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4a4058a883d4..a18a497293c4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9170,11 +9170,6 @@ struct VerticalTabsSidebar: View { SidebarFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) .frame(maxWidth: .infinity, alignment: .leading) } - .overlay { - FirstMouseGatedHostingOverlay() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .accessibilityHidden(true) - } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .overlay(alignment: .trailing) { @@ -12519,10 +12514,7 @@ private struct TabItemView: View, Equatable { .contentShape(Rectangle()) .opacity(isBeingDragged ? 0.6 : 1) .overlay { - SidebarWorkspaceRowHoverTracker( - rowInteractionState: $rowInteractionState, - onMenuTrackingEnded: flushWorkspaceContextMenuFreezeIfLive - ) + SidebarWorkspaceRowHoverTracker(rowInteractionState: $rowInteractionState) } .overlay { MiddleClickCapture { @@ -12642,7 +12634,8 @@ private struct TabItemView: View, Equatable { } .onDisappear { rowInteractionState.contextMenuDidDisappear() - flushWorkspaceContextMenuFreezeIfLive() + frozenPresentation = nil + flushDeferredWorkspaceObservationInvalidation() } } } @@ -12653,7 +12646,7 @@ private struct TabItemView: View, Equatable { current: workspaceSnapshotStorage, next: nextSnapshot, force: force, - freezesSidebarWorkspaceDetails: rowInteractionState.freezesSidebarWorkspaceDetails + contextMenuVisible: rowInteractionState.contextMenuVisible ) if workspaceSnapshotStorage != decision.workspaceSnapshotStorage { @@ -12676,12 +12669,6 @@ private struct TabItemView: View, Equatable { contextMenuState.pendingWorkspaceSnapshot = nil } - private func flushWorkspaceContextMenuFreezeIfLive() { - guard !rowInteractionState.freezesSidebarWorkspaceDetails else { return } - frozenPresentation = nil - flushDeferredWorkspaceObservationInvalidation() - } - private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { isMulti ? multi : single } diff --git a/Sources/GhosttyNSView+IMEComposition.swift b/Sources/GhosttyNSView+IMEComposition.swift index 0105f0ada716..dc51ba0abfe9 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -1,5 +1,4 @@ import AppKit -import Carbon.HIToolbox extension GhosttyNSView { /// Clamps AppKit's marked-text selection into the active preedit buffer. @@ -34,96 +33,19 @@ extension GhosttyNSView { func shouldSuppressGhosttyKeyForwardingAfterIMEHandling( before: (text: String, selection: NSRange), after: (text: String, selection: NSRange), - accumulatedText: [String], - event: NSEvent? = nil, - textInputHandledEvent: Bool = false, - inputSourceId: String? = nil + accumulatedText: [String] ) -> Bool { guard accumulatedText.isEmpty else { return false } let hadMarkedTextBefore = !before.text.isEmpty let hasMarkedTextAfter = !after.text.isEmpty - guard hadMarkedTextBefore || hasMarkedTextAfter else { - guard textInputHandledEvent, isBopomofoInputSource(inputSourceId) else { return false } - return shouldKeepNoMarkedIMECommandInsideTextInput(event) - } + guard hadMarkedTextBefore || hasMarkedTextAfter else { return false } if before.text != after.text { return true } - if before.selection != after.selection { - return true - } - - guard let event, isInputMethodSource(inputSourceId) else { - return false - } - return shouldKeepIMECompositionCommandInsideTextInput(event) - } - - func isInputMethodSource(_ sourceId: String?) -> Bool { - guard let sourceId else { return false } - return sourceId.localizedCaseInsensitiveContains("inputmethod") - } - - func isBopomofoInputSource(_ sourceId: String?) -> Bool { - guard let sourceId else { return false } - return sourceId.localizedCaseInsensitiveContains("Zhuyin") - || sourceId.localizedCaseInsensitiveContains("Bopomofo") - } - - func hasOnlyTextInputCommandModifiers(_ event: NSEvent) -> Bool { - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - return flags.isEmpty || flags == [.shift] - } - - func shouldKeepNoMarkedIMECommandInsideTextInput(_ event: NSEvent?) -> Bool { - guard let event else { return false } - guard hasOnlyTextInputCommandModifiers(event) else { return false } - - switch Int(event.keyCode) { - case kVK_DownArrow, kVK_PageUp, kVK_PageDown, kVK_Space: - return true - default: - return false - } - } - - /// Returns true when a window-level key-equivalent probe should re-enter - /// the terminal's keyDown path so AppKit's text input context sees the key - /// before terminal bindings or cursor escape sequences do. - func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent) -> Bool { - shouldRouteTextInputKeyEquivalentToKeyDown(event, inputSourceId: nil) - } - - func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent, inputSourceId: String?) -> Bool { - guard event.type == .keyDown else { return false } - let resolvedInputSourceId = inputSourceId ?? KeyboardLayout.id - if hasMarkedText() { - return isInputMethodSource(resolvedInputSourceId) - && shouldKeepIMECompositionCommandInsideTextInput(event) - } - return isBopomofoInputSource(resolvedInputSourceId) - && shouldKeepNoMarkedIMECommandInsideTextInput(event) - } - - /// Returns true for active-composition command keys that belong to AppKit's - /// text input manager even when marked text itself does not change. - func shouldKeepIMECompositionCommandInsideTextInput(_ event: NSEvent) -> Bool { - guard hasOnlyTextInputCommandModifiers(event) else { return false } - - switch Int(event.keyCode) { - case kVK_LeftArrow, kVK_RightArrow, kVK_UpArrow, kVK_DownArrow, - kVK_PageUp, kVK_PageDown, kVK_Home, kVK_End, - kVK_Space, kVK_Return, kVK_ANSI_KeypadEnter, kVK_Escape, - kVK_Tab, kVK_Delete, kVK_ForwardDelete: - return true - default: - return false - } + return before.selection != after.selection } #if DEBUG @@ -132,18 +54,12 @@ extension GhosttyNSView { markedSelectionBefore: NSRange, markedTextAfter: String, markedSelectionAfter: NSRange, - accumulatedText: [String], - event: NSEvent? = nil, - textInputHandledEvent: Bool = false, - inputSourceId: String? = nil + accumulatedText: [String] ) -> Bool { shouldSuppressGhosttyKeyForwardingAfterIMEHandling( before: (markedTextBefore, markedSelectionBefore), after: (markedTextAfter, markedSelectionAfter), - accumulatedText: accumulatedText, - event: event, - textInputHandledEvent: textInputHandledEvent, - inputSourceId: inputSourceId + accumulatedText: accumulatedText ) } #endif diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 985de1b6f957..2afd39b3e7d1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2110,9 +2110,8 @@ class GhosttyApp { return } - let fallbackShouldUseHostLayerBackground = usesHostLayerBackground(for: fallbackConfig) loadInlineGhosttyConfig( - "macos-background-from-layer = \(fallbackShouldUseHostLayerBackground)", + "macos-background-from-layer = true", into: fallbackConfig, prefix: "cmux-renderer-bg", logLabel: "renderer background (fallback)" @@ -2125,7 +2124,7 @@ class GhosttyApp { ) loadCmuxOwnedGhosttyKeybindOverrides(fallbackConfig) let fallbackRenderingModeChanged = setUsesHostLayerBackground( - fallbackShouldUseHostLayerBackground, + true, source: "initialize.fallbackConfig" ) ghostty_config_finalize(fallbackConfig) @@ -2261,17 +2260,15 @@ class GhosttyApp { } #endif loadCJKFontFallbackIfNeeded(config) - let shouldUseHostLayerBackground = usesHostLayerBackground(for: config) let renderingModeChanged = setUsesHostLayerBackground( - shouldUseHostLayerBackground, + true, source: "loadDefaultConfigFilesWithLegacyFallback" ) - // Let Ghostty paint solid opaque terminal backgrounds so default cells - // and explicit ANSI background cells share one renderer/compositor path. - // Host-layer ownership remains required for translucent and blurred - // terminal backgrounds. + // Let cmux own the window-level backdrop once, while Ghostty keeps + // rendering text, cell backgrounds, and background images. This avoids + // separate translucent fills for terminal and chrome surfaces. loadInlineGhosttyConfig( - "macos-background-from-layer = \(shouldUseHostLayerBackground)", + "macos-background-from-layer = true", into: config, prefix: "cmux-renderer-bg", logLabel: "renderer background" @@ -3229,7 +3226,10 @@ class GhosttyApp { let resolvedCursorText = ghosttyColorValue(from: config, key: "cursor-text", fallback: baseline.cursorTextColor) let resolvedSelectionBackground = ghosttyColorValue(from: config, key: "selection-background", fallback: baseline.selectionBackground) let resolvedSelectionForeground = ghosttyColorValue(from: config, key: "selection-foreground", fallback: baseline.selectionForeground) - let opacity = defaultBackgroundOpacityValue(from: config) + var opacity = baseline.backgroundOpacity + let opacityKey = "background-opacity" + _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) + opacity = min(1.0, max(0.0, opacity)) let backgroundBlur = defaultBackgroundBlurValue(from: config) applyDefaultBackground( color: resolvedColor, @@ -3246,20 +3246,6 @@ class GhosttyApp { ) } - private func defaultBackgroundOpacityValue(from config: ghostty_config_t) -> Double { - var opacity = Self.fallbackAppearanceConfig.backgroundOpacity - let key = "background-opacity" - _ = ghostty_config_get(config, &opacity, key, UInt(key.lengthOfBytes(using: .utf8))) - return Double(WindowAppearanceSnapshot.clampedOpacity(opacity)) - } - - private func usesHostLayerBackground(for config: ghostty_config_t) -> Bool { - WindowAppearanceSnapshot.usesHostLayerBackground( - backgroundOpacity: defaultBackgroundOpacityValue(from: config), - backgroundBlur: defaultBackgroundBlurValue(from: config) - ) - } - private func defaultBackgroundBlurValue(from config: ghostty_config_t) -> GhosttyBackgroundBlur { var value: Int16 = 0 let key = "background-blur" @@ -6169,6 +6155,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { fileprivate private(set) var keyboardCopyModeActive = false private var wordPathHoverActive = false private var keyboardCopyModeConsumedKeyUps: Set = [] + private var imeConsumedKeyUps: Set = [] private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState() private var keyboardCopyModeViewportRow: Int? /// Tracks whether the user has explicitly entered visual selection mode (v). @@ -6196,7 +6183,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") }() static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)? - @MainActor static var debugTextInputEventHandler: ((GhosttyNSView, NSEvent) -> Bool)? #endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? @@ -7201,7 +7187,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let result = super.becomeFirstResponder() var shouldApplySurfaceFocus = false if result { - imeSuppressedKeyUpKeyCodes.removeAll() + imeConsumedKeyUps.removeAll() if let terminalSurface, AppDelegate.shared?.allowsTerminalKeyboardFocus( workspaceId: terminalSurface.tabId, @@ -7289,9 +7275,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() if result { + imeConsumedKeyUps.removeAll() desiredFocus = false terminalSurface?.recordExternalFocusState(false) - imeSuppressedKeyUpKeyCodes.removeAll() } if result, let surface = surface { let now = CACurrentMediaTime() @@ -7304,7 +7290,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // For NSTextInputClient - accumulates text during key events private(set) var keyTextAccumulator: [String]? = nil - private var imeSuppressedKeyUpKeyCodes: Set = [] private var markedText = NSMutableAttributedString() private var markedSelectedRange = NSRange(location: NSNotFound, length: 0) private var lastPerformKeyEvent: TimeInterval? @@ -7324,12 +7309,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } - func setIMETransientStateForTesting(suppressedKeyUpKeyCodes: Set) { - imeSuppressedKeyUpKeyCodes = suppressedKeyUpKeyCodes - } - var imeSuppressedKeyUpKeyCodesForTesting: Set { - imeSuppressedKeyUpKeyCodes - } func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool { shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore) } @@ -7730,16 +7709,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let markedTextBefore = markedText.length > 0 let markedStateBefore = (markedText.string, markedSelectedRange) - // Capture the keyboard layout ID before interpretation so the IME - // forwarding decision uses the source that saw this key. - let keyboardIdBefore = KeyboardLayout.id + // Capture the keyboard layout ID before interpretation so we can + // detect if an IME changed it (e.g. toggling input methods). + // We only check when not already in a preedit state. + let keyboardIdBefore: String? = if (!markedTextBefore) { + KeyboardLayout.id + } else { + nil + } // Let the input system handle the event (for IME, dead keys, etc.) #if DEBUG let interpretTimingStart = CmuxTypingTiming.start() let interpretPhaseStart = ProcessInfo.processInfo.systemUptime #endif - let textInputHandledEvent = handleTextInputKeyEvent(translationEvent) + interpretKeyEvents([translationEvent]) #if DEBUG interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 CmuxTypingTiming.logDuration( @@ -7751,8 +7735,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // If the keyboard layout changed, an input method grabbed the event. // Sync preedit and return without sending the key to Ghostty. - if !markedTextBefore, keyboardIdBefore != KeyboardLayout.id { - imeSuppressedKeyUpKeyCodes.insert(event.keyCode) + if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id { + imeConsumedKeyUps.insert(event.keyCode) #if DEBUG let syncPreeditStart = ProcessInfo.processInfo.systemUptime #endif @@ -7774,21 +7758,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let accumulatedText = keyTextAccumulator ?? [] if shouldSuppressGhosttyKeyForwardingAfterIMEHandling( - before: markedStateBefore, - after: (markedText.string, markedSelectedRange), - accumulatedText: accumulatedText, - event: translationEvent, - textInputHandledEvent: textInputHandledEvent, - inputSourceId: keyboardIdBefore + before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText ) { - imeSuppressedKeyUpKeyCodes.insert(event.keyCode) + imeConsumedKeyUps.insert(event.keyCode) return } - // A forwarded keyDown owns its keyUp. Clear any stale IME suppression - // entry left by an earlier suppressed repeat for the same physical key. - imeSuppressedKeyUpKeyCodes.remove(event.keyCode) - // Build the key event var keyEvent = ghostty_input_key_s() keyEvent.action = action @@ -7969,20 +7944,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Rendering is driven by Ghostty's wakeups/renderer. } - @discardableResult - private func handleTextInputKeyEvent(_ event: NSEvent) -> Bool { -#if DEBUG - if let debugTextInputEventHandler = Self.debugTextInputEventHandler { - return debugTextInputEventHandler(self, event) - } -#endif - guard let inputContext else { - interpretKeyEvents([event]) - return false - } - return inputContext.handleEvent(event) - } - @discardableResult private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { #if DEBUG @@ -8015,10 +7976,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif override func keyUp(with event: NSEvent) { - if imeSuppressedKeyUpKeyCodes.remove(event.keyCode) != nil { - return - } - guard let surface = ensureSurfaceReadyForInput() else { super.keyUp(with: event) return @@ -8037,6 +7994,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil { return } + if imeConsumedKeyUps.remove(event.keyCode) != nil { + return + } // Build release events from the same translation path as keyDown so // consumers that depend on precise key identity (for example Space diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 07148c4e873a..0c0c4a7503d6 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1049,7 +1049,7 @@ final class FilePreviewPDFChromeHostView: NSView { final class FilePreviewPDFChromeHostingView: NSHostingView { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - PaneFirstClickFocusSettings.isEnabled() + true } } diff --git a/Sources/Sidebar/SidebarWorkspaceRowHoverTracker.swift b/Sources/Sidebar/SidebarWorkspaceRowHoverTracker.swift index 442948ffe78f..6ef2307bb8bf 100644 --- a/Sources/Sidebar/SidebarWorkspaceRowHoverTracker.swift +++ b/Sources/Sidebar/SidebarWorkspaceRowHoverTracker.swift @@ -3,13 +3,9 @@ import SwiftUI struct SidebarWorkspaceRowHoverTracker: NSViewRepresentable { @Binding var rowInteractionState: SidebarWorkspaceRowInteractionState - var onMenuTrackingEnded: () -> Void = {} func makeCoordinator() -> Coordinator { - Coordinator( - rowInteractionState: $rowInteractionState, - onMenuTrackingEnded: onMenuTrackingEnded - ) + Coordinator(rowInteractionState: $rowInteractionState) } func makeNSView(context: Context) -> SidebarWorkspaceRowHoverTrackingView { @@ -26,19 +22,13 @@ struct SidebarWorkspaceRowHoverTracker: NSViewRepresentable { func updateNSView(_ nsView: SidebarWorkspaceRowHoverTrackingView, context: Context) { context.coordinator.rowInteractionState = $rowInteractionState - context.coordinator.onMenuTrackingEnded = onMenuTrackingEnded } final class Coordinator { var rowInteractionState: Binding - var onMenuTrackingEnded: () -> Void - init( - rowInteractionState: Binding, - onMenuTrackingEnded: @escaping () -> Void = {} - ) { + init(rowInteractionState: Binding) { self.rowInteractionState = rowInteractionState - self.onMenuTrackingEnded = onMenuTrackingEnded } func pointerHoverChanged(_ hovering: Bool) { @@ -50,7 +40,6 @@ struct SidebarWorkspaceRowHoverTracker: NSViewRepresentable { rowInteractionState.wrappedValue.contextMenuTrackingDidBegin() } else { rowInteractionState.wrappedValue.contextMenuTrackingDidEnd() - onMenuTrackingEnded() } } } diff --git a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift index 8e929a470041..d619543f95e6 100644 --- a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift +++ b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift @@ -54,9 +54,9 @@ struct SidebarWorkspaceSnapshotRefreshPolicy { current: SidebarWorkspaceSnapshotBuilder.Snapshot?, next: SidebarWorkspaceSnapshotBuilder.Snapshot, force: Bool, - freezesSidebarWorkspaceDetails: Bool + contextMenuVisible: Bool ) -> Decision { - guard freezesSidebarWorkspaceDetails else { + guard contextMenuVisible else { return Decision( workspaceSnapshotStorage: force || current != next ? next : current, pendingWorkspaceSnapshot: nil, @@ -77,25 +77,11 @@ struct SidebarWorkspaceSnapshotRefreshPolicy { } struct SidebarWorkspaceRowInteractionState: Equatable { - // AppKit menu tracking is the authoritative freeze lifetime for row pointer - // context menus. SwiftUI appearance is only a fallback for menu surfaces that - // do not emit AppKit tracking; it must not end an active AppKit-tracking - // freeze early. - private enum ContextMenuDetailsFreezePhase: Equatable { - case live - case swiftUIFallback - case appKitTracking - } - private(set) var isPointerHovering = false - private var contextMenuDetailsFreezePhase: ContextMenuDetailsFreezePhase = .live + private(set) var contextMenuVisible = false private var contextMenuTrackingSuppressesCloseButton = false private var deferredPointerHoveringWhileContextMenuTracking: Bool? - var freezesSidebarWorkspaceDetails: Bool { - contextMenuDetailsFreezePhase != .live - } - mutating func setPointerHovering(_ hovering: Bool) { if contextMenuTrackingSuppressesCloseButton { deferredPointerHoveringWhileContextMenuTracking = hovering @@ -107,27 +93,25 @@ struct SidebarWorkspaceRowInteractionState: Equatable { } mutating func contextMenuDidAppear() { - beginSwiftUIFallbackContextMenuFreeze() + contextMenuVisible = true contextMenuTrackingSuppressesCloseButton = true deferredPointerHoveringWhileContextMenuTracking = nil isPointerHovering = false } mutating func contextMenuDidDisappear() { - endSwiftUIFallbackContextMenuFreeze() + contextMenuVisible = false contextMenuTrackingSuppressesCloseButton = false applyDeferredPointerHovering() } mutating func contextMenuTrackingDidBegin() { - beginAppKitTrackingContextMenuFreeze() contextMenuTrackingSuppressesCloseButton = true deferredPointerHoveringWhileContextMenuTracking = nil isPointerHovering = false } mutating func contextMenuTrackingDidEnd() { - endAppKitTrackingContextMenuFreeze() contextMenuTrackingSuppressesCloseButton = false applyDeferredPointerHovering() } @@ -142,24 +126,6 @@ struct SidebarWorkspaceRowInteractionState: Equatable { && !shortcutHintModeActive } - private mutating func beginSwiftUIFallbackContextMenuFreeze() { - guard contextMenuDetailsFreezePhase == .live else { return } - contextMenuDetailsFreezePhase = .swiftUIFallback - } - - private mutating func endSwiftUIFallbackContextMenuFreeze() { - guard contextMenuDetailsFreezePhase == .swiftUIFallback else { return } - contextMenuDetailsFreezePhase = .live - } - - private mutating func beginAppKitTrackingContextMenuFreeze() { - contextMenuDetailsFreezePhase = .appKitTracking - } - - private mutating func endAppKitTrackingContextMenuFreeze() { - contextMenuDetailsFreezePhase = .live - } - private mutating func applyDeferredPointerHovering() { guard let deferredHover = deferredPointerHoveringWhileContextMenuTracking else { return } self.deferredPointerHoveringWhileContextMenuTracking = nil diff --git a/Sources/Update/MinimalModeSidebarControls.swift b/Sources/Update/MinimalModeSidebarControls.swift index 587194b7925d..c294c67722bc 100644 --- a/Sources/Update/MinimalModeSidebarControls.swift +++ b/Sources/Update/MinimalModeSidebarControls.swift @@ -143,7 +143,7 @@ final class MinimalModeSidebarControlActionView: NSView { } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - PaneFirstClickFocusSettings.isEnabled() + true } override func hitTest(_ point: NSPoint) -> NSView? { diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index afe31e8f2a74..737f6f1b84e2 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -237,12 +237,7 @@ class UpdateDriver: NSObject, SPUUserDriver { } private func applyState(_ newState: UpdateState) { - switch newState { - case .updateAvailable(let update): - viewModel.recordAvailableUpdate(update) - default: - viewModel.state = newState - } + viewModel.applyDriverState(newState) UpdateLogStore.shared.append("state -> \(describe(newState))") } diff --git a/Sources/Update/UpdateTestSupport.swift b/Sources/Update/UpdateTestSupport.swift index 998e27a669bb..b6c9a96ca22b 100644 --- a/Sources/Update/UpdateTestSupport.swift +++ b/Sources/Update/UpdateTestSupport.swift @@ -45,7 +45,7 @@ enum UpdateTestSupport { UpdateLogStore.shared.append("ui test mock feed check: \(feedURLString)") UpdateTestURLProtocol.registerIfNeeded() DispatchQueue.main.async { - viewModel.state = .checking(.init(cancel: {})) + viewModel.applyDriverState(.checking(.init(cancel: {}))) } let task = URLSession.shared.dataTask(with: feedURL) { data, _, _ in @@ -55,9 +55,9 @@ enum UpdateTestSupport { let applyState = { if hasItem { let appcastItem = makeAppcastItem(displayVersion: version) ?? SUAppcastItem.empty() - viewModel.recordAvailableUpdate(.init(appcastItem: appcastItem, reply: { _ in })) + viewModel.applyDriverState(.updateAvailable(.init(appcastItem: appcastItem, reply: { _ in }))) } else { - viewModel.state = .notFound(.init(acknowledgement: {})) + viewModel.applyDriverState(.notFound(.init(acknowledgement: {}))) } } DispatchQueue.main.async { @@ -76,9 +76,9 @@ enum UpdateTestSupport { } private static func transition(to state: UpdateState, on viewModel: UpdateViewModel) { - viewModel.state = .checking(.init(cancel: {})) + viewModel.applyDriverState(.checking(.init(cancel: {}))) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - viewModel.state = state + viewModel.applyDriverState(state) } } diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 4797ff9eb884..969796e69bea 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -29,15 +29,9 @@ class UpdateViewModel: ObservableObject { } func recordDetectedUpdate(_ item: SUAppcastItem) { - recordDetectedUpdateMetadata(item) - } - - func recordAvailableUpdate(_ update: UpdateState.UpdateAvailable) { - recordDetectedUpdateMetadata(update.appcastItem) - state = .updateAvailable(update) - if let overrideState, case .updateAvailable = overrideState { - self.overrideState = .updateAvailable(update) - } + let version = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString) + detectedUpdateItem = version == nil ? nil : item + detectedUpdateVersion = version } func clearDetectedUpdate() { @@ -45,12 +39,6 @@ class UpdateViewModel: ObservableObject { detectedUpdateVersion = nil } - func cancelActiveStateForNewCheck() { - state.cancel() - state = .idle - overrideState = nil - } - func dismissDetectedAvailableUpdate() { clearDetectedUpdate() @@ -69,10 +57,17 @@ class UpdateViewModel: ObservableObject { } } - private func recordDetectedUpdateMetadata(_ item: SUAppcastItem) { - let version = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString) - detectedUpdateItem = version == nil ? nil : item - detectedUpdateVersion = version + func cancelActiveStateForNewCheck() { + state.cancel() + state = .idle + overrideState = nil + } + + func applyDriverState(_ newState: UpdateState) { + if case .updateAvailable(let update) = newState { + recordDetectedUpdate(update.appcastItem) + } + state = newState } var text: String { diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index c3eac3a914af..2b809560fc92 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -213,11 +213,6 @@ struct WindowGlassSettingsSnapshot { } struct WindowAppearanceSnapshot { - /// Treat opacity values within floating-point round-trip tolerance of 1.0 - /// as opaque so user-configured `1.0` does not accidentally trigger host - /// ownership after Double/CGFloat conversions. - private static let opaqueBackgroundOwnershipThreshold: CGFloat = 0.999 - let terminalBackgroundColor: NSColor let terminalBackgroundOpacity: CGFloat let terminalBackgroundBlur: GhosttyBackgroundBlur @@ -290,28 +285,6 @@ struct WindowAppearanceSnapshot { usesHostLayerBackground ? .windowHostBackdrop : .ghosttyRendererOwnedBackgroundImage } - static func usesHostLayerBackground( - backgroundOpacity: Double, - backgroundBlur: GhosttyBackgroundBlur - ) -> Bool { - if backgroundBlur != .disabled { - return true - } - return clampedOpacity(backgroundOpacity) < opaqueBackgroundOwnershipThreshold - } - - static func terminalRenderingMode( - backgroundOpacity: Double, - backgroundBlur: GhosttyBackgroundBlur - ) -> GhosttyTerminalBackdropRenderingMode { - terminalRenderingMode( - usesHostLayerBackground: usesHostLayerBackground( - backgroundOpacity: backgroundOpacity, - backgroundBlur: backgroundBlur - ) - ) - } - var compositedTerminalBackgroundColor: NSColor { terminalBackgroundColor.withAlphaComponent(terminalBackgroundOpacity) } diff --git a/cmuxTests/AppDelegateBareSpaceShortcutRoutingTests.swift b/cmuxTests/AppDelegateBareSpaceShortcutRoutingTests.swift index 68957ca92358..843401bab4d4 100644 --- a/cmuxTests/AppDelegateBareSpaceShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateBareSpaceShortcutRoutingTests.swift @@ -129,7 +129,7 @@ final class AppDelegateBareSpaceShortcutRoutingTests: XCTestCase { XCTAssertEqual(manager.tabs.count, initialCount + 1, "Bare Space chord should dispatch on the second stroke") } - func testCreateMainWindowIgnoresLegacyPersistedGeometryWhenNoSourceWindow() throws { + func testCreateMainWindowUsesPersistedGeometryWhenNoSourceWindow() throws { let previousShared = AppDelegate.shared let appDelegate = AppDelegate() defer { AppDelegate.shared = previousShared } @@ -137,20 +137,11 @@ final class AppDelegateBareSpaceShortcutRoutingTests: XCTestCase { let defaults = UserDefaults.standard let persistedGeometryKey = AppDelegate.debugPersistedWindowGeometryDefaultsKey let previousPersistedGeometry = defaults.object(forKey: persistedGeometryKey) - let primaryFrameAutosaveName = AppDelegate.debugPrimaryMainWindowFrameAutosaveName - let primaryFrameAutosaveKey = appKitFrameAutosaveDefaultsKey(primaryFrameAutosaveName) - let previousPrimaryFrameAutosave = defaults.object(forKey: primaryFrameAutosaveKey) - NSWindow.removeFrame(usingName: primaryFrameAutosaveName) var windowId: UUID? defer { if let windowId { closeWindow(withId: windowId) } - restoreDefaultsValue( - previousPrimaryFrameAutosave, - forKey: primaryFrameAutosaveKey, - defaults: defaults - ) restoreDefaultsValue( previousPersistedGeometry, forKey: persistedGeometryKey, @@ -162,15 +153,15 @@ final class AppDelegateBareSpaceShortcutRoutingTests: XCTestCase { let visibleFrame = screen.visibleFrame let savedWidth = max( CGFloat(SessionPersistencePolicy.minimumWindowWidth), - min(720, visibleFrame.width - 80) + min(1_100, visibleFrame.width - 40) ) let savedHeight = max( CGFloat(SessionPersistencePolicy.minimumWindowHeight), - min(520, visibleFrame.height - 80) + min(760, visibleFrame.height - 40) ) let savedFrame = CGRect( - x: visibleFrame.minX + 37, - y: visibleFrame.minY + 43, + x: visibleFrame.midX - savedWidth / 2, + y: visibleFrame.midY - savedHeight / 2, width: savedWidth, height: savedHeight ) @@ -189,242 +180,10 @@ final class AppDelegateBareSpaceShortcutRoutingTests: XCTestCase { windowId = createdWindowId let window = try XCTUnwrap(window(withId: createdWindowId)) - XCTAssertFalse(window.frameAutosaveName.isEmpty) - XCTAssertGreaterThan( - abs(window.frame.minX - savedFrame.minX), - 1, - "Legacy cmux lastWindowGeometry must not compete with AppKit frame autosave." - ) - XCTAssertGreaterThan( - abs(window.frame.minY - savedFrame.minY), - 1, - "Legacy cmux lastWindowGeometry must not compete with AppKit frame autosave." - ) - } - - func testLegacyPersistedGeometryCleanupRemovesCurrentV2Key() throws { - let defaults = UserDefaults.standard - let persistedGeometryKey = AppDelegate.debugPersistedWindowGeometryDefaultsKey - let previousPersistedGeometry = defaults.object(forKey: persistedGeometryKey) - defer { - restoreDefaultsValue( - previousPersistedGeometry, - forKey: persistedGeometryKey, - defaults: defaults - ) - } - - defaults.set(Data([1, 2, 3]), forKey: persistedGeometryKey) - - AppDelegate.debugRemoveLegacyPersistedWindowGeometry(defaults: defaults) - - XCTAssertNil( - defaults.object(forKey: persistedGeometryKey), - "The removed cmux-owned frame geometry key must be cleared so AppKit frame autosave is the only persisted frame source." - ) - } - - func testCreateMainWindowRegistersAppKitFrameAutosaveNames() throws { - let previousShared = AppDelegate.shared - let appDelegate = AppDelegate() - defer { AppDelegate.shared = previousShared } - let defaults = UserDefaults.standard - let primaryFrameAutosaveName = AppDelegate.debugPrimaryMainWindowFrameAutosaveName - let primaryFrameAutosaveKey = appKitFrameAutosaveDefaultsKey(primaryFrameAutosaveName) - let previousPrimaryFrameAutosave = defaults.object(forKey: primaryFrameAutosaveKey) - NSWindow.removeFrame(usingName: primaryFrameAutosaveName) - var secondaryFrameAutosaveName: NSWindow.FrameAutosaveName? - - let firstWindowId = appDelegate.createMainWindow(shouldActivate: false, sourceWindow: nil) - let secondWindowId = appDelegate.createMainWindow(shouldActivate: false, sourceWindow: nil) - defer { - closeWindow(withId: secondWindowId) - closeWindow(withId: firstWindowId) - if let secondaryFrameAutosaveName { - NSWindow.removeFrame(usingName: secondaryFrameAutosaveName) - } - restoreDefaultsValue( - previousPrimaryFrameAutosave, - forKey: primaryFrameAutosaveKey, - defaults: defaults - ) - } - - let firstWindow = try XCTUnwrap(window(withId: firstWindowId)) - let secondWindow = try XCTUnwrap(window(withId: secondWindowId)) - secondaryFrameAutosaveName = secondWindow.frameAutosaveName - - XCTAssertFalse( - firstWindow.frameAutosaveName.isEmpty, - "Main windows must opt in to AppKit frame autosave so macOS owns screen topology restoration." - ) - XCTAssertFalse( - secondWindow.frameAutosaveName.isEmpty, - "Additional main windows also need autosave names so monitor wake restores do not fall back to cmux-only geometry." - ) - XCTAssertNotEqual( - firstWindow.frameAutosaveName, - secondWindow.frameAutosaveName, - "Each live main window needs a distinct AppKit frame autosave name." - ) - } - - func testClosingSecondaryMainWindowRemovesEphemeralFrameAutosaveName() throws { - let previousShared = AppDelegate.shared - let appDelegate = AppDelegate() - defer { AppDelegate.shared = previousShared } - let defaults = UserDefaults.standard - let primaryFrameAutosaveName = AppDelegate.debugPrimaryMainWindowFrameAutosaveName - let primaryFrameAutosaveKey = appKitFrameAutosaveDefaultsKey(primaryFrameAutosaveName) - let previousPrimaryFrameAutosave = defaults.object(forKey: primaryFrameAutosaveKey) - NSWindow.removeFrame(usingName: primaryFrameAutosaveName) - - let firstWindowId = appDelegate.createMainWindow(shouldActivate: false, sourceWindow: nil) - let secondWindowId = appDelegate.createMainWindow(shouldActivate: false, sourceWindow: nil) - defer { - closeWindow(withId: secondWindowId) - closeWindow(withId: firstWindowId) - restoreDefaultsValue( - previousPrimaryFrameAutosave, - forKey: primaryFrameAutosaveKey, - defaults: defaults - ) - } - - let secondWindow = try XCTUnwrap(window(withId: secondWindowId)) - let secondaryFrameAutosaveName = secondWindow.frameAutosaveName - let secondaryFrameAutosaveKey = appKitFrameAutosaveDefaultsKey(secondaryFrameAutosaveName) - let previousSecondaryFrameAutosave = defaults.object(forKey: secondaryFrameAutosaveKey) - defer { - restoreDefaultsValue( - previousSecondaryFrameAutosave, - forKey: secondaryFrameAutosaveKey, - defaults: defaults - ) - } - - XCTAssertFalse(secondaryFrameAutosaveName.isEmpty) - XCTAssertNotEqual(secondaryFrameAutosaveName, primaryFrameAutosaveName) - - NSWindow.removeFrame(usingName: secondaryFrameAutosaveName) - secondWindow.saveFrame(usingName: secondaryFrameAutosaveName) - XCTAssertNotNil(defaults.object(forKey: secondaryFrameAutosaveKey)) - - closeWindow(withId: secondWindowId) - - XCTAssertTrue( - secondWindow.frameAutosaveName.isEmpty, - "Ephemeral autosave names must be cleared before removing their saved frame." - ) - XCTAssertNil( - defaults.object(forKey: secondaryFrameAutosaveKey), - "UUID-scoped autosave names are per-window and must be removed when the window closes." - ) - } - - func testClosingPrimaryMainWindowPromotesSurvivingWindowAutosaveName() throws { - let previousShared = AppDelegate.shared - let appDelegate = AppDelegate() - defer { AppDelegate.shared = previousShared } - let defaults = UserDefaults.standard - let primaryFrameAutosaveName = AppDelegate.debugPrimaryMainWindowFrameAutosaveName - let primaryFrameAutosaveKey = appKitFrameAutosaveDefaultsKey(primaryFrameAutosaveName) - let previousPrimaryFrameAutosave = defaults.object(forKey: primaryFrameAutosaveKey) - NSWindow.removeFrame(usingName: primaryFrameAutosaveName) - - let firstWindowId = appDelegate.createMainWindow(shouldActivate: false, sourceWindow: nil) - let secondWindowId = appDelegate.createMainWindow(shouldActivate: false, sourceWindow: nil) - var secondaryFrameAutosaveName: NSWindow.FrameAutosaveName? - var previousSecondaryFrameAutosave: Any? - defer { - closeWindow(withId: secondWindowId) - closeWindow(withId: firstWindowId) - if let secondaryFrameAutosaveName { - restoreDefaultsValue( - previousSecondaryFrameAutosave, - forKey: appKitFrameAutosaveDefaultsKey(secondaryFrameAutosaveName), - defaults: defaults - ) - } - restoreDefaultsValue( - previousPrimaryFrameAutosave, - forKey: primaryFrameAutosaveKey, - defaults: defaults - ) - } - - let firstWindow = try XCTUnwrap(window(withId: firstWindowId)) - let secondWindow = try XCTUnwrap(window(withId: secondWindowId)) - secondaryFrameAutosaveName = secondWindow.frameAutosaveName - let secondaryFrameAutosaveKey = appKitFrameAutosaveDefaultsKey(secondWindow.frameAutosaveName) - previousSecondaryFrameAutosave = defaults.object(forKey: secondaryFrameAutosaveKey) - - XCTAssertEqual(firstWindow.frameAutosaveName, primaryFrameAutosaveName) - XCTAssertNotEqual(secondWindow.frameAutosaveName, primaryFrameAutosaveName) - let screen = try XCTUnwrap(NSScreen.main ?? NSScreen.screens.first) - let visibleFrame = screen.visibleFrame - let savedWidth = max( - CGFloat(SessionPersistencePolicy.minimumWindowWidth), - min(520, visibleFrame.width - 120) - ) - let savedHeight = max( - CGFloat(SessionPersistencePolicy.minimumWindowHeight), - min(360, visibleFrame.height - 120) - ) - let firstFrame = CGRect( - x: visibleFrame.minX + 24, - y: visibleFrame.minY + 32, - width: savedWidth, - height: savedHeight - ) - let secondFrame = CGRect( - x: min(visibleFrame.minX + 144, visibleFrame.maxX - savedWidth - 24), - y: min(visibleFrame.minY + 152, visibleFrame.maxY - savedHeight - 32), - width: savedWidth, - height: savedHeight - ) - firstWindow.setFrame(firstFrame, display: false) - firstWindow.saveFrame(usingName: primaryFrameAutosaveName) - secondWindow.setFrame(secondFrame, display: false) - NSWindow.removeFrame(usingName: secondWindow.frameAutosaveName) - secondWindow.saveFrame(usingName: secondWindow.frameAutosaveName) - let survivorFrameBeforePromotion = secondWindow.frame - XCTAssertNotNil(defaults.object(forKey: secondaryFrameAutosaveKey)) - - closeWindow(withId: firstWindowId) - - XCTAssertEqual( - secondWindow.frameAutosaveName, - primaryFrameAutosaveName, - "A surviving main window must take over the stable AppKit autosave slot when the primary closes." - ) - XCTAssertNil( - defaults.object(forKey: secondaryFrameAutosaveKey), - "Promoting a survivor to the primary slot must also retire its UUID-scoped saved frame." - ) - XCTAssertNotNil( - defaults.object(forKey: primaryFrameAutosaveKey), - "Promotion should immediately persist the survivor's frame under the stable primary autosave key." - ) - let probeWindow = NSWindow( - contentRect: CGRect(x: 0, y: 0, width: 10, height: 10), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { probeWindow.close() } - XCTAssertTrue( - probeWindow.setFrameUsingName(primaryFrameAutosaveName, force: true), - "Promotion should leave the survivor's restorable frame in the stable primary autosave slot." - ) - XCTAssertEqual(probeWindow.frame.minX, survivorFrameBeforePromotion.minX, accuracy: 1) - XCTAssertEqual(probeWindow.frame.minY, survivorFrameBeforePromotion.minY, accuracy: 1) - XCTAssertEqual(probeWindow.frame.width, survivorFrameBeforePromotion.width, accuracy: 1) - XCTAssertEqual(probeWindow.frame.height, survivorFrameBeforePromotion.height, accuracy: 1) - XCTAssertEqual(secondWindow.frame.minX, survivorFrameBeforePromotion.minX, accuracy: 1) - XCTAssertEqual(secondWindow.frame.minY, survivorFrameBeforePromotion.minY, accuracy: 1) - XCTAssertEqual(secondWindow.frame.width, survivorFrameBeforePromotion.width, accuracy: 1) - XCTAssertEqual(secondWindow.frame.height, survivorFrameBeforePromotion.height, accuracy: 1) + XCTAssertEqual(window.frame.minX, savedFrame.minX, accuracy: 1) + XCTAssertEqual(window.frame.minY, savedFrame.minY, accuracy: 1) + XCTAssertEqual(window.frame.width, savedFrame.width, accuracy: 1) + XCTAssertEqual(window.frame.height, savedFrame.height, accuracy: 1) } private func makeKeyDownEvent( @@ -482,10 +241,4 @@ final class AppDelegateBareSpaceShortcutRoutingTests: XCTestCase { defaults.removeObject(forKey: key) } } - - /// Mirrors AppKit's internal frame-autosave UserDefaults key convention. - /// Keep this localized to tests because AppKit does not document the format. - private func appKitFrameAutosaveDefaultsKey(_ name: NSWindow.FrameAutosaveName) -> String { - "NSWindow Frame \(name)" - } } diff --git a/cmuxTests/AuthManagerBrowserSignInTests.swift b/cmuxTests/AuthManagerBrowserSignInTests.swift deleted file mode 100644 index d7f19de515f4..000000000000 --- a/cmuxTests/AuthManagerBrowserSignInTests.swift +++ /dev/null @@ -1,246 +0,0 @@ -import XCTest -import Combine -import CMUXAuthCore - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@MainActor -final class AuthManagerBrowserSignInTests: XCTestCase { - private actor InMemoryAuthTokenStore: StackAuthTokenStoreProtocol { - private var accessToken: String? - private var refreshToken: String? - - func getStoredAccessToken() async -> String? { - accessToken - } - - func getStoredRefreshToken() async -> String? { - refreshToken - } - - func setTokens(accessToken: String?, refreshToken: String?) async { - self.accessToken = accessToken - self.refreshToken = refreshToken - } - - func clearTokens() async { - accessToken = nil - refreshToken = nil - } - - func compareAndSet( - compareRefreshToken: String, - newRefreshToken: String?, - newAccessToken: String? - ) async { - guard refreshToken == compareRefreshToken else { return } - refreshToken = newRefreshToken - accessToken = newAccessToken - } - } - - private struct StubAuthClient: AuthClientProtocol { - let user = CMUXAuthUser( - id: "user_123", - primaryEmail: "user@example.com", - displayName: "Test User" - ) - let teams = [AuthTeamSummary(id: "team_123", displayName: "Team")] - - func currentUser() async throws -> CMUXAuthUser? { - user - } - - func listTeams() async throws -> [AuthTeamSummary] { - teams - } - } - - private struct FailingRefreshAuthClient: AuthClientProtocol { - func currentUser() async throws -> CMUXAuthUser? { - throw AuthManagerError.invalidCallback - } - - func listTeams() async throws -> [AuthTeamSummary] { - [] - } - } - - private func makeIsolatedSettingsStore() -> AuthSettingsStore { - let suiteName = "cmux-auth-manager-browser-sign-in-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - return AuthSettingsStore(userDefaults: defaults) - } - - func testDefaultSignInUsesScopedWebAuthenticationSession() { - XCTAssertTrue(AuthManager.shouldUseSystemWebAuthenticationSession(environment: [:])) - XCTAssertTrue(AuthManager.shouldUseSystemWebAuthenticationSession( - environment: ["CMUX_AUTH_USE_ASWEB_AUTH_SESSION": "1"] - )) - XCTAssertTrue(AuthManager.shouldUseSystemWebAuthenticationSession( - environment: ["CMUX_AUTH_USE_ASWEB_AUTH_SESSION": " true "] - )) - XCTAssertFalse(AuthManager.shouldUseSystemWebAuthenticationSession( - environment: ["CMUX_AUTH_USE_ASWEB_AUTH_SESSION": "0"] - )) - XCTAssertFalse(AuthManager.shouldUseSystemWebAuthenticationSession( - environment: ["CMUX_AUTH_USE_ASWEB_AUTH_SESSION": "false"] - )) - } - - func testBeginSignInOpensExternalBrowserCallbackURL() async { - let tokenStore = InMemoryAuthTokenStore() - var openedURL: URL? - let manager = AuthManager( - client: StubAuthClient(), - tokenStore: tokenStore, - settingsStore: makeIsolatedSettingsStore(), - urlOpener: { openedURL = $0 }, - usesSystemWebAuthenticationSession: { false } - ) - await manager.awaitBootstrapped() - - var observedLoadingValues: [Bool] = [] - let loadingSink = manager.$isLoading.sink { - observedLoadingValues.append($0) - } - - manager.beginSignIn() - withExtendedLifetime(loadingSink) {} - - let url = openedURL - XCTAssertEqual(url?.path, "/handler/sign-in") - let components = url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) } - let afterAuthReturnTo = components?.queryItems?.first { $0.name == "after_auth_return_to" }?.value - XCTAssertEqual(url?.scheme, AuthEnvironment.afterSignInOrigin.scheme) - XCTAssertEqual(url?.host, AuthEnvironment.afterSignInOrigin.host) - XCTAssertTrue(afterAuthReturnTo?.contains(AuthEnvironment.callbackURL.absoluteString) == true) - XCTAssertFalse(manager.isLoading) - XCTAssertEqual(observedLoadingValues, [false]) - await manager.signOut() - } - - func testBrowserCallbackClearsLoadingAndSeedsTokens() async throws { - let tokenStore = InMemoryAuthTokenStore() - var openedURL: URL? - let manager = AuthManager( - client: StubAuthClient(), - tokenStore: tokenStore, - settingsStore: makeIsolatedSettingsStore(), - urlOpener: { openedURL = $0 }, - usesSystemWebAuthenticationSession: { false } - ) - await manager.awaitBootstrapped() - - manager.beginSignIn() - XCTAssertNotNil(openedURL) - XCTAssertFalse(manager.isLoading) - - let callbackURL = try XCTUnwrap(URL( - string: "\(AuthEnvironment.callbackScheme)://auth-callback?stack_refresh=refresh-token&stack_access=access-token" - )) - try await manager.handleCallbackURL(callbackURL) - - XCTAssertFalse(manager.isLoading) - XCTAssertTrue(manager.isAuthenticated) - XCTAssertEqual(manager.currentUser?.id, "user_123") - let storedAccessToken = await tokenStore.getStoredAccessToken() - let storedRefreshToken = await tokenStore.getStoredRefreshToken() - XCTAssertEqual(storedAccessToken, "access-token") - XCTAssertEqual(storedRefreshToken, "refresh-token") - let cachedAccessToken = try await manager.getAccessToken() - XCTAssertEqual(cachedAccessToken, "access-token") - - await manager.signOut() - do { - _ = try await manager.getAccessToken() - XCTFail("Expected sign out to clear the cached access token") - } catch AuthManagerError.missingAccessToken { - let storedAccessTokenAfterSignOut = await tokenStore.getStoredAccessToken() - XCTAssertNil(storedAccessTokenAfterSignOut) - } catch { - XCTFail("Unexpected access token error: \(error)") - } - } - - func testBrowserCallbackDoesNotCacheAccessTokenWhenRefreshFails() async throws { - let tokenStore = InMemoryAuthTokenStore() - let manager = AuthManager( - client: FailingRefreshAuthClient(), - tokenStore: tokenStore, - settingsStore: makeIsolatedSettingsStore(), - urlOpener: { _ in }, - usesSystemWebAuthenticationSession: { false } - ) - await manager.awaitBootstrapped() - - let callbackURL = try XCTUnwrap(URL( - string: "\(AuthEnvironment.callbackScheme)://auth-callback?stack_refresh=refresh-token&stack_access=access-token" - )) - - do { - try await manager.handleCallbackURL(callbackURL) - XCTFail("Expected refresh failure to propagate") - } catch AuthManagerError.invalidCallback { - XCTAssertFalse(manager.isAuthenticated) - XCTAssertFalse(manager.isLoading) - do { - _ = try await manager.getAccessToken() - XCTFail("Expected failed callback refresh to leave the fast cache empty") - } catch AuthManagerError.missingAccessToken { - } catch { - XCTFail("Unexpected access token error: \(error)") - } - } catch { - XCTFail("Unexpected callback error: \(error)") - } - } - - func testBrowserCallbackClearsPreviousFastAccessTokenWhenRefreshFails() async throws { - let tokenStore = InMemoryAuthTokenStore() - let manager = AuthManager( - client: FailingRefreshAuthClient(), - tokenStore: tokenStore, - settingsStore: makeIsolatedSettingsStore(), - urlOpener: { _ in }, - usesSystemWebAuthenticationSession: { false } - ) - await manager.awaitBootstrapped() - - manager.applySignInResult(AuthManager.SignInResult( - accessToken: "old-access-token", - refreshToken: "old-refresh-token", - email: "old@example.com", - displayName: "Old User", - userId: "old_user", - selectedTeamId: nil, - teams: [] - )) - let previousAccessToken = try await manager.getAccessToken() - XCTAssertEqual(previousAccessToken, "old-access-token") - - let callbackURL = try XCTUnwrap(URL( - string: "\(AuthEnvironment.callbackScheme)://auth-callback?stack_refresh=refresh-token&stack_access=access-token" - )) - - do { - try await manager.handleCallbackURL(callbackURL) - XCTFail("Expected refresh failure to propagate") - } catch AuthManagerError.invalidCallback { - do { - _ = try await manager.getAccessToken() - XCTFail("Expected failed callback refresh to clear the previous fast cache") - } catch AuthManagerError.missingAccessToken { - } catch { - XCTFail("Unexpected access token error: \(error)") - } - } catch { - XCTFail("Unexpected callback error: \(error)") - } - } -} diff --git a/cmuxTests/BrowserArrowKeyForwardingTests.swift b/cmuxTests/BrowserArrowKeyForwardingTests.swift index 162d57cfe89d..94664ffce29d 100644 --- a/cmuxTests/BrowserArrowKeyForwardingTests.swift +++ b/cmuxTests/BrowserArrowKeyForwardingTests.swift @@ -1,6 +1,5 @@ import XCTest import AppKit -import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -28,259 +27,3 @@ final class BrowserArrowKeyForwardingTests: XCTestCase { XCTAssertFalse(shouldDispatchBrowserArrowViaFirstResponderKeyDown(keyCode: 125, firstResponderIsBrowser: true, flags: [.command])) } } - -@MainActor -final class BrowserReturnKeyForwardingTests: XCTestCase { - private final class RecordingWKWebView: WKWebView { - var keyDownCallCount = 0 - var lastKeyCode: UInt16? - var reentrantPerformKeyEquivalentEvent: NSEvent? - var reentrantPerformKeyEquivalentResult: Bool? - - override var acceptsFirstResponder: Bool { true } - - override func keyDown(with event: NSEvent) { - keyDownCallCount += 1 - lastKeyCode = event.keyCode - if let reentrantPerformKeyEquivalentEvent { - reentrantPerformKeyEquivalentResult = window?.performKeyEquivalent(with: reentrantPerformKeyEquivalentEvent) - } - } - } - - private final class FocusableWebSubview: NSView { - override var acceptsFirstResponder: Bool { true } - } - - private final class DetachedFieldEditor: NSTextView { - override var acceptsFirstResponder: Bool { true } - - override var isFieldEditor: Bool { - get { true } - set {} - } - } - - private func makeKeyEvent( - windowNumber: Int, - keyCode: UInt16, - modifierFlags: NSEvent.ModifierFlags = [], - characters: String = "\r", - charactersIgnoringModifiers: String = "\r", - timestamp: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> NSEvent { - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifierFlags, - timestamp: timestamp, - windowNumber: windowNumber, - context: nil, - characters: characters, - charactersIgnoringModifiers: charactersIgnoringModifiers, - isARepeat: false, - keyCode: keyCode - ) else { - fatalError("Failed to construct key event") - } - return event - } - - func testRoutesPlainReturnFromEmbeddedWKWebViewResponderToKeyDownAndConsumesIt() { - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { window.close() } - - let webView = RecordingWKWebView(frame: window.contentView?.bounds ?? NSRect(x: 0, y: 0, width: 320, height: 240)) - let webSubview = FocusableWebSubview(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) - webView.addSubview(webSubview) - window.contentView = webView - - XCTAssertTrue(window.makeFirstResponder(webSubview)) - - let event = makeKeyEvent(windowNumber: window.windowNumber, keyCode: 36) - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual(webView.keyDownCallCount, 1) - XCTAssertEqual(webView.lastKeyCode, 36) - } - - func testRoutesPlainArrowFromEmbeddedWKWebViewResponderToKeyDown() { - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { window.close() } - - let webView = RecordingWKWebView(frame: window.contentView?.bounds ?? NSRect(x: 0, y: 0, width: 320, height: 240)) - let webSubview = FocusableWebSubview(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) - webView.addSubview(webSubview) - window.contentView = webView - - XCTAssertTrue(window.makeFirstResponder(webSubview)) - - let event = makeKeyEvent(windowNumber: window.windowNumber, keyCode: 123) - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual(webView.keyDownCallCount, 1) - XCTAssertEqual(webView.lastKeyCode, 123) - } - - func testRoutesReturnFromDetachedFieldEditorOwnerResponderChainToEmbeddedWKWebView() { - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { window.close() } - - let webView = RecordingWKWebView(frame: window.contentView?.bounds ?? NSRect(x: 0, y: 0, width: 320, height: 240)) - window.contentView = webView - - let ownerView = FocusableWebSubview(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) - ownerView.nextResponder = webView - - let fieldEditor = DetachedFieldEditor(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) - fieldEditor.nextResponder = ownerView - - XCTAssertTrue(window.makeFirstResponder(fieldEditor)) - fieldEditor.nextResponder = ownerView - - let event = makeKeyEvent(windowNumber: window.windowNumber, keyCode: 36) - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual(webView.keyDownCallCount, 1) - XCTAssertEqual(webView.lastKeyCode, 36) - } - - func testConsumesReentrantReturnDuringForwardedBrowserKeyDown() { - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { window.close() } - - let webView = RecordingWKWebView(frame: window.contentView?.bounds ?? NSRect(x: 0, y: 0, width: 320, height: 240)) - let webSubview = FocusableWebSubview(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) - webView.addSubview(webSubview) - window.contentView = webView - - XCTAssertTrue(window.makeFirstResponder(webSubview)) - - let event = makeKeyEvent(windowNumber: window.windowNumber, keyCode: 36) - webView.reentrantPerformKeyEquivalentEvent = event - - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual(webView.keyDownCallCount, 1) - XCTAssertEqual(webView.lastKeyCode, 36) - XCTAssertEqual(webView.reentrantPerformKeyEquivalentResult, true) - } - - func testDoesNotConsumeDifferentReentrantReturnDuringForwardedBrowserKeyDown() { - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { window.close() } - - let webView = RecordingWKWebView(frame: window.contentView?.bounds ?? NSRect(x: 0, y: 0, width: 320, height: 240)) - let webSubview = FocusableWebSubview(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) - webView.addSubview(webSubview) - window.contentView = webView - - XCTAssertTrue(window.makeFirstResponder(webSubview)) - - let event = makeKeyEvent(windowNumber: window.windowNumber, keyCode: 36, timestamp: 100) - webView.reentrantPerformKeyEquivalentEvent = makeKeyEvent( - windowNumber: window.windowNumber, - keyCode: 36, - timestamp: 101 - ) - - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual(webView.keyDownCallCount, 1) - XCTAssertEqual(webView.lastKeyCode, 36) - XCTAssertEqual(webView.reentrantPerformKeyEquivalentResult, false) - } - - func testDoesNotConsumeReentrantNonReturnDuringForwardedBrowserKeyDown() { - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { window.close() } - - let webView = RecordingWKWebView(frame: window.contentView?.bounds ?? NSRect(x: 0, y: 0, width: 320, height: 240)) - let webSubview = FocusableWebSubview(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) - webView.addSubview(webSubview) - window.contentView = webView - - XCTAssertTrue(window.makeFirstResponder(webSubview)) - - let returnEvent = makeKeyEvent(windowNumber: window.windowNumber, keyCode: 36) - webView.reentrantPerformKeyEquivalentEvent = makeKeyEvent( - windowNumber: window.windowNumber, - keyCode: 13, - characters: "w", - charactersIgnoringModifiers: "w" - ) - - XCTAssertTrue(window.performKeyEquivalent(with: returnEvent)) - XCTAssertEqual(webView.keyDownCallCount, 1) - XCTAssertEqual(webView.reentrantPerformKeyEquivalentResult, false) - } - - func testReturnForwardingKeepsShortcutAndIMECasesOutOfTheForcedPath() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: false, - flags: [] - ) - ) - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - firstResponderHasMarkedText: true, - flags: [] - ) - ) - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.command] - ) - ) - XCTAssertTrue( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 76, - firstResponderIsBrowser: true, - flags: [.shift] - ) - ) - } -} diff --git a/cmuxTests/BrowserFindJavaScriptTests.swift b/cmuxTests/BrowserFindJavaScriptTests.swift index d0b0d47f4c4c..ee5c24738f24 100644 --- a/cmuxTests/BrowserFindJavaScriptTests.swift +++ b/cmuxTests/BrowserFindJavaScriptTests.swift @@ -156,8 +156,7 @@ final class BrowserPopupDecisionTests: XCTestCase { browserNavigationShouldCreatePopup( navigationType: .other, modifierFlags: [], - buttonNumber: 2, - popupFeaturesWereSpecified: true + buttonNumber: 2 ) ) } diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 1a42d9e6032e..b3ccb265b59a 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -8,7 +8,7 @@ import ObjectiveC.runtime @testable import cmux #endif -@MainActor +private var cjkIMEInterpretKeyEventsSwizzled = false var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)? private var ghosttyPasteActionSwizzled = false private var ghosttyPasteActionHook: ((GhosttyNSView, Any?) -> Void)? @@ -16,6 +16,13 @@ private var ghosttyPasteAsPlainTextActionSwizzled = false private var ghosttyPasteAsPlainTextActionHook: ((GhosttyNSView, Any?) -> Void)? private extension GhosttyNSView { + @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) { + if let hook = cjkIMEInterpretKeyEventsHook, hook(self, eventArray) { + return + } + cmuxUnitTest_interpretKeyEvents(eventArray) + } + @objc func cmuxUnitTest_paste(_ sender: Any?) { ghosttyPasteActionHook?(self, sender) cmuxUnitTest_paste(sender) @@ -27,15 +34,36 @@ private extension GhosttyNSView { } } -@MainActor func installCJKIMEInterpretKeyEventsSwizzle() { - GhosttyNSView.debugTextInputEventHandler = { candidateView, event in - if let hook = cjkIMEInterpretKeyEventsHook, hook(candidateView, [event]) { - return true - } - candidateView.interpretKeyEvents([event]) - return false + guard !cjkIMEInterpretKeyEventsSwizzled else { return } + + let originalSelector = #selector(GhosttyNSView.interpretKeyEvents(_:)) + let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_interpretKeyEvents(_:)) + + guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else { + fatalError("Unable to locate GhosttyNSView interpretKeyEvents methods for swizzling") + } + + let didAddMethod = class_addMethod( + GhosttyNSView.self, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + GhosttyNSView.self, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) } + + cjkIMEInterpretKeyEventsSwizzled = true } private func installGhosttyPasteActionSwizzle() { @@ -1122,10 +1150,6 @@ final class KoreanIMEReturnCommitRegressionTests: XCTestCase { view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0)) - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler - // Simulate Korean input source so shouldSendCommittedIMEConfirmKey fires KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.Korean.2SetKorean" installCJKIMEInterpretKeyEventsSwizzle() @@ -1135,9 +1159,8 @@ final class KoreanIMEReturnCommitRegressionTests: XCTestCase { return true } defer { - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler + KeyboardLayout.debugInputSourceIdOverride = nil + cjkIMEInterpretKeyEventsHook = nil } var sawReturnPress = false @@ -1184,9 +1207,6 @@ final class KoreanIMEMarkedTextLeakRegressionTests: XCTestCase { workingDirectory: nil ) let hostedView = surface.hostedView - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), @@ -1196,9 +1216,8 @@ final class KoreanIMEMarkedTextLeakRegressionTests: XCTestCase { ) defer { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler + KeyboardLayout.debugInputSourceIdOverride = nil + cjkIMEInterpretKeyEventsHook = nil window.orderOut(nil) } diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index f47a5e730403..982956c1a7ec 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -1,6 +1,5 @@ import XCTest import AppKit -import Carbon.HIToolbox #if canImport(cmux_DEV) @testable import cmux_DEV @@ -56,17 +55,11 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } - private func keyEvent( - type: NSEvent.EventType = .keyDown, - text: String, - keyCode: UInt16, - modifierFlags: NSEvent.ModifierFlags = [], - windowNumber: Int - ) throws -> NSEvent { + private func keyEvent(text: String, keyCode: UInt16, windowNumber: Int) throws -> NSEvent { try XCTUnwrap(NSEvent.keyEvent( - with: type, + with: .keyDown, location: .zero, - modifierFlags: modifierFlags, + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: windowNumber, context: nil, @@ -77,54 +70,6 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { )) } - private struct NoMarkedIMEKeyProbe { - let name: String - let text: String - let keyCode: UInt16 - } - - private var noMarkedNavigationKeyProbes: [NoMarkedIMEKeyProbe] { - [ - NoMarkedIMEKeyProbe(name: "Left", text: "\u{F702}", keyCode: UInt16(kVK_LeftArrow)), - NoMarkedIMEKeyProbe(name: "Right", text: "\u{F703}", keyCode: UInt16(kVK_RightArrow)), - NoMarkedIMEKeyProbe(name: "Up", text: "\u{F700}", keyCode: UInt16(kVK_UpArrow)), - NoMarkedIMEKeyProbe(name: "Down", text: "\u{F701}", keyCode: UInt16(kVK_DownArrow)), - NoMarkedIMEKeyProbe(name: "Home", text: "\u{F729}", keyCode: UInt16(kVK_Home)), - NoMarkedIMEKeyProbe(name: "End", text: "\u{F72B}", keyCode: UInt16(kVK_End)), - NoMarkedIMEKeyProbe(name: "PageUp", text: "\u{F72C}", keyCode: UInt16(kVK_PageUp)), - NoMarkedIMEKeyProbe(name: "PageDown", text: "\u{F72D}", keyCode: UInt16(kVK_PageDown)), - NoMarkedIMEKeyProbe(name: "Space", text: " ", keyCode: UInt16(kVK_Space)), - ] - } - - private func assertInputSourceDoesNotSwallowNoMarkedIMECommandKeys( - _ inputSourceId: String, - file: StaticString = #filePath, - line: UInt = #line - ) throws { - let view = GhosttyNSView(frame: .zero) - - for probe in noMarkedNavigationKeyProbes { - let event = try keyEvent(text: probe.text, keyCode: probe.keyCode, windowNumber: 0) - - XCTAssertFalse( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "", - markedSelectionBefore: NSRange(location: NSNotFound, length: 0), - markedTextAfter: "", - markedSelectionAfter: NSRange(location: NSNotFound, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: true, - inputSourceId: inputSourceId - ), - "\(inputSourceId) should forward \(probe.name) to Ghostty when no composition is active", - file: file, - line: line - ) - } - } - func testSelectedRangeReturnsEmptyRangeWithoutSelectionOrMarkedText() { let view = GhosttyNSView(frame: .zero) let range = view.selectedRange() @@ -220,12 +165,10 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride let previousInterpretHook = cjkIMEInterpretKeyEventsHook - let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler defer { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride cjkIMEInterpretKeyEventsHook = previousInterpretHook - GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler window.orderOut(nil) withExtendedLifetime(terminalSurface) {} } @@ -305,367 +248,4 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) ) } - - func testZhuyinReturnForwardsToTerminalAfterCompositionIsCleared() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - surfaceView.setMarkedText( - "ㄋ", - selectedRange: NSRange(location: 1, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - surfaceView.unmarkText() - XCTAssertFalse(surfaceView.hasMarkedText()) - - installCJKIMEInterpretKeyEventsSwizzle() - cjkIMEInterpretKeyEventsHook = { candidateView, events in - guard candidateView === surfaceView, - let event = events.first, - Int(event.keyCode) == kVK_Return else { - return false - } - return true - } - - var forwardedText: [String] = [] - var forwardedPressKeyCodes: [UInt32] = [] - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS else { return } - if let text = keyEvent.text { - forwardedText.append(String(cString: text)) - } else { - forwardedPressKeyCodes.append(keyEvent.keycode) - } - } - - let event = try keyEvent( - text: "\r", - keyCode: UInt16(kVK_Return), - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: event) - } - - XCTAssertEqual(forwardedText, []) - XCTAssertEqual( - forwardedPressKeyCodes, - [UInt32(kVK_Return)], - "Plain Return after Zhuyin composition is cleared must execute in the terminal" - ) - } - - func testDoesNotSuppressNonInputMethodDeadKeyCommandWhenMarkedTextIsUnchanged() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertFalse( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "`", - markedSelectionBefore: NSRange(location: 1, length: 0), - markedTextAfter: "`", - markedSelectionAfter: NSRange(location: 1, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: true, - inputSourceId: "com.apple.keylayout.USInternational" - ) - ) - } - - func testDoesNotRouteNonInputMethodDeadKeyMarkedTextThroughKeyDown() throws { - let view = GhosttyNSView(frame: .zero) - view.setMarkedText( - "`", - selectedRange: NSRange(location: 1, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertFalse( - view.shouldRouteTextInputKeyEquivalentToKeyDown( - event, - inputSourceId: "com.apple.keylayout.USInternational" - ), - "Dead-key marked text should keep normal AppKit key-equivalent dispatch" - ) - } - - func testRoutesInputMethodMarkedTextCommandThroughKeyDown() throws { - let view = GhosttyNSView(frame: .zero) - view.setMarkedText( - "ㄓㄨ", - selectedRange: NSRange(location: 2, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertTrue( - view.shouldRouteTextInputKeyEquivalentToKeyDown( - event, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ), - "Input-method marked text should still route command keys through keyDown" - ) - } - - func testSuppressesInputMethodCommandWhenMarkedTextIsUnchanged() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertTrue( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "ㄓㄨ", - markedSelectionBefore: NSRange(location: 2, length: 0), - markedTextAfter: "ㄓㄨ", - markedSelectionAfter: NSRange(location: 2, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: false, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ) - ) - } - - func testKoreanInputSourceDoesNotSwallowNavigationAndSpaceKeysWithoutComposition() throws { - try assertInputSourceDoesNotSwallowNoMarkedIMECommandKeys( - "com.apple.inputmethod.Korean.2SetKorean" - ) - } - - func testJapaneseInputSourceDoesNotSwallowNavigationAndSpaceKeysWithoutComposition() throws { - try assertInputSourceDoesNotSwallowNoMarkedIMECommandKeys( - "com.apple.inputmethod.Kotoeri.Japanese" - ) - } - - func testSimplifiedChinesePinyinDoesNotSwallowNavigationAndSpaceKeysWithoutComposition() throws { - try assertInputSourceDoesNotSwallowNoMarkedIMECommandKeys( - "com.apple.inputmethod.SCIM.ITABC" - ) - } - - func testCangjieDoesNotSwallowNavigationAndSpaceKeysWithoutComposition() throws { - try assertInputSourceDoesNotSwallowNoMarkedIMECommandKeys( - "com.apple.inputmethod.TCIM.Cangjie" - ) - } - - func testZhuyinPreCompositionStillUsesNoMarkedTextSuppression() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertTrue( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "", - markedSelectionBefore: NSRange(location: NSNotFound, length: 0), - markedTextAfter: "", - markedSelectionAfter: NSRange(location: NSNotFound, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: true, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ) - ) - } - - func testZhuyinNumpadInputForwardsWithoutMarkedText() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "1", - keyCode: UInt16(kVK_ANSI_Keypad1), - modifierFlags: [.numericPad], - windowNumber: 0 - ) - - XCTAssertFalse( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "", - markedSelectionBefore: NSRange(location: NSNotFound, length: 0), - markedTextAfter: "", - markedSelectionAfter: NSRange(location: NSNotFound, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: true, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ) - ) - } - - func testForwardedKeyDownClearsStaleIMESuppressedKeyUpEntry() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - let keyCode = UInt16(kVK_DownArrow) - surfaceView.setIMETransientStateForTesting(suppressedKeyUpKeyCodes: [keyCode]) - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - GhosttyNSView.debugTextInputEventHandler = { _, _ in false } - - var forwardedPressCount = 0 - var forwardedReleaseCount = 0 - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.keycode == UInt32(keyCode) else { return } - if keyEvent.action == GHOSTTY_ACTION_PRESS { - forwardedPressCount += 1 - } else if keyEvent.action == GHOSTTY_ACTION_RELEASE { - forwardedReleaseCount += 1 - } - } - - let keyDown = try keyEvent( - text: "\u{F701}", - keyCode: keyCode, - windowNumber: window.windowNumber - ) - let keyUp = try keyEvent( - type: .keyUp, - text: "\u{F701}", - keyCode: keyCode, - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: keyDown) - } - - XCTAssertEqual(forwardedPressCount, 1) - XCTAssertFalse( - surfaceView.imeSuppressedKeyUpKeyCodesForTesting.contains(keyCode), - "Forwarded keyDown must clear stale IME keyUp suppression for the same key" - ) - - withExtendedLifetime(terminalSurface) { - surfaceView.keyUp(with: keyUp) - } - - XCTAssertEqual( - forwardedReleaseCount, - 1, - "The eventual keyUp must reach Ghostty after a forwarded keyDown clears stale suppression" - ) - } - - func testLayoutChangeIMEHandledKeyDownSuppressesMatchingKeyUp() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - let keyCode = UInt16(kVK_ANSI_5) - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - GhosttyNSView.debugTextInputEventHandler = { candidateView, _ in - guard candidateView === surfaceView else { return false } - KeyboardLayout.debugInputSourceIdOverride = "com.apple.keylayout.US" - return true - } - - var forwardedPressCount = 0 - var forwardedReleaseCount = 0 - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.keycode == UInt32(keyCode) else { return } - if keyEvent.action == GHOSTTY_ACTION_PRESS { - forwardedPressCount += 1 - } else if keyEvent.action == GHOSTTY_ACTION_RELEASE { - forwardedReleaseCount += 1 - } - } - - let keyDown = try keyEvent( - text: "5", - keyCode: keyCode, - windowNumber: window.windowNumber - ) - let keyUp = try keyEvent( - type: .keyUp, - text: "5", - keyCode: keyCode, - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: keyDown) - } - - XCTAssertEqual(forwardedPressCount, 0) - XCTAssertTrue( - surfaceView.imeSuppressedKeyUpKeyCodesForTesting.contains(keyCode), - "Layout-change IME handling consumes keyDown and must suppress the matching keyUp" - ) - - withExtendedLifetime(terminalSurface) { - surfaceView.keyUp(with: keyUp) - } - - XCTAssertEqual( - forwardedReleaseCount, - 0, - "IME-consumed layout-change keyDown must not leave Ghostty with an unmatched release" - ) - XCTAssertFalse(surfaceView.imeSuppressedKeyUpKeyCodesForTesting.contains(keyCode)) - } } diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 337dbe9d7e3a..6a5fa9f835a8 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -7,6 +7,264 @@ import Darwin #endif final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { + func testClaudeClearSessionStartMarksWorkspaceRunning() throws { + let context = try makeClaudeHookContext(name: "claude-clear-running") + defer { context.cleanup() } + + let result = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"clear-session","source":"clear","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + XCTAssertTrue( + context.state.commands.contains { $0 == "clear_notifications --tab=\(context.workspaceId)" }, + "Expected clear SessionStart to clear stale notifications, saw \(context.state.commands)" + ) + XCTAssertTrue( + context.state.commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected clear SessionStart to mark Claude running, saw \(context.state.commands)" + ) + } + + func testClaudeStopFromPreviousSessionDoesNotClobberClearRunningStatus() throws { + let context = try makeClaudeHookContext(name: "claude-clear-stale-stop") + defer { context.cleanup() } + + let oldStart = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(oldStart.timedOut, oldStart.stderr) + XCTAssertEqual(oldStart.status, 0, oldStart.stderr) + + let clearStart = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"clear-session","source":"clear","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(clearStart.timedOut, clearStart.stderr) + XCTAssertEqual(clearStart.status, 0, clearStart.stderr) + + let lateOldStart = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"old-session","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(lateOldStart.timedOut, lateOldStart.stderr) + XCTAssertEqual(lateOldStart.status, 0, lateOldStart.stderr) + + let staleStop = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "stop"], + standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old turn finished late"}"# + ) + XCTAssertFalse(staleStop.timedOut, staleStop.stderr) + XCTAssertEqual(staleStop.status, 0, staleStop.stderr) + + XCTAssertTrue( + context.state.commands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected clear SessionStart to mark Claude running, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { + $0.hasPrefix("set_status claude_code Idle ") && $0.contains("--tab=\(context.workspaceId)") + }, + "Expected stale Stop from old session not to clobber the clear session, saw \(context.state.commands)" + ) + } + + func testClaudePromptSubmitFromNewSessionCanReplaceStoppedSession() throws { + let context = try makeClaudeHookContext(name: "claude-new-session-after-stop") + defer { context.cleanup() } + + let oldStart = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(oldStart.timedOut, oldStart.stderr) + XCTAssertEqual(oldStart.status, 0, oldStart.stderr) + + let oldPrompt = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "prompt-submit"], + standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"# + ) + XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + + let oldStop = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "stop"], + standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old turn finished"}"# + ) + XCTAssertFalse(oldStop.timedOut, oldStop.stderr) + XCTAssertEqual(oldStop.status, 0, oldStop.stderr) + + let newStart = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-start"], + standardInput: #"{"session_id":"new-session","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# + ) + XCTAssertFalse(newStart.timedOut, newStart.stderr) + XCTAssertEqual(newStart.status, 0, newStart.stderr) + + let newPromptStart = context.state.commands.count + let newPrompt = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "prompt-submit"], + standardInput: #"{"session_id":"new-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"# + ) + XCTAssertFalse(newPrompt.timedOut, newPrompt.stderr) + XCTAssertEqual(newPrompt.status, 0, newPrompt.stderr) + + let newPromptCommands = Array(context.state.commands.dropFirst(newPromptStart)) + XCTAssertTrue( + newPromptCommands.contains { + $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") + }, + "Expected a new Claude session to replace a stopped idle owner on prompt-submit, saw \(newPromptCommands)" + ) + } + + func testClaudeSessionEndChecksConsumedWorkspaceBeforeClearingVisibleState() throws { + let context = try makeClaudeHookContext(name: "claude-stale-session-end-workspace") + defer { context.cleanup() } + + let staleWorkspaceId = "33333333-3333-3333-3333-333333333333" + let activeSurfaceId = "44444444-4444-4444-4444-444444444444" + let staleSessionId = "stale-session" + let activeSessionId = "active-session" + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + staleSessionId: [ + "sessionId": staleSessionId, + "workspaceId": staleWorkspaceId, + "surfaceId": context.surfaceId, + "cwd": context.root.path, + "startedAt": now, + "updatedAt": now, + ], + activeSessionId: [ + "sessionId": activeSessionId, + "workspaceId": staleWorkspaceId, + "surfaceId": activeSurfaceId, + "cwd": context.root.path, + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [ + staleWorkspaceId: [ + "sessionId": activeSessionId, + "updatedAt": now, + ], + ], + ] + let stateURL = context.root.appendingPathComponent("claude-hook-sessions.json") + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: stateURL, options: .atomic) + + let result = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-end"], + standardInput: #"{"cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"# + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK\n") + let savedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let savedSessions = try XCTUnwrap(savedState["sessions"] as? [String: Any]) + XCTAssertNil( + savedSessions[staleSessionId], + "Expected fallback session-end handling to consume the seeded stale session" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("clear_status claude_code ") && $0.contains("--tab=\(staleWorkspaceId)") }, + "Expected stale SessionEnd not to clear the consumed workspace, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("clear_agent_pid claude_code ") && $0.contains("--tab=\(staleWorkspaceId)") }, + "Expected stale SessionEnd not to clear the consumed workspace PID, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0 == "clear_notifications --tab=\(staleWorkspaceId)" }, + "Expected stale SessionEnd not to clear the consumed workspace notifications, saw \(context.state.commands)" + ) + } + + func testClaudeSessionEndDoesNotConsumeSameSessionStaleTurn() throws { + let context = try makeClaudeHookContext(name: "claude-stale-session-end-turn") + defer { context.cleanup() } + + let sessionId = "same-session" + let now = Date().timeIntervalSince1970 + let store: [String: Any] = [ + "version": 1, + "sessions": [ + sessionId: [ + "sessionId": sessionId, + "workspaceId": context.workspaceId, + "surfaceId": context.surfaceId, + "cwd": context.root.path, + "startedAt": now, + "updatedAt": now, + ], + ], + "activeSessionsByWorkspace": [ + context.workspaceId: [ + "sessionId": sessionId, + "turnId": "turn-2", + "updatedAt": now, + ], + ], + ] + let stateURL = context.root.appendingPathComponent("claude-hook-sessions.json") + try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) + .write(to: stateURL, options: .atomic) + + let result = runClaudeHook( + context: context, + arguments: ["hooks", "claude", "session-end"], + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"# + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertFalse( + context.state.commands.contains { $0.hasPrefix("clear_agent_pid claude_code ") && $0.contains("--tab=\(context.workspaceId)") }, + "Expected stale same-session turn not to clear current PID, saw \(context.state.commands)" + ) + XCTAssertFalse( + context.state.commands.contains { $0 == "clear_notifications --tab=\(context.workspaceId)" }, + "Expected stale same-session turn not to clear current notifications, saw \(context.state.commands)" + ) + + let savedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let savedSessions = try XCTUnwrap(savedState["sessions"] as? [String: Any]) + XCTAssertNotNil( + savedSessions[sessionId], + "Expected stale same-session SessionEnd not to consume the active session" + ) + let activeSessions = try XCTUnwrap(savedState["activeSessionsByWorkspace"] as? [String: Any]) + let active = try XCTUnwrap(activeSessions[context.workspaceId] as? [String: Any]) + XCTAssertEqual(active["turnId"] as? String, "turn-2") + } + func testRightSidebarCLIForwardsV1SocketCommandsQuietly() throws { let cliPath = try bundledCLIPath() let cases: [(name: String, arguments: [String], expectedCommand: String, response: String, stdout: String)] = [ @@ -664,6 +922,80 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + private struct ClaudeHookContext { + let cliPath: String + let socketPath: String + let listenerFD: Int32 + let state: MockSocketServerState + let root: URL + let workspaceId: String + let surfaceId: String + + func cleanup() { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + } + + private func makeClaudeHookContext(name: String) throws -> ClaudeHookContext { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) + let socketPath = makeSocketPath(String(name.prefix(6))) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return ClaudeHookContext( + cliPath: try bundledCLIPath(), + socketPath: socketPath, + listenerFD: try bindUnixSocket(at: socketPath), + state: MockSocketServerState(), + root: root, + workspaceId: "11111111-1111-1111-1111-111111111111", + surfaceId: "22222222-2222-2222-2222-222222222222" + ) + } + + private func runClaudeHook( + context: ClaudeHookContext, + arguments: [String], + standardInput: String + ) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: context.listenerFD, state: context.state) { line in + guard let payload = self.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: context.surfaceId) + case "feed.push": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + let result = runProcess( + executablePath: context.cliPath, + arguments: arguments, + environment: [ + "HOME": context.root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": context.socketPath, + "CMUX_WORKSPACE_ID": context.workspaceId, + "CMUX_SURFACE_ID": context.surfaceId, + "CMUX_CLAUDE_HOOK_STATE_PATH": context.root.appendingPathComponent("claude-hook-sessions.json").path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1", + ], + standardInput: standardInput, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + func testBrowserImportDefaultsNonInteractiveInCodingAgent() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("browser-import-agent") diff --git a/cmuxTests/InactivePaneFirstClickFocusTests.swift b/cmuxTests/InactivePaneFirstClickFocusTests.swift index 7bdd14e97f1e..17bdc0206da0 100644 --- a/cmuxTests/InactivePaneFirstClickFocusTests.swift +++ b/cmuxTests/InactivePaneFirstClickFocusTests.swift @@ -1,6 +1,5 @@ import XCTest import AppKit -import SwiftUI import WebKit #if canImport(cmux_DEV) @@ -70,301 +69,4 @@ final class InactivePaneFirstClickFocusTests: XCTestCase { XCTAssertFalse(view.acceptsFirstMouse(for: nil)) } - - func testMinimalModeSidebarControlsAcceptFirstMouseWhenSettingEnabled() { - UserDefaults.standard.set(true, forKey: settingsKey) - - let view = MinimalModeSidebarControlActionView(frame: NSRect(x: 0, y: 0, width: 120, height: 32)) - - XCTAssertTrue(view.acceptsFirstMouse(for: nil)) - } - - func testMinimalModeSidebarControlsRejectFirstMouseWhenSettingDisabled() { - UserDefaults.standard.set(false, forKey: settingsKey) - - let view = MinimalModeSidebarControlActionView(frame: NSRect(x: 0, y: 0, width: 120, height: 32)) - - XCTAssertFalse(view.acceptsFirstMouse(for: nil)) - } - - func testPDFChromeHostingViewAcceptsFirstMouseWhenSettingEnabled() { - UserDefaults.standard.set(true, forKey: settingsKey) - - let view = FilePreviewPDFChromeHostingView(rootView: AnyView(EmptyView())) - - XCTAssertTrue(view.acceptsFirstMouse(for: nil)) - } - - func testPDFChromeHostingViewRejectsFirstMouseWhenSettingDisabled() { - UserDefaults.standard.set(false, forKey: settingsKey) - - let view = FilePreviewPDFChromeHostingView(rootView: AnyView(EmptyView())) - - XCTAssertFalse(view.acceptsFirstMouse(for: nil)) - } - - private struct SidebarFirstClickHarness { - let tabManager: TabManager - let initialWorkspace: Workspace - let targetWorkspace: Workspace - let window: NSWindow - let keyWindow: NSWindow - let host: NSView - - @MainActor - func close() { - window.orderOut(nil) - keyWindow.orderOut(nil) - } - } - - private enum SidebarFirstClickHarnessError: Error, CustomStringConvertible { - case missingAccessibilityElement(String) - case missingAccessibilityFrame(String) - - var description: String { - switch self { - case .missingAccessibilityElement(let identifier): - return "Missing sidebar accessibility element \(identifier)" - case .missingAccessibilityFrame(let identifier): - return "Missing sidebar accessibility frame \(identifier)" - } - } - } - - private typealias AccessibilityNode = NSAccessibilityElementProtocol & NSAccessibilityProtocol - - private struct InactiveFirstClickResult { - let acceptedFirstMouse: Bool - let hitViewClassName: String - } - - private func makeSidebarFirstClickHarness() throws -> SidebarFirstClickHarness { - let tabManager = TabManager(autoWelcomeIfNeeded: false) - let initialWorkspace = try XCTUnwrap(tabManager.selectedWorkspace) - let targetWorkspace = tabManager.addWorkspace( - title: "Second Workspace", - select: false, - eagerLoadTerminal: false, - autoWelcomeIfNeeded: false - ) - var selection = SidebarSelection.tabs - var selectedTabIds: Set = [initialWorkspace.id] - var lastSidebarSelectionIndex: Int? = 0 - let sidebar = VerticalTabsSidebar( - updateViewModel: UpdateViewModel(), - fileExplorerState: FileExplorerState(), - onSendFeedback: {}, - onToggleSidebar: {}, - onNewTab: {}, - selection: Binding( - get: { selection }, - set: { selection = $0 } - ), - selectedTabIds: Binding( - get: { selectedTabIds }, - set: { selectedTabIds = $0 } - ), - lastSidebarSelectionIndex: Binding( - get: { lastSidebarSelectionIndex }, - set: { lastSidebarSelectionIndex = $0 } - ) - ) - .environmentObject(tabManager) - .environmentObject(TerminalNotificationStore.shared) - let host = NSHostingView(rootView: sidebar) - let frame = NSRect(x: 0, y: 0, width: 240, height: 360) - let window = NSWindow( - contentRect: frame, - styleMask: [.titled], - backing: .buffered, - defer: false - ) - let keyWindow = NSWindow( - contentRect: NSRect(x: 260, y: 0, width: 120, height: 120), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - window.contentView = host - host.frame = frame - window.orderFront(nil) - keyWindow.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertFalse(window.isKeyWindow) - - return SidebarFirstClickHarness( - tabManager: tabManager, - initialWorkspace: initialWorkspace, - targetWorkspace: targetWorkspace, - window: window, - keyWindow: keyWindow, - host: host - ) - } - - private func findAccessibilityElement( - identifier: String, - in root: Any, - visited: inout Set - ) -> AccessibilityNode? { - guard let element = root as? AccessibilityNode, - let object = root as? NSObject else { - return nil - } - let objectIdentifier = ObjectIdentifier(object) - guard visited.insert(objectIdentifier).inserted else { return nil } - - if let elementIdentifier = element.accessibilityIdentifier?(), elementIdentifier == identifier { - return element - } - - let children = object.accessibilityAttributeValue(.children) as? [Any] ?? [] - for child in children { - if let match = findAccessibilityElement(identifier: identifier, in: child, visited: &visited) { - return match - } - } - - let visibleChildren = object.accessibilityAttributeValue(.visibleChildren) as? [Any] ?? [] - for child in visibleChildren { - if let match = findAccessibilityElement(identifier: identifier, in: child, visited: &visited) { - return match - } - } - - return nil - } - - private func targetRowPoint(in harness: SidebarFirstClickHarness) throws -> NSPoint { - let identifier = "sidebarWorkspace.\(harness.targetWorkspace.id.uuidString)" - var visited = Set() - guard let targetElement = findAccessibilityElement( - identifier: identifier, - in: harness.host, - visited: &visited - ) else { - throw SidebarFirstClickHarnessError.missingAccessibilityElement(identifier) - } - let targetFrame = targetElement.accessibilityFrame() - guard !targetFrame.isEmpty else { - throw SidebarFirstClickHarnessError.missingAccessibilityFrame(identifier) - } - - let screenCenter = NSPoint(x: targetFrame.midX, y: targetFrame.midY) - let windowPoint = harness.window.convertPoint(fromScreen: screenCenter) - XCTAssertTrue( - harness.host.bounds.contains(windowPoint), - "Expected \(identifier) center \(windowPoint) to be inside the sidebar host." - ) - return windowPoint - } - - private func mouseEvent( - _ type: NSEvent.EventType, - at point: NSPoint, - in window: NSWindow, - eventNumber: Int, - pressure: Float - ) throws -> NSEvent { - try XCTUnwrap(NSEvent.mouseEvent( - with: type, - location: point, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: eventNumber, - clickCount: 1, - pressure: pressure - )) - } - - private func sendClick(at point: NSPoint, in window: NSWindow) throws { - NSApp.sendEvent(try mouseEvent(.leftMouseDown, at: point, in: window, eventNumber: 1, pressure: 1)) - NSApp.sendEvent(try mouseEvent(.leftMouseUp, at: point, in: window, eventNumber: 2, pressure: 0)) - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) - } - - private func dispatchInactiveFirstClick(in harness: SidebarFirstClickHarness) throws -> InactiveFirstClickResult { - let targetRowPoint = try targetRowPoint(in: harness) - let downEvent = try XCTUnwrap(NSEvent.mouseEvent( - with: .leftMouseDown, - location: targetRowPoint, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: harness.window.windowNumber, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 1 - )) - let hitView = try XCTUnwrap(harness.host.hitTest(targetRowPoint)) - let acceptedFirstMouse = hitView.acceptsFirstMouse(for: downEvent) - - try sendClick(at: targetRowPoint, in: harness.window) - return InactiveFirstClickResult( - acceptedFirstMouse: acceptedFirstMouse, - hitViewClassName: String(describing: type(of: hitView)) - ) - } - - func testWorkspaceSidebarClickSwitchesWorkspaceWhenWindowIsActive() throws { - UserDefaults.standard.set(true, forKey: settingsKey) - - let harness = try makeSidebarFirstClickHarness() - defer { harness.close() } - - harness.window.makeKeyAndOrderFront(nil) - harness.window.displayIfNeeded() - harness.host.layoutSubtreeIfNeeded() - - XCTAssertTrue(harness.window.isKeyWindow) - try sendClick(at: try targetRowPoint(in: harness), in: harness.window) - XCTAssertEqual(harness.tabManager.selectedTabId, harness.targetWorkspace.id) - } - - func testWorkspaceSidebarInactiveFirstClickSwitchesWorkspaceWhenSettingEnabled() throws { - UserDefaults.standard.set(true, forKey: settingsKey) - - let harness = try makeSidebarFirstClickHarness() - defer { harness.close() } - - let result = try dispatchInactiveFirstClick(in: harness) - - XCTAssertTrue( - result.acceptedFirstMouse, - "Inactive first click on workspace \(harness.targetWorkspace.id) should pass through when the setting is enabled." - ) - XCTAssertTrue(harness.window.isKeyWindow) - XCTAssertFalse( - result.hitViewClassName.contains("FirstMouseGated"), - "Enabled inactive sidebar click should not be captured by the production first-mouse gate, got \(result.hitViewClassName)." - ) - XCTAssertEqual(harness.tabManager.selectedTabId, harness.targetWorkspace.id) - XCTAssertNotEqual(harness.tabManager.selectedTabId, harness.initialWorkspace.id) - } - - func testWorkspaceSidebarInactiveFirstClickDoesNotSwitchWorkspaceWhenSettingDisabled() throws { - UserDefaults.standard.set(false, forKey: settingsKey) - - let harness = try makeSidebarFirstClickHarness() - defer { harness.close() } - - let result = try dispatchInactiveFirstClick(in: harness) - - XCTAssertFalse( - result.acceptedFirstMouse, - "Inactive first click on workspace \(harness.targetWorkspace.id) should activate the window only." - ) - XCTAssertTrue(harness.window.isKeyWindow) - XCTAssertTrue( - result.hitViewClassName.contains("FirstMouseGated"), - "Expected inactive sidebar click to be captured by the production first-mouse gate, got \(result.hitViewClassName)." - ) - XCTAssertEqual(harness.tabManager.selectedTabId, harness.initialWorkspace.id) - XCTAssertNotEqual(harness.tabManager.selectedTabId, harness.targetWorkspace.id) - } } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 14335d2ffde6..94877e475211 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -6,6 +6,7 @@ import WebKit import ObjectiveC.runtime import Bonsplit import UserNotifications +import Sparkle #if canImport(cmux_DEV) @testable import cmux_DEV @@ -1679,6 +1680,91 @@ final class UpdateViewModelPresentationTests: XCTestCase { XCTAssertFalse(viewModel.showsDetectedBackgroundUpdate) XCTAssertEqual(viewModel.text, "Checking for Updates…") } + + func testDismissDetectedAvailableUpdateRepliesAndClearsState() throws { + let viewModel = UpdateViewModel() + let item = try XCTUnwrap(makeAppcastItem(displayVersion: "9.9.9")) + let recorder = UpdateChoiceRecorder() + + viewModel.recordDetectedUpdate(item) + viewModel.state = .updateAvailable(.init( + appcastItem: item, + reply: { recorder.record($0) } + )) + + viewModel.dismissDetectedAvailableUpdate() + + XCTAssertEqual(recorder.snapshot(), [.dismiss]) + XCTAssertEqual(viewModel.state, .idle) + XCTAssertNil(viewModel.detectedUpdateVersion) + XCTAssertNil(viewModel.detectedUpdateItem) + XCTAssertFalse(viewModel.showsPill) + } + + func testCancelActiveStateForNewCheckDismissesAndClearsTransientState() throws { + let viewModel = UpdateViewModel() + let item = try XCTUnwrap(makeAppcastItem(displayVersion: "9.9.9")) + let recorder = UpdateChoiceRecorder() + + viewModel.state = .updateAvailable(.init( + appcastItem: item, + reply: { recorder.record($0) } + )) + viewModel.overrideState = .checking(.init(cancel: {})) + + viewModel.cancelActiveStateForNewCheck() + + XCTAssertEqual(recorder.snapshot(), [.dismiss]) + XCTAssertEqual(viewModel.state, .idle) + XCTAssertNil(viewModel.overrideState) + } + + func testApplyDriverStateRecordsDetectedUpdateMetadata() throws { + let viewModel = UpdateViewModel() + let item = try XCTUnwrap(makeAppcastItem(displayVersion: "9.9.9")) + + viewModel.applyDriverState(.updateAvailable(.init( + appcastItem: item, + reply: { _ in } + ))) + viewModel.state = .idle + + XCTAssertEqual(viewModel.detectedUpdateVersion, "9.9.9") + XCTAssertTrue(viewModel.hasCachedDetectedUpdateDetails) + XCTAssertTrue(viewModel.showsDetectedBackgroundUpdate) + } + + private func makeAppcastItem(displayVersion: String) -> SUAppcastItem? { + let enclosure: [String: Any] = [ + "url": "https://example.com/cmux.zip", + "length": "1024", + "sparkle:version": displayVersion, + "sparkle:shortVersionString": displayVersion, + ] + let dict: [String: Any] = [ + "title": "cmux \(displayVersion)", + "pubDate": "Wed, 25 Mar 2026 12:00:00 +0000", + "enclosure": enclosure, + ] + return SUAppcastItem(dictionary: dict) + } +} + +private final class UpdateChoiceRecorder: @unchecked Sendable { + private let lock = NSLock() + private var choices: [SPUUserUpdateChoice] = [] + + func record(_ choice: SPUUserUpdateChoice) { + lock.lock() + choices.append(choice) + lock.unlock() + } + + func snapshot() -> [SPUUserUpdateChoice] { + lock.lock() + defer { lock.unlock() } + return choices + } } @MainActor diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index 1c4bdda5df04..76110c71ad00 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -31,7 +31,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { current: current, next: next, force: false, - freezesSidebarWorkspaceDetails: true + contextMenuVisible: true ) var expectedDisplayed = current @@ -63,7 +63,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { current: current, next: next, force: false, - freezesSidebarWorkspaceDetails: true + contextMenuVisible: true ) XCTAssertEqual(decision.workspaceSnapshotStorage, next) @@ -79,7 +79,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { current: current, next: next, force: false, - freezesSidebarWorkspaceDetails: false + contextMenuVisible: false ) XCTAssertEqual(decision.workspaceSnapshotStorage, next) @@ -87,52 +87,6 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { XCTAssertFalse(decision.hasDeferredWorkspaceObservationInvalidation) } - func testMenuTrackingEndRestoresLiveDetailsAfterColorAction() { - var interactionState = SidebarWorkspaceRowInteractionState() - let initial = Self.snapshot( - customColorHex: nil, - compactGitBranchSummaryText: "main", - compactBranchDirectoryRow: "main - ~/repo" - ) - let colorAssigned = Self.snapshot( - customColorHex: "#C0392B", - compactGitBranchSummaryText: "main", - compactBranchDirectoryRow: "main - ~/repo" - ) - let branchChanged = Self.snapshot( - customColorHex: "#C0392B", - compactGitBranchSummaryText: "feature/live", - compactBranchDirectoryRow: "feature/live - ~/repo" - ) - - interactionState.contextMenuTrackingDidBegin() - interactionState.contextMenuDidAppear() - let colorDecision = SidebarWorkspaceSnapshotRefreshPolicy.decision( - current: initial, - next: colorAssigned, - force: false, - freezesSidebarWorkspaceDetails: interactionState.freezesSidebarWorkspaceDetails - ) - XCTAssertEqual(colorDecision.workspaceSnapshotStorage?.customColorHex, "#C0392B") - - interactionState.contextMenuTrackingDidEnd() - let branchDecision = SidebarWorkspaceSnapshotRefreshPolicy.decision( - current: colorDecision.workspaceSnapshotStorage, - next: branchChanged, - force: false, - freezesSidebarWorkspaceDetails: interactionState.freezesSidebarWorkspaceDetails - ) - - XCTAssertEqual( - branchDecision.workspaceSnapshotStorage?.compactGitBranchSummaryText, - "feature/live", - "Once AppKit menu tracking ends after assigning a workspace color, live sidebar details such as git branch must update even if SwiftUI does not deliver context-menu disappearance." - ) - XCTAssertEqual(branchDecision.workspaceSnapshotStorage?.compactBranchDirectoryRow, "feature/live - ~/repo") - XCTAssertNil(branchDecision.pendingWorkspaceSnapshot) - XCTAssertFalse(branchDecision.hasDeferredWorkspaceObservationInvalidation) - } - private static func snapshot( title: String = "workspace", customDescription: String? = nil, @@ -140,8 +94,6 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { customColorHex: String? = nil, remoteConnectionStatusText: String = "Disconnected", latestSubmittedMessage: String? = nil, - compactGitBranchSummaryText: String? = nil, - compactBranchDirectoryRow: String? = nil, listeningPorts: [Int] = [] ) -> SidebarWorkspaceSnapshotBuilder.Snapshot { SidebarWorkspaceSnapshotBuilder.Snapshot( @@ -158,8 +110,8 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { metadataBlocks: [], latestLog: nil, progress: nil, - compactGitBranchSummaryText: compactGitBranchSummaryText, - compactBranchDirectoryRow: compactBranchDirectoryRow, + compactGitBranchSummaryText: nil, + compactBranchDirectoryRow: nil, branchDirectoryLines: [], branchLinesContainBranch: false, pullRequestRows: [], @@ -169,25 +121,9 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { } final class SidebarWorkspaceRowInteractionStateTests: XCTestCase { - func testMenuTrackingEndEndsSidebarDetailsFreezeEvenIfSwiftUIDisappearIsStale() { - var state = SidebarWorkspaceRowInteractionState() - - state.contextMenuTrackingDidBegin() - state.contextMenuDidAppear() - XCTAssertTrue(state.freezesSidebarWorkspaceDetails) - - state.contextMenuTrackingDidEnd() - - XCTAssertFalse( - state.freezesSidebarWorkspaceDetails, - "AppKit menu tracking end is the authoritative lifetime boundary for deferred sidebar detail snapshots." - ) - } - func testHoverRevealIsIndependentFromStaleContextMenuVisibility() { var state = SidebarWorkspaceRowInteractionState() - state.contextMenuTrackingDidBegin() state.contextMenuDidAppear() state.contextMenuTrackingDidEnd() state.setPointerHovering(true) @@ -211,23 +147,6 @@ final class SidebarWorkspaceRowInteractionStateTests: XCTestCase { ) } - func testSwiftUIDisappearDoesNotEndAppKitTrackingFreezeBeforeTrackingEnds() { - var state = SidebarWorkspaceRowInteractionState() - - state.contextMenuTrackingDidBegin() - state.contextMenuDidAppear() - state.contextMenuDidDisappear() - - XCTAssertTrue( - state.freezesSidebarWorkspaceDetails, - "SwiftUI disappearance is only a fallback cleanup path; AppKit tracking remains the authoritative lifetime while it is active." - ) - - state.contextMenuTrackingDidEnd() - - XCTAssertFalse(state.freezesSidebarWorkspaceDetails) - } - func testContextMenuTrackingBeginHidesExistingCloseButtonBeforeSwiftUIMenuAppears() { var state = SidebarWorkspaceRowInteractionState() @@ -253,7 +172,6 @@ final class SidebarWorkspaceRowInteractionStateTests: XCTestCase { func testHoverDuringContextMenuTrackingStaysHiddenUntilTrackingEnds() { var state = SidebarWorkspaceRowInteractionState() - state.contextMenuTrackingDidBegin() state.contextMenuDidAppear() state.setPointerHovering(true) @@ -282,10 +200,8 @@ final class SidebarWorkspaceRowInteractionStateTests: XCTestCase { get: { state }, set: { state = $0 } ) - var menuTrackingEndCount = 0 let coordinator = SidebarWorkspaceRowHoverTracker.Coordinator( - rowInteractionState: binding, - onMenuTrackingEnded: { menuTrackingEndCount += 1 } + rowInteractionState: binding ) coordinator.menuTrackingChanged(true) @@ -300,7 +216,6 @@ final class SidebarWorkspaceRowInteractionStateTests: XCTestCase { ), "A pointer exit observed during menu tracking must overwrite any earlier deferred hover enter before the menu dismisses." ) - XCTAssertEqual(menuTrackingEndCount, 1) } func testMenuTrackingSuppressionOnlyAppliesToPointerMenusInsideRow() { diff --git a/cmuxTests/SocketControlPasswordStoreTests.swift b/cmuxTests/SocketControlPasswordStoreTests.swift index ea45e661a0b5..fef69a4ce9b9 100644 --- a/cmuxTests/SocketControlPasswordStoreTests.swift +++ b/cmuxTests/SocketControlPasswordStoreTests.swift @@ -1,4 +1,5 @@ import XCTest +import CMUXAuthCore #if canImport(cmux_DEV) @testable import cmux_DEV @@ -241,6 +242,242 @@ final class SocketControlPasswordStoreTests: XCTestCase { } } +@MainActor +final class AuthManagerSignOutTests: XCTestCase { + func testAuthLogRedactionRemovesTokensAndEmails() { + let message = """ + auth.webauth callback failed: cmux://auth-callback?stack_access=access-token&stack_refresh=refresh-token&state=opaque-state user=alice@example.com Authorization: Bearer header.payload.signature + """ + + let redacted = AuthManager.redactedAuthLogMessageForTesting(message) + + XCTAssertFalse(redacted.contains("access-token")) + XCTAssertFalse(redacted.contains("refresh-token")) + XCTAssertFalse(redacted.contains("opaque-state")) + XCTAssertFalse(redacted.contains("alice@example.com")) + XCTAssertFalse(redacted.contains("header.payload.signature")) + XCTAssertTrue(redacted.contains("stack_access=")) + XCTAssertTrue(redacted.contains("stack_refresh=")) + XCTAssertTrue(redacted.contains("state=")) + XCTAssertTrue(redacted.contains("")) + XCTAssertTrue(redacted.contains("Authorization=")) + } + + func testSignOutClearsInFlightBrowserSignInLoadingState() async { + let suiteName = "cmux-auth-manager-sign-out-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Could not create isolated defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let manager = AuthManager( + client: AuthManagerSignOutTestClient(), + tokenStore: AuthManagerSignOutTestTokenStore(), + settingsStore: AuthSettingsStore(userDefaults: defaults) + ) + await manager.awaitBootstrapped() + + manager.markBrowserSignInLoadingForTesting() + XCTAssertTrue(manager.isLoading) + + await manager.signOut() + + XCTAssertFalse(manager.isLoading) + } + + func testSignOutDuringBrowserCallbackDoesNotLeaveStaleAccessToken() async throws { + let suiteName = "cmux-auth-manager-sign-out-race-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Could not create isolated defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let tokenStore = AuthManagerSignOutTestTokenStore() + let manager = AuthManager( + client: AuthManagerSignOutTestClient(), + tokenStore: tokenStore, + settingsStore: AuthSettingsStore(userDefaults: defaults) + ) + await manager.awaitBootstrapped() + + await tokenStore.suspendNextSetTokens() + let callbackURL = try XCTUnwrap(URL(string: "cmux://auth-callback?stack_refresh=refresh-after-signout&stack_access=access-after-signout")) + let callbackTask = Task { @MainActor in + try await manager.handleCallbackURL(callbackURL) + } + + await tokenStore.waitForSuspendedSetTokens() + await manager.signOut() + await tokenStore.resumeSuspendedSetTokens() + try await callbackTask.value + + XCTAssertFalse(manager.isAuthenticated) + let storedAccessToken = await tokenStore.getStoredAccessToken() + XCTAssertNil(storedAccessToken) + do { + _ = try await manager.getAccessToken() + XCTFail("Expected getAccessToken to reject the stale post-sign-out token") + } catch AuthManagerError.missingAccessToken { + } catch { + XCTFail("Expected missingAccessToken, got \(error)") + } + } + + func testNewSignInDuringInFlightSignOutSurvivesSignOutCompletion() async throws { + let suiteName = "cmux-auth-manager-sign-out-new-sign-in-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Could not create isolated defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let client = AuthManagerSuspendingSignOutTestClient() + let tokenStore = AuthManagerSignOutTestTokenStore() + await tokenStore.setTokens(accessToken: "old-access", refreshToken: "old-refresh") + let manager = AuthManager( + client: client, + tokenStore: tokenStore, + settingsStore: AuthSettingsStore(userDefaults: defaults) + ) + await manager.awaitBootstrapped() + + let signOutTask = Task { @MainActor in + await manager.signOut() + } + await client.waitForSignOutStarted() + + let callbackURL = try XCTUnwrap(URL(string: "cmux://auth-callback?stack_refresh=new-refresh&stack_access=new-access")) + try await manager.handleCallbackURL(callbackURL) + await client.resumeSignOut() + await signOutTask.value + + XCTAssertTrue(manager.isAuthenticated) + let storedAccessToken = await tokenStore.getStoredAccessToken() + let storedRefreshToken = await tokenStore.getStoredRefreshToken() + XCTAssertEqual(storedAccessToken, "new-access") + XCTAssertEqual(storedRefreshToken, "new-refresh") + } +} + +private struct AuthManagerSignOutTestClient: AuthClientProtocol { + func currentUser() async throws -> CMUXAuthUser? { + nil + } + + func listTeams() async throws -> [AuthTeamSummary] { + [] + } + + func signOut() async throws {} +} + +private actor AuthManagerSuspendingSignOutTestClient: AuthClientProtocol { + private var signOutContinuation: CheckedContinuation? + private var signOutStartedContinuation: CheckedContinuation? + + func currentUser() async throws -> CMUXAuthUser? { + nil + } + + func listTeams() async throws -> [AuthTeamSummary] { + [] + } + + func signOut() async throws { + await withCheckedContinuation { continuation in + signOutContinuation = continuation + signOutStartedContinuation?.resume() + signOutStartedContinuation = nil + } + } + + func waitForSignOutStarted() async { + if signOutContinuation != nil { + return + } + await withCheckedContinuation { continuation in + signOutStartedContinuation = continuation + } + } + + func resumeSignOut() { + let continuation = signOutContinuation + signOutContinuation = nil + continuation?.resume() + } +} + +private actor AuthManagerSignOutTestTokenStore: StackAuthTokenStoreProtocol { + private var accessToken: String? + private var refreshToken: String? + private var shouldSuspendNextSetTokens = false + private var suspendedSetTokensContinuation: CheckedContinuation? + private var suspendedSetTokensWaiter: CheckedContinuation? + + func suspendNextSetTokens() { + shouldSuspendNextSetTokens = true + } + + func waitForSuspendedSetTokens() async { + if suspendedSetTokensContinuation != nil { + return + } + await withCheckedContinuation { continuation in + suspendedSetTokensWaiter = continuation + } + } + + func resumeSuspendedSetTokens() { + shouldSuspendNextSetTokens = false + let continuation = suspendedSetTokensContinuation + suspendedSetTokensContinuation = nil + continuation?.resume() + } + + func getStoredAccessToken() async -> String? { + accessToken + } + + func getStoredRefreshToken() async -> String? { + refreshToken + } + + func setTokens(accessToken: String?, refreshToken: String?) async { + if shouldSuspendNextSetTokens { + await withCheckedContinuation { continuation in + suspendedSetTokensContinuation = continuation + suspendedSetTokensWaiter?.resume() + suspendedSetTokensWaiter = nil + } + } + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + func clearTokens() async { + accessToken = nil + refreshToken = nil + } + + func compareAndSet( + compareRefreshToken: String, + newRefreshToken: String?, + newAccessToken: String? + ) async { + guard refreshToken == compareRefreshToken else { return } + refreshToken = newRefreshToken + accessToken = newAccessToken + } +} + final class CmuxCLIPathInstallerTests: XCTestCase { func testInstallAndUninstallRoundTripWithoutAdministratorPrivileges() throws { let fileManager = FileManager.default diff --git a/cmuxTests/TraditionalChineseIMENumpadRegressionTests.swift b/cmuxTests/TraditionalChineseIMENumpadRegressionTests.swift index 3d390ed2ffc6..017a9b5d5c6c 100644 --- a/cmuxTests/TraditionalChineseIMENumpadRegressionTests.swift +++ b/cmuxTests/TraditionalChineseIMENumpadRegressionTests.swift @@ -252,12 +252,10 @@ final class TraditionalChineseIMENumpadRegressionTests: XCTestCase { let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride let previousInterpretHook = cjkIMEInterpretKeyEventsHook - let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler defer { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride cjkIMEInterpretKeyEventsHook = previousInterpretHook - GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler window.orderOut(nil) withExtendedLifetime(terminalSurface) {} } diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index fc75eb3f1e3b..d09b3f720f17 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,7 +1,6 @@ import XCTest import Foundation import AppKit -import Sparkle #if canImport(cmux_DEV) @testable import cmux_DEV @@ -9,169 +8,6 @@ import Sparkle @testable import cmux #endif -@MainActor -final class UpdateViewModelLatestEmissionTests: XCTestCase { - func testPassiveDetectedUpdatePillFollowsEveryValidEmissionIncludingRollback() throws { - let model = UpdateViewModel() - - let firstPollItem = try Self.appcastItem(displayVersion: "9.9.0") - model.recordDetectedUpdate(firstPollItem) - XCTAssertEqual(model.text, "Update Available: 9.9.0") - XCTAssertEqual(Self.visibleUpdateVersion(in: model), "9.9.0") - - let newerPollItem = try Self.appcastItem(displayVersion: "9.9.1") - model.recordDetectedUpdate(newerPollItem) - XCTAssertEqual(model.text, "Update Available: 9.9.1") - XCTAssertEqual(Self.visibleUpdateVersion(in: model), "9.9.1") - - // The pill follows the current appcast emission. If a newer release is yanked, - // keeping the unavailable version would be another stale first-signal cache. - let rollbackPollItem = try Self.appcastItem(displayVersion: "9.9.0") - model.recordDetectedUpdate(rollbackPollItem) - XCTAssertEqual(model.text, "Update Available: 9.9.0") - XCTAssertEqual(Self.visibleUpdateVersion(in: model), "9.9.0") - } - - func testPassiveDetectionDoesNotReplaceInstallableStateWithoutMatchingReply() throws { - let model = UpdateViewModel() - let installedReply = UpdateChoiceRecorder() - - model.recordAvailableUpdate(try Self.updateAvailable( - displayVersion: "9.9.0", - recorder: installedReply - )) - - model.recordDetectedUpdate(try Self.appcastItem(displayVersion: "9.9.1")) - - XCTAssertEqual(model.detectedUpdateVersion, "9.9.1") - XCTAssertEqual(Self.visibleUpdateVersion(in: model), "9.9.0") - XCTAssertEqual(model.text, "Update Available: 9.9.0") - - guard case .updateAvailable(let update) = model.effectiveState else { - XCTFail("Expected installable state to remain updateAvailable") - return - } - update.reply(.install) - - XCTAssertEqual(installedReply.count, 1) - } - - func testAvailableUpdateRefreshUsesLatestReplyWhenOverrideIsVisible() throws { - let model = UpdateViewModel() - let staleReply = UpdateChoiceRecorder() - let latestReply = UpdateChoiceRecorder() - - model.overrideState = .updateAvailable(try Self.updateAvailable( - displayVersion: "9.9.0", - recorder: staleReply - )) - - model.recordAvailableUpdate(try Self.updateAvailable( - displayVersion: "9.9.1", - recorder: latestReply - )) - - XCTAssertEqual(Self.visibleUpdateVersion(in: model), "9.9.1") - - guard case .updateAvailable(let update) = model.effectiveState else { - XCTFail("Expected visible override to remain updateAvailable") - return - } - update.reply(.dismiss) - - XCTAssertEqual(staleReply.count, 0) - XCTAssertEqual(latestReply.count, 1) - } - - func testDismissDetectedAvailableUpdateClearsStaleInstallableStateAndRepliesOnce() throws { - let model = UpdateViewModel() - let reply = UpdateChoiceRecorder() - let update = try Self.updateAvailable(displayVersion: "9.9.0", recorder: reply) - - model.recordAvailableUpdate(update) - model.overrideState = .updateAvailable(update) - XCTAssertTrue(model.showsPill) - XCTAssertEqual(model.detectedUpdateVersion, "9.9.0") - - model.dismissDetectedAvailableUpdate() - - XCTAssertEqual(model.state, .idle) - XCTAssertNil(model.overrideState) - XCTAssertNil(model.detectedUpdateVersion) - XCTAssertFalse(model.showsPill) - XCTAssertEqual(reply.count, 1) - } - - func testCancelActiveStateForNewCheckPreventsQueuedDismissFromReplyingAgain() throws { - let model = UpdateViewModel() - let reply = UpdateChoiceRecorder() - let update = try Self.updateAvailable(displayVersion: "9.9.0", recorder: reply) - - model.recordAvailableUpdate(update) - model.overrideState = .updateAvailable(update) - - model.cancelActiveStateForNewCheck() - model.dismissDetectedAvailableUpdate() - - XCTAssertEqual(model.state, .idle) - XCTAssertNil(model.overrideState) - XCTAssertEqual(reply.count, 1) - } - - private static func visibleUpdateVersion(in model: UpdateViewModel) -> String? { - switch model.effectiveState { - case .idle: - return model.detectedUpdateVersion - case .updateAvailable(let update): - return update.appcastItem.displayVersionString - default: - return nil - } - } - - private static func updateAvailable( - displayVersion: String, - recorder: UpdateChoiceRecorder? = nil - ) throws -> UpdateState.UpdateAvailable { - .init( - appcastItem: try appcastItem(displayVersion: displayVersion), - reply: { choice in recorder?.record(choice) } - ) - } - - private static func appcastItem(displayVersion: String) throws -> SUAppcastItem { - let enclosure: [String: Any] = [ - "url": "https://example.com/cmux-\(displayVersion).zip", - "length": "1024", - "sparkle:version": displayVersion, - "sparkle:shortVersionString": displayVersion, - ] - let item: [String: Any] = [ - "title": "cmux \(displayVersion)", - "pubDate": "Wed, 25 Mar 2026 12:00:00 +0000", - "enclosure": enclosure, - ] - return try XCTUnwrap(SUAppcastItem(dictionary: item)) - } - - private final class UpdateChoiceRecorder: @unchecked Sendable { - private let lock = NSLock() - private var recordedCount = 0 - - var count: Int { - lock.lock() - defer { lock.unlock() } - return recordedCount - } - - func record(_: SPUUserUpdateChoice) { - lock.lock() - recordedCount += 1 - lock.unlock() - } - } -} - final class BrowserInsecureHTTPSettingsTests: XCTestCase { func testDefaultAllowlistPatternsArePresent() { XCTAssertEqual( diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 5f1b20cfbbee..7bcd5e334199 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -131,75 +131,6 @@ final class WindowAccessorTests: XCTestCase { } } -@MainActor -final class MainWindowFocusRedrawTests: XCTestCase { - func testKeyRegainInvalidatesContentWhenPointerIsOverSidebarDivider() { - _ = NSApplication.shared - - let appDelegate = AppDelegate() - let tabManager = TabManager(autoWelcomeIfNeeded: false) - let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: tabManager) - defer { - appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) - } - - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 640, height: 420)) - let splitView = NSSplitView(frame: contentView.bounds) - splitView.isVertical = true - splitView.autoresizingMask = [.width, .height] - splitView.dividerStyle = .thin - - let sidebar = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 420)) - let main = NSView(frame: NSRect(x: 221, y: 0, width: 419, height: 420)) - splitView.addArrangedSubview(sidebar) - splitView.addArrangedSubview(main) - contentView.addSubview(splitView) - splitView.setPosition(220, ofDividerAt: 0) - - let window = CmuxMainWindow( - contentRect: contentView.bounds, - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) - window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)") - window.contentView = contentView - defer { window.close() } - - contentView.layoutSubtreeIfNeeded() - splitView.adjustSubviews() - let dividerPointInSplit = NSPoint( - x: sidebar.frame.maxX + splitView.dividerThickness / 2, - y: splitView.bounds.midY - ) - let dividerRectInSplit = NSRect( - x: sidebar.frame.maxX, - y: 0, - width: max(splitView.dividerThickness, 1), - height: splitView.bounds.height - ).insetBy(dx: -5, dy: 0) - - XCTAssertTrue( - dividerRectInSplit.contains(dividerPointInSplit), - "Test setup should place the pointer over the sidebar/main split divider without depending on screen coordinates." - ) - - contentView.needsDisplay = false - - appDelegate.handleCmuxWindowResignedKey( - Notification(name: NSWindow.didResignKeyNotification, object: window) - ) - appDelegate.handleCmuxWindowBecameKey( - Notification(name: NSWindow.didBecomeKeyNotification, object: window) - ) - - XCTAssertTrue( - contentView.needsDisplay, - "Regaining key focus must invalidate the root content view even when the pointer is over the resize divider." - ) - } -} - @MainActor final class AppDelegateWindowContextRoutingTests: XCTestCase { private func makeMainWindow(id: UUID) -> NSWindow { @@ -1802,17 +1733,6 @@ private final class FilePreviewPDFChromeNotificationFlag: @unchecked Sendable { @MainActor final class FilePreviewPDFChromeTests: XCTestCase { func testChromeHostsAcceptFirstMouse() { - let settingsKey = "paneFirstClickFocus.enabled" - let previousValue = UserDefaults.standard.object(forKey: settingsKey) - defer { - if let previousValue { - UserDefaults.standard.set(previousValue, forKey: settingsKey) - } else { - UserDefaults.standard.removeObject(forKey: settingsKey) - } - } - UserDefaults.standard.set(true, forKey: settingsKey) - let host = FilePreviewPDFChromeHostingView(rootView: AnyView(EmptyView())) XCTAssertTrue(host.acceptsFirstMouse(for: nil)) diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 53d55a0406a0..0396108065ec 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -110,54 +110,6 @@ final class WindowAppearanceSnapshotTests: XCTestCase { XCTAssertEqual(plan.windowBackgroundColor.hexString(includeAlpha: true), "#272822FF") } - func testOpaqueUnblurredTerminalBackgroundIsRendererOwned() { - let snapshot = makeSnapshot( - unifySurfaceBackdrops: false, - backgroundOpacity: 1.0, - backgroundBlur: .disabled - ) - let policy = snapshot.policy(for: .windowRoot) - - XCTAssertNil(policy.hostLayerBackgroundColor) - guard case let .ghosttyTerminalBackdrop(_, _, renderingMode) = policy else { - XCTFail("expected terminal backdrop policy") - return - } - XCTAssertEqual(renderingMode, .ghosttyRendererOwnedBackgroundImage) - } - - func testTranslucentTerminalBackgroundStaysHostLayerOwned() { - let snapshot = makeSnapshot( - unifySurfaceBackdrops: false, - backgroundOpacity: 0.9, - backgroundBlur: .disabled - ) - let policy = snapshot.policy(for: .windowRoot) - - XCTAssertNotNil(policy.hostLayerBackgroundColor) - guard case let .ghosttyTerminalBackdrop(_, _, renderingMode) = policy else { - XCTFail("expected terminal backdrop policy") - return - } - XCTAssertEqual(renderingMode, .windowHostBackdrop) - } - - func testBlurredTerminalBackgroundStaysHostLayerOwned() { - let snapshot = makeSnapshot( - unifySurfaceBackdrops: false, - backgroundOpacity: 1.0, - backgroundBlur: .radius(20) - ) - let policy = snapshot.policy(for: .windowRoot) - - XCTAssertNotNil(policy.hostLayerBackgroundColor) - guard case let .ghosttyTerminalBackdrop(_, _, renderingMode) = policy else { - XCTFail("expected terminal backdrop policy") - return - } - XCTAssertEqual(renderingMode, .windowHostBackdrop) - } - func testDebugBackgroundGlassUsesWindowGlassPhase() { let snapshot = makeSnapshot( unifySurfaceBackdrops: false, @@ -185,10 +137,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { terminalBackgroundColor: NSColor(hex: "#272822") ?? .black, terminalBackgroundOpacity: backgroundOpacity, terminalBackgroundBlur: backgroundBlur, - terminalRenderingMode: WindowAppearanceSnapshot.terminalRenderingMode( - backgroundOpacity: Double(backgroundOpacity), - backgroundBlur: backgroundBlur - ), + terminalRenderingMode: .windowHostBackdrop, unifySurfaceBackdrops: unifySurfaceBackdrops, sidebarSettings: SidebarBackdropSettingsSnapshot( materialRawValue: SidebarMaterialOption.sidebar.rawValue, diff --git a/scripts/install-zig-ci.sh b/scripts/install-zig-ci.sh index a5dfeeeabb55..51b8fdf82f5f 100755 --- a/scripts/install-zig-ci.sh +++ b/scripts/install-zig-ci.sh @@ -3,6 +3,9 @@ set -euo pipefail ZIG_REQUIRED="${ZIG_REQUIRED:-0.15.2}" ZIG_MINISIGN_PUBLIC_KEY="${ZIG_MINISIGN_PUBLIC_KEY:-RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U}" +export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" +export HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}" +export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" if command -v zig >/dev/null 2>&1; then INSTALLED_ZIG_VERSION="$(zig version 2>/dev/null || true)" diff --git a/tests/test_claude_hook_clear_running_status.py b/tests/test_claude_hook_clear_running_status.py new file mode 100644 index 000000000000..cc9f283b50e5 --- /dev/null +++ b/tests/test_claude_hook_clear_running_status.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Regression: /clear SessionStart keeps Claude Running status current.""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import tempfile +import threading +import time +import uuid +from pathlib import Path + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit: + if os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + raise RuntimeError(f"Configured cmux CLI is not executable: {explicit}") + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +class HookSocketServer: + def __init__(self, workspace_id: str, surface_id: str) -> None: + self.workspace_id = workspace_id + self.surface_id = surface_id + self.commands: list[str] = [] + self.ready = threading.Event() + self.stop = threading.Event() + self.error: Exception | None = None + self.root = tempfile.TemporaryDirectory(prefix="cmux-claude-clear-") + self.socket_path = os.path.join(self.root.name, "cmux.sock") + self.thread = threading.Thread(target=self._run, daemon=True) + self.server: socket.socket | None = None + + def __enter__(self) -> "HookSocketServer": + self.thread.start() + if not self.ready.wait(timeout=2.0): + raise RuntimeError("socket server did not become ready") + if self.error is not None: + raise self.error + return self + + def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + self.stop.set() + if self.server is not None: + self.server.close() + self.thread.join(timeout=2.0) + self.root.cleanup() + + def _run(self) -> None: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + self.server = server + server.bind(self.socket_path) + server.listen(8) + server.settimeout(0.1) + self.ready.set() + while not self.stop.is_set(): + try: + conn, _ = server.accept() + except socket.timeout: + continue + except OSError: + return + threading.Thread(target=self._handle, args=(conn,), daemon=True).start() + except Exception as exc: + self.error = exc + self.ready.set() + + def _handle(self, conn: socket.socket) -> None: + with conn: + conn.settimeout(0.1) + buffer = b"" + idle_deadline = time.time() + 6.0 + while not self.stop.is_set() and time.time() < idle_deadline: + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + break + idle_deadline = time.time() + 2.0 + buffer += chunk + while b"\n" in buffer: + raw_line, buffer = buffer.split(b"\n", 1) + if not raw_line: + continue + line = raw_line.decode("utf-8", errors="replace") + self.commands.append(line) + conn.sendall((self._response_for(line) + "\n").encode("utf-8")) + + def _response_for(self, line: str) -> str: + if not line.startswith("{"): + return "OK" + try: + request = json.loads(line) + except json.JSONDecodeError: + return "OK" + + method = request.get("method") + result: dict[str, object] = {} + if method == "surface.list": + result = { + "surfaces": [ + { + "index": 0, + "id": self.surface_id, + "ref": "surface:1", + "focused": True, + } + ] + } + elif method == "workspace.current": + result = {"workspace_id": self.workspace_id} + elif method == "workspace.list": + result = { + "workspaces": [ + { + "index": 0, + "id": self.workspace_id, + "ref": "workspace:1", + } + ] + } + elif method == "window.list": + result = {"windows": [{"id": str(uuid.uuid4()).upper()}]} + elif method == "debug.terminals": + result = {"terminals": []} + + return json.dumps({"id": request.get("id"), "ok": True, "result": result}) + + +def run_claude_hook( + cli_path: str, + socket_path: str, + subcommand: str, + payload: dict[str, object], + env: dict[str, str], +) -> None: + proc = subprocess.run( + [cli_path, "--socket", socket_path, "claude-hook", subcommand], + input=json.dumps(payload), + text=True, + capture_output=True, + env=env, + timeout=8, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError( + f"cmux claude-hook {subcommand} failed:\n" + f"exit={proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}" + ) + + +def has_command(commands: list[str], fragment: str) -> bool: + return any(fragment in command for command in commands) + + +def has_command_with(commands: list[str], *fragments: str) -> bool: + return any(all(fragment in command for fragment in fragments) for command in commands) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + workspace_id = str(uuid.uuid4()).upper() + surface_id = str(uuid.uuid4()).upper() + old_session_id = f"old-{uuid.uuid4().hex}" + new_session_id = f"new-{uuid.uuid4().hex}" + + with HookSocketServer(workspace_id=workspace_id, surface_id=surface_id) as server: + state_path = Path(server.root.name) / "claude-hook-state.json" + env = os.environ.copy() + env["CMUX_SOCKET_PATH"] = server.socket_path + env["CMUX_WORKSPACE_ID"] = workspace_id + env["CMUX_SURFACE_ID"] = surface_id + env["CMUX_CLAUDE_HOOK_STATE_PATH"] = str(state_path) + env["CMUX_CLI_SENTRY_DISABLED"] = "1" + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + old_pid_env = env.copy() + old_pid_env["CMUX_CLAUDE_PID"] = "11111" + clear_pid_env = env.copy() + clear_pid_env["CMUX_CLAUDE_PID"] = "22222" + + run_claude_hook( + cli_path, + server.socket_path, + "session-start", + {"session_id": old_session_id, "source": "startup", "cwd": "/tmp"}, + old_pid_env, + ) + run_claude_hook( + cli_path, + server.socket_path, + "prompt-submit", + {"session_id": old_session_id, "turn_id": "turn-1", "cwd": "/tmp"}, + env, + ) + + if not has_command(server.commands, f"set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab={workspace_id}"): + print("FAIL: expected prompt-submit to set Claude Running") + print(f"commands={server.commands!r}") + return 1 + + run_claude_hook( + cli_path, + server.socket_path, + "stop", + { + "session_id": old_session_id, + "turn_id": "turn-1", + "cwd": "/tmp", + "last_assistant_message": "first turn completed", + }, + env, + ) + second_turn_start = len(server.commands) + run_claude_hook( + cli_path, + server.socket_path, + "prompt-submit", + {"session_id": old_session_id, "turn_id": "turn-2", "cwd": "/tmp"}, + env, + ) + second_turn_commands = server.commands[second_turn_start:] + + if not has_command(second_turn_commands, f"set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab={workspace_id}"): + print("FAIL: expected second turn prompt-submit to set Claude Running") + print(f"second_turn_commands={second_turn_commands!r}") + return 1 + + clear_start = len(server.commands) + run_claude_hook( + cli_path, + server.socket_path, + "session-start", + {"session_id": new_session_id, "source": "clear", "cwd": "/tmp"}, + clear_pid_env, + ) + clear_commands = server.commands[clear_start:] + + if not has_command(clear_commands, f"clear_notifications --tab={workspace_id}"): + print("FAIL: expected clear SessionStart to clear stale notifications") + print(f"clear_commands={clear_commands!r}") + return 1 + if not has_command_with( + clear_commands, + f"set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab={workspace_id}", + f"--panel={surface_id}", + ): + print("FAIL: expected clear SessionStart to set Claude Running on the current panel") + print(f"clear_commands={clear_commands!r}") + return 1 + + late_old_start = len(server.commands) + run_claude_hook( + cli_path, + server.socket_path, + "session-start", + {"session_id": old_session_id, "source": "startup", "cwd": "/tmp"}, + old_pid_env, + ) + late_old_start_commands = server.commands[late_old_start:] + + if has_command(late_old_start_commands, "set_agent_pid claude_code 11111"): + print("FAIL: stale pre-clear SessionStart must not overwrite active Claude PID") + print(f"late_old_start_commands={late_old_start_commands!r}") + return 1 + + old_stop_start = len(server.commands) + run_claude_hook( + cli_path, + server.socket_path, + "stop", + { + "session_id": old_session_id, + "turn_id": "turn-2", + "cwd": "/tmp", + "last_assistant_message": "old turn completed late", + }, + env, + ) + old_stop_commands = server.commands[old_stop_start:] + + if has_command(old_stop_commands, f"set_status claude_code Idle --icon=pause.circle.fill --color=#8E8E93 --tab={workspace_id}"): + print("FAIL: stale pre-clear Stop must not overwrite the active clear session") + print(f"old_stop_commands={old_stop_commands!r}") + return 1 + + old_session_end_start = len(server.commands) + run_claude_hook( + cli_path, + server.socket_path, + "session-end", + {"session_id": old_session_id, "cwd": "/tmp"}, + env, + ) + old_session_end_commands = server.commands[old_session_end_start:] + + stale_session_end_forbidden_prefixes = [ + f"clear_status claude_code --tab={workspace_id}", + f"clear_agent_pid claude_code --tab={workspace_id}", + f"clear_notifications --tab={workspace_id}", + ] + for forbidden_prefix in stale_session_end_forbidden_prefixes: + if has_command(old_session_end_commands, forbidden_prefix): + print("FAIL: stale pre-clear SessionEnd must not clear the active clear session") + print(f"forbidden_prefix={forbidden_prefix!r}") + print(f"old_session_end_commands={old_session_end_commands!r}") + return 1 + + print("PASS: Claude /clear SessionStart preserves Running against stale Stop and SessionEnd") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 3a5871bb12bd..06299272a324 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -82,6 +82,19 @@ def run_wrapper( for arg in "$@"; do printf '%s\\n' "$arg" >> "$FAKE_REAL_ARGS_LOG" done +if [[ "${1:-}" == "--help" ]]; then + cat <<'HELP' +Usage: claude [options] [command] [prompt] + +Commands: + agents Manage agents + doctor Check Claude health + experimental-next Future command exposed by the real CLI help + plugin|plugins Manage plugins + update|upgrade Update Claude +HELP + exit 0 +fi exec node "$FAKE_REAL_NODE_SCRIPT" "$@" """, ) @@ -449,7 +462,7 @@ def run_wrapper_auth_env( def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: list[str]) -> None: code, real_argv, cmux_log, stderr, claudecode, node_options, runtime_node_options, child_node_options, hook_cmux_bin, _ = run_wrapper( socket_state="live", - argv=["--print", "hello"], + argv=["hello"], ) expect(code == 0, f"live socket: wrapper exited {code}: {stderr}", failures) expect("--settings" in real_argv, f"live socket: missing --settings in args: {real_argv}", failures) @@ -460,7 +473,7 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: f"live socket: wrapper should not unlock bypass permissions via {flag}: {real_argv}", failures, ) - expect(real_argv[-2:] == ["--print", "hello"], f"live socket: expected original print args to pass through, got {real_argv}", failures) + expect(real_argv[-1] == "hello", f"live socket: expected original arg to pass through, got {real_argv}", failures) expect(any(" ping" in line for line in cmux_log), f"live socket: expected cmux ping, got {cmux_log}", failures) expect( any("timeout=0.75" in line for line in cmux_log), @@ -558,54 +571,6 @@ def test_plain_claude_launch_argv_has_no_empty_argument(failures: list[str]) -> expect(argv[0].endswith("/real-bin/claude"), f"plain claude: expected real claude executable, got {argv}", failures) -def test_plain_claude_injects_hook_flags(failures: list[str]) -> None: - code, real_argv, _, stderr, _, _, _, _, _, _ = run_wrapper( - socket_state="live", - argv=[], - ) - expect(code == 0, f"plain claude injection: wrapper exited {code}: {stderr}", failures) - expect("--settings" in real_argv, f"plain claude injection: expected --settings injection, got {real_argv}", failures) - expect("--session-id" in real_argv, f"plain claude injection: expected --session-id injection, got {real_argv}", failures) - - -def test_print_invocation_injects_hook_flags(failures: list[str]) -> None: - code, real_argv, _, stderr, _, _, _, _, _, _ = run_wrapper( - socket_state="live", - argv=["--print", "hi"], - ) - expect(code == 0, f"print injection: wrapper exited {code}: {stderr}", failures) - expect("--settings" in real_argv, f"print injection: expected --settings injection, got {real_argv}", failures) - expect("--session-id" in real_argv, f"print injection: expected --session-id injection, got {real_argv}", failures) - expect(real_argv[-2:] == ["--print", "hi"], f"print injection: expected print args preserved, got {real_argv}", failures) - - -def test_short_worktree_invocation_injects_hook_flags(failures: list[str]) -> None: - code, real_argv, _, stderr, _, _, _, _, _, _ = run_wrapper( - socket_state="live", - argv=["-w", "feature-worktree"], - ) - expect(code == 0, f"short worktree injection: wrapper exited {code}: {stderr}", failures) - expect("--settings" in real_argv, f"short worktree injection: expected --settings injection, got {real_argv}", failures) - expect("--session-id" in real_argv, f"short worktree injection: expected --session-id injection, got {real_argv}", failures) - expect(real_argv[-2:] == ["-w", "feature-worktree"], f"short worktree injection: expected worktree args preserved, got {real_argv}", failures) - - -def test_value_options_before_print_do_not_hide_interactive_entry(failures: list[str]) -> None: - cases = [ - ("short model", ["-m", "claude-opus-4", "--print", "hello"]), - ("agents json", ["--agents", '{"reviewer":{}}', "--print", "hello"]), - ] - for label, argv in cases: - code, real_argv, _, stderr, _, _, _, _, _, _ = run_wrapper( - socket_state="live", - argv=argv, - ) - expect(code == 0, f"{label} value-option injection: wrapper exited {code}: {stderr}", failures) - expect("--settings" in real_argv, f"{label} value-option injection: expected --settings injection, got {real_argv}", failures) - expect("--session-id" in real_argv, f"{label} value-option injection: expected --session-id injection, got {real_argv}", failures) - expect(real_argv[-len(argv):] == argv, f"{label} value-option injection: expected original args preserved, got {real_argv}", failures) - - def test_command_like_invocations_bypass_hook_injection(failures: list[str]) -> None: subcommands = [ "mcp", @@ -622,9 +587,10 @@ def test_command_like_invocations_bypass_hook_injection(failures: list[str]) -> "setup-token", "install", "daemon", + "experimental-next", ] for subcommand in subcommands: - code, real_argv, _, stderr, _, _, _, _, _, _ = run_wrapper( + code, real_argv, _, stderr, _, node_options, _, _, _, _ = run_wrapper( socket_state="live", argv=[subcommand], ) @@ -632,6 +598,7 @@ def test_command_like_invocations_bypass_hook_injection(failures: list[str]) -> expect(real_argv == [subcommand], f"{subcommand} passthrough: expected raw argv, got {real_argv}", failures) expect("--settings" not in real_argv, f"{subcommand} passthrough: expected no --settings injection, got {real_argv}", failures) expect("--session-id" not in real_argv, f"{subcommand} passthrough: expected no --session-id injection, got {real_argv}", failures) + expect(node_options == "__UNSET__", f"{subcommand} passthrough: expected no NODE_OPTIONS injection, got {node_options!r}", failures) code, real_argv, _, stderr, _, _, _, _, _, _ = run_wrapper( socket_state="live", @@ -643,6 +610,19 @@ def test_command_like_invocations_bypass_hook_injection(failures: list[str]) -> expect("--session-id" not in real_argv, f"agents after global option passthrough: expected no --session-id injection, got {real_argv}", failures) +def test_passthrough_flags_bypass_hook_injection(failures: list[str]) -> None: + for flag in ("--help", "--version", "-h", "-v"): + code, real_argv, _, stderr, _, node_options, _, _, _, _ = run_wrapper( + socket_state="live", + argv=[flag], + ) + expect(code == 0, f"{flag} passthrough: wrapper exited {code}: {stderr}", failures) + expect(real_argv == [flag], f"{flag} passthrough: expected raw argv, got {real_argv}", failures) + expect("--settings" not in real_argv, f"{flag} passthrough: expected no --settings injection, got {real_argv}", failures) + expect("--session-id" not in real_argv, f"{flag} passthrough: expected no --session-id injection, got {real_argv}", failures) + expect(node_options == "__UNSET__", f"{flag} passthrough: expected no NODE_OPTIONS injection, got {node_options!r}", failures) + + def test_agents_subcommand_removes_cmux_terminal_fingerprint(failures: list[str]) -> None: scenarios = [ ("agents env probe", {}), @@ -676,7 +656,7 @@ def test_live_socket_preserves_third_party_claude_auth_for_fresh_launch(failures "ANTHROPIC_SMALL_FAST_MODEL": "stale-small-model", } code, auth_env, real_argv, stderr = run_wrapper_auth_env( - argv=["--print", "hello"], + argv=["hello"], inherited_env=inherited, ) expect(code == 0, f"fresh auth env: wrapper exited {code}: {stderr}", failures) @@ -967,7 +947,7 @@ def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[s restored = "--max-old-space-size=2048 --trace-warnings" code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( socket_state="live", - argv=["--print", "hello"], + argv=["hello"], node_options=existing, ) expect(code == 0, f"space-separated heap flag: wrapper exited {code}: {stderr}", failures) @@ -993,7 +973,7 @@ def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[ bad_tmpdir.write_text("occupied", encoding="utf-8") code, real_argv, cmux_log, stderr, claudecode, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( socket_state="live", - argv=["--print", "hello"], + argv=["hello"], tmpdir=str(bad_tmpdir), ) expect(code == 0, f"tmpdir failure: wrapper exited {code}: {stderr}", failures) @@ -1008,9 +988,9 @@ def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[ def test_live_socket_preserves_explicit_bypass_availability_flag(failures: list[str]) -> None: cases = [ - ("allow/print", ["--allow-dangerously-skip-permissions", "--print", "hello"], True, "--allow-dangerously-skip-permissions"), + ("allow/plain", ["--allow-dangerously-skip-permissions", "hello"], True, "--allow-dangerously-skip-permissions"), ("allow/resume", ["--allow-dangerously-skip-permissions", "--resume", "some-session-id"], False, "--allow-dangerously-skip-permissions"), - ("short/print", ["--dangerously-skip-permissions", "--print", "hello"], True, "--dangerously-skip-permissions"), + ("short/plain", ["--dangerously-skip-permissions", "hello"], True, "--dangerously-skip-permissions"), ("short/resume", ["--dangerously-skip-permissions", "--resume", "some-session-id"], False, "--dangerously-skip-permissions"), ] for label, argv, expects_session_id, expected_flag in cases: @@ -1035,7 +1015,7 @@ def test_live_socket_stale_mktemp_literal_does_not_warn(failures: list[str]) -> (guard_dir / "restore-node-options.XXXXXX.cjs").write_text("stale", encoding="utf-8") code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( socket_state="live", - argv=["--print", "hello"], + argv=["hello"], tmpdir=str(tmpdir), ) expect(code == 0, f"stale mktemp literal: wrapper exited {code}: {stderr}", failures) @@ -1112,11 +1092,8 @@ def main() -> int: failures: list[str] = [] test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures) test_plain_claude_launch_argv_has_no_empty_argument(failures) - test_plain_claude_injects_hook_flags(failures) - test_print_invocation_injects_hook_flags(failures) - test_short_worktree_invocation_injects_hook_flags(failures) - test_value_options_before_print_do_not_hide_interactive_entry(failures) test_command_like_invocations_bypass_hook_injection(failures) + test_passthrough_flags_bypass_hook_injection(failures) test_agents_subcommand_removes_cmux_terminal_fingerprint(failures) test_live_socket_preserves_third_party_claude_auth_for_fresh_launch(failures) test_live_socket_normalizes_subrouter_claude_config_dir(failures) diff --git a/tests/test_codex_feed_hooks.py b/tests/test_codex_feed_hooks.py index 8d5570024e87..bfca2edf6f47 100644 --- a/tests/test_codex_feed_hooks.py +++ b/tests/test_codex_feed_hooks.py @@ -524,18 +524,25 @@ def test_install_adds_codex_permission_request_hook(cli_path: str, root: Path) - raise AssertionError(f"wrong {event_name} timeout: {groups[-1]!r}") config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "hooks = true" not in config_toml: - raise AssertionError(f"hooks feature was not enabled: {config_toml!r}") - if "codex_hooks" in config_toml: - raise AssertionError(f"deprecated codex_hooks feature was written: {config_toml!r}") + if "codex_hooks = true" not in config_toml: + raise AssertionError(f"codex_hooks feature was not enabled: {config_toml!r}") -def test_install_migrates_legacy_codex_hooks_feature(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-legacy" +def test_install_codex_hooks_only_edits_real_features_table(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home" codex_home.mkdir() - # Real configs can contain both names after users tried the old and new flags. - (codex_home / "config.toml").write_text( - "[features]\napps = true\ncodex_hooks = false\nhooks = false\n", + config_path = codex_home / "config.toml" + config_path.write_text( + "\n".join( + [ + "# See [features] in the documentation.", + 'note = "literal [features] mention"', + "", + "[features]", + "existing = true", + "", + ] + ), encoding="utf-8", ) env = os.environ.copy() @@ -554,22 +561,26 @@ def test_install_migrates_legacy_codex_hooks_feature(cli_path: str, root: Path) f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" ) - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "codex_hooks" in config_toml: - raise AssertionError(f"deprecated codex_hooks feature was preserved: {config_toml!r}") - if "hooks = true" not in config_toml: - raise AssertionError(f"hooks feature was not enabled: {config_toml!r}") - if "apps = true" not in config_toml: - raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") + config_toml = config_path.read_text(encoding="utf-8") + if config_toml.count("codex_hooks = true") != 1: + raise AssertionError(f"codex_hooks should be inserted exactly once: {config_toml!r}") + if "# See [features] in the documentation." not in config_toml: + raise AssertionError(f"comment with [features] was corrupted: {config_toml!r}") + if 'note = "literal [features] mention"' not in config_toml: + raise AssertionError(f"string literal with [features] was corrupted: {config_toml!r}") + + lines = config_toml.splitlines() + features_index = lines.index("[features]") + if lines[features_index + 1] != "codex_hooks = true": + raise AssertionError(f"codex_hooks should be inserted into [features]: {config_toml!r}") -def test_install_migrates_dotted_codex_hooks_feature(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-dotted-legacy" +def test_uninstall_codex_hooks_removes_empty_features_table_from_install(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-empty-features-uninstall" codex_home.mkdir() - (codex_home / "config.toml").write_text( - "features.apps = true\nfeatures.codex_hooks = false\nfeatures.hooks = false\n", - encoding="utf-8", - ) + config_path = codex_home / "config.toml" + original_config = 'model = "gpt-5.1-codex"\n' + config_path.write_text(original_config, encoding="utf-8") env = os.environ.copy() env["CODEX_HOME"] = str(codex_home) @@ -586,183 +597,49 @@ def test_install_migrates_dotted_codex_hooks_feature(cli_path: str, root: Path) f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" ) - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "features.codex_hooks" in config_toml or "[features]" in config_toml: - raise AssertionError(f"dotted legacy config was rewritten incorrectly: {config_toml!r}") - if "features.hooks = true" not in config_toml: - raise AssertionError(f"dotted hooks feature was not enabled: {config_toml!r}") - if "features.apps = true" not in config_toml: - raise AssertionError(f"existing dotted feature setting was not preserved: {config_toml!r}") - - -def test_uninstall_preserves_existing_codex_hooks_feature(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-uninstall-existing" - codex_home.mkdir() - (codex_home / "config.toml").write_text( - "[features]\napps = true\nhooks = true\n", - encoding="utf-8", - ) - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - for action in ["install", "uninstall"]: - result = subprocess.run( - [cli_path, "hooks", "codex", action, "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "hooks = true" not in config_toml: - raise AssertionError(f"pre-existing hooks feature was removed: {config_toml!r}") - if "apps = true" not in config_toml: - raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") - - -def test_uninstall_restores_disabled_codex_hooks_feature(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-uninstall-disabled" - codex_home.mkdir() - (codex_home / "config.toml").write_text( - "[features]\napps = true\nhooks = false\n", - encoding="utf-8", - ) - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - for action in ["install", "uninstall"]: - result = subprocess.run( - [cli_path, "hooks", "codex", action, "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "hooks = false" not in config_toml: - raise AssertionError(f"pre-existing disabled hooks feature was not restored: {config_toml!r}") - if "hooks = true" in config_toml: - raise AssertionError(f"cmux-owned hooks feature was not removed: {config_toml!r}") - if "apps = true" not in config_toml: - raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") - - -def test_uninstall_restores_disabled_dotted_codex_hooks_feature(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-uninstall-dotted-disabled" - codex_home.mkdir() - (codex_home / "config.toml").write_text( - "features.apps = true\nfeatures.hooks = false\n", - encoding="utf-8", - ) - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - for action in ["install", "uninstall"]: - result = subprocess.run( - [cli_path, "hooks", "codex", action, "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "features.hooks = false" not in config_toml: - raise AssertionError(f"pre-existing disabled dotted hooks feature was not restored: {config_toml!r}") - if "features.hooks = true" in config_toml: - raise AssertionError(f"cmux-owned dotted hooks feature was not removed: {config_toml!r}") - if "features.apps = true" not in config_toml: - raise AssertionError(f"existing dotted feature setting was not preserved: {config_toml!r}") - + installed_config = config_path.read_text(encoding="utf-8") + if "[features]" not in installed_config or "codex_hooks = true" not in installed_config: + raise AssertionError(f"install should add the codex_hooks feature table: {installed_config!r}") -def test_install_scans_features_past_bracketed_array(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-bracketed-array" - codex_home.mkdir() - (codex_home / "config.toml").write_text( - "[features]\napps = [\n [1, 2],\n]\nhooks = false\n", - encoding="utf-8", + result = subprocess.run( + [cli_path, "hooks", "codex", "uninstall", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, ) - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - for action in ["install", "uninstall"]: - result = subprocess.run( - [cli_path, "hooks", "codex", action, "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if action == "install" and config_toml.count("hooks = true") != 1: - raise AssertionError(f"install wrote duplicate hooks settings: {config_toml!r}") - - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "hooks = false" not in config_toml or "hooks = true" in config_toml: - raise AssertionError(f"uninstall did not restore hooks after bracketed array: {config_toml!r}") - if "[1, 2]" not in config_toml: - raise AssertionError(f"bracketed array content was not preserved: {config_toml!r}") - - -def test_uninstall_removes_cmux_owned_codex_hooks_feature(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-uninstall-owned" - codex_home.mkdir() - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - for action in ["install", "uninstall"]: - result = subprocess.run( - [cli_path, "hooks", "codex", action, "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, + if result.returncode != 0: + raise AssertionError( + f"hooks codex uninstall failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" ) - if result.returncode != 0: - raise AssertionError( - f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "hooks = true" in config_toml or "codex_hooks" in config_toml: - raise AssertionError(f"cmux-owned hooks feature was not removed: {config_toml!r}") - if "[features]" in config_toml: - raise AssertionError(f"empty features table was preserved: {config_toml!r}") + config_toml = config_path.read_text(encoding="utf-8") + if config_toml != original_config: + raise AssertionError(f"uninstall should remove the empty [features] table: {config_toml!r}") -def test_uninstall_recovers_orphaned_codex_hooks_marker(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-orphaned-marker" +def test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-legacy-uninstall" codex_home.mkdir() (codex_home / "hooks.json").write_text('{"hooks": {}}\n', encoding="utf-8") - (codex_home / "config.toml").write_text( - "[features]\n" - "apps = true\n" - "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin\n" - "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df previous line: hooks = false\n" - "hooks = true\n", + config_path = codex_home / "config.toml" + config_path.write_text( + "\n".join( + [ + "[features]", + "apps = true", + "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin", + "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df previous line: hooks = false", + "hooks = true", + "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df end", + "# cmux hooks codex feature begin", + "# cmux hooks codex feature previous line: features.hooks = false", + "features.hooks = true", + "# cmux hooks codex feature end", + "", + ] + ), encoding="utf-8", ) env = os.environ.copy() @@ -781,21 +658,28 @@ def test_uninstall_recovers_orphaned_codex_hooks_marker(cli_path: str, root: Pat f"hooks codex uninstall failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" ) - config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + config_toml = config_path.read_text(encoding="utf-8") + if "cmux-codex-hooks-feature" in config_toml: + raise AssertionError(f"legacy managed markers were not removed: {config_toml!r}") + if "cmux hooks codex feature" in config_toml: + raise AssertionError(f"old legacy managed markers were not removed: {config_toml!r}") + if "hooks = true" in config_toml: + raise AssertionError(f"cmux-owned legacy hooks setting was not removed: {config_toml!r}") if "hooks = false" not in config_toml: raise AssertionError(f"previous hooks setting was not restored: {config_toml!r}") - if "hooks = true" in config_toml: - raise AssertionError(f"orphaned cmux marker was not removed: {config_toml!r}") + if "features.hooks = false" not in config_toml: + raise AssertionError(f"previous dotted hooks setting was not restored: {config_toml!r}") if "apps = true" not in config_toml: raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") -def test_install_surfaces_invalid_codex_config_encoding(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-invalid-install-config" +def test_install_codex_hooks_preserves_config_when_toml_read_fails(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home" codex_home.mkdir() config_path = codex_home / "config.toml" - invalid_bytes = b"\xff" - config_path.write_bytes(invalid_bytes) + original_bytes = b'model = "safe"\ninvalid_utf8 = "\xff"\n' + config_path.write_bytes(original_bytes) + env = os.environ.copy() env["CODEX_HOME"] = str(codex_home) @@ -808,47 +692,13 @@ def test_install_surfaces_invalid_codex_config_encoding(cli_path: str, root: Pat timeout=20, ) if result.returncode == 0: - raise AssertionError("hooks codex install unexpectedly succeeded with invalid config encoding") - if config_path.read_bytes() != invalid_bytes: - raise AssertionError("hooks codex install overwrote unreadable config content") - - -def test_uninstall_surfaces_invalid_codex_config_encoding(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home-invalid-uninstall-config" - codex_home.mkdir() - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - install_result = subprocess.run( - [cli_path, "hooks", "codex", "install", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if install_result.returncode != 0: raise AssertionError( - "initial hooks codex install failed " - f"exit={install_result.returncode}\nstdout={install_result.stdout}\nstderr={install_result.stderr}" + "hooks codex install should fail when existing config.toml cannot be read as UTF-8" + ) + if config_path.read_bytes() != original_bytes: + raise AssertionError( + "hooks codex install should not overwrite config.toml after a read failure" ) - - config_path = codex_home / "config.toml" - invalid_bytes = b"\xff" - config_path.write_bytes(invalid_bytes) - - result = subprocess.run( - [cli_path, "hooks", "codex", "uninstall", "--yes"], - capture_output=True, - text=True, - check=False, - env=env, - timeout=20, - ) - if result.returncode == 0: - raise AssertionError("hooks codex uninstall unexpectedly succeeded with invalid config encoding") - if config_path.read_bytes() != invalid_bytes: - raise AssertionError("hooks codex uninstall overwrote unreadable config content") def test_permission_reply_uses_codex_permission_request_schema(cli_path: str, root: Path) -> None: @@ -948,16 +798,10 @@ def main() -> int: test_codex_monitor_exits_when_workspace_has_no_surfaces(cli_path, root) test_codex_monitor_survives_transient_owner_rpc_timeout(cli_path, root) test_install_adds_codex_permission_request_hook(cli_path, root) - test_install_migrates_legacy_codex_hooks_feature(cli_path, root) - test_install_migrates_dotted_codex_hooks_feature(cli_path, root) - test_uninstall_preserves_existing_codex_hooks_feature(cli_path, root) - test_uninstall_restores_disabled_codex_hooks_feature(cli_path, root) - test_uninstall_restores_disabled_dotted_codex_hooks_feature(cli_path, root) - test_install_scans_features_past_bracketed_array(cli_path, root) - test_uninstall_removes_cmux_owned_codex_hooks_feature(cli_path, root) - test_uninstall_recovers_orphaned_codex_hooks_marker(cli_path, root) - test_install_surfaces_invalid_codex_config_encoding(cli_path, root) - test_uninstall_surfaces_invalid_codex_config_encoding(cli_path, root) + test_install_codex_hooks_only_edits_real_features_table(cli_path, root) + test_uninstall_codex_hooks_removes_empty_features_table_from_install(cli_path, root) + test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path, root) + test_install_codex_hooks_preserves_config_when_toml_read_fails(cli_path, root) test_permission_reply_uses_codex_permission_request_schema(cli_path, root) test_codex_persistent_permission_modes_degrade_to_once(cli_path, root) test_codex_pre_tool_use_is_telemetry_not_actionable(cli_path, root)