diff --git a/CLI/CLI+AgentWrappers.swift b/CLI/CLI+AgentWrappers.swift index fe5294d3..c53443d9 100644 --- a/CLI/CLI+AgentWrappers.swift +++ b/CLI/CLI+AgentWrappers.swift @@ -16,7 +16,7 @@ extension ProgramaCLI { return prefix.contains("cmux claude wrapper - injects hooks and session tracking") } - private func resolveExecutableInSearchPath( + func resolveExecutableInSearchPath( _ name: String, searchPath: String?, skip: ((String) -> Bool)? = nil @@ -33,7 +33,7 @@ extension ProgramaCLI { return nil } - private func resolveClaudeExecutable(searchPath: String?) -> String? { + func resolveClaudeExecutable(searchPath: String?) -> String? { resolveExecutableInSearchPath( "claude", searchPath: searchPath, @@ -41,6 +41,12 @@ extension ProgramaCLI { ) } + /// Resolves the `codex` CLI executable from PATH. Unlike Claude, Programa does not + /// ship a codex wrapper, so no skip predicate is needed. + func resolveCodexExecutable(searchPath: String?) -> String? { + resolveExecutableInSearchPath("codex", searchPath: searchPath) + } + private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool { commandArgs.contains { arg in arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=") diff --git a/CLI/CLI+Aside.swift b/CLI/CLI+Aside.swift new file mode 100644 index 00000000..c5b0b4a6 --- /dev/null +++ b/CLI/CLI+Aside.swift @@ -0,0 +1,271 @@ +import Foundation + +/// Locates the Aside CLI (https://docs.aside.com) on disk. Pure and process-free so +/// it can be unit tested without touching the real filesystem. +struct AsideCLILocator { + static func resolve(environment: [String: String], fileExists: (String) -> Bool) -> String? { + if let home = environment["HOME"], !home.isEmpty { + let localBin = (home as NSString).appendingPathComponent(".local/bin/aside") + if fileExists(localBin) { + return localBin + } + let cliApp = (home as NSString).appendingPathComponent(".aside/cli/Aside CLI.app/Contents/MacOS/aside") + if fileExists(cliApp) { + return cliApp + } + } + let entries = environment["PATH"]?.split(separator: ":").map(String.init) ?? [] + for entry in entries where !entry.isEmpty { + let candidate = (entry as NSString).appendingPathComponent("aside") + if fileExists(candidate) { + return candidate + } + } + return nil + } +} + +/// Builds the command plan for registering/removing Aside's MCP server(s) with +/// Claude Code and Codex. Pure so the exact argv can be unit tested without +/// spawning a process. +struct AsideMCPPlan { + let claudeCommands: [[String]] + let codexCommands: [[String]] + + static func build( + asidePath: String, + claudeExecutable: String?, + codexExecutable: String?, + withDevTools: Bool, + install: Bool + ) -> AsideMCPPlan { + var claudeCommands: [[String]] = [] + var codexCommands: [[String]] = [] + + if let claude = claudeExecutable { + if install { + claudeCommands.append([ + claude, "mcp", "add", "--scope", "user", "--transport", "stdio", "aside", "--", asidePath, "mcp", + ]) + if withDevTools { + claudeCommands.append([ + claude, "mcp", "add", "--scope", "user", "--transport", "stdio", "aside-devtools", + "--", "npx", "-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9223", + ]) + } + } else { + claudeCommands.append([claude, "mcp", "remove", "--scope", "user", "aside"]) + claudeCommands.append([claude, "mcp", "remove", "--scope", "user", "aside-devtools"]) + } + } + + if let codex = codexExecutable { + if install { + codexCommands.append([codex, "mcp", "add", "aside", "--", asidePath, "mcp"]) + if withDevTools { + codexCommands.append([ + codex, "mcp", "add", "aside-devtools", + "--", "npx", "-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9223", + ]) + } + } else { + codexCommands.append([codex, "mcp", "remove", "aside"]) + codexCommands.append([codex, "mcp", "remove", "aside-devtools"]) + } + } + + return AsideMCPPlan(claudeCommands: claudeCommands, codexCommands: codexCommands) + } +} + +extension ProgramaCLI { + /// Report-only probe of Aside's Chrome DevTools Protocol endpoint. Returns the + /// `webSocketDebuggerUrl` from `http://127.0.0.1:9223/json/version` when Aside is + /// running, nil otherwise (including on any network/parse failure). + func asideDevToolsEndpoint(timeout: TimeInterval = 2) -> String? { + guard let url = URL(string: "http://127.0.0.1:9223/json/version") else { return nil } + var request = URLRequest(url: url) + request.timeoutInterval = timeout + let semaphore = DispatchSemaphore(value: 0) + var result: String? + let task = URLSession.shared.dataTask(with: request) { data, _, _ in + defer { semaphore.signal() } + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let webSocketDebuggerUrl = json["webSocketDebuggerUrl"] as? String else { return } + result = webSocketDebuggerUrl + } + task.resume() + _ = semaphore.wait(timeout: .now() + timeout + 0.5) + return result + } + + private func asideResolveClients(environment: [String: String]) -> (claude: String?, codex: String?) { + let searchPath = environment["PATH"] + return ( + resolveClaudeExecutable(searchPath: searchPath), + resolveCodexExecutable(searchPath: searchPath) + ) + } + + private func asideIsRegistered(executable: String, serverName: String) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = ["mcp", "get", serverName] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + private func asideRunCommand(_ command: [String]) throws { + guard let executable = command.first else { return } + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = Array(command.dropFirst()) + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw CLIError(message: "Command failed (\(process.terminationStatus)): \(command.joined(separator: " "))") + } + } + + func runAside(arguments: [String]) throws { + let subcommand = arguments.first?.lowercased() ?? "help" + let withDevTools = arguments.contains("--with-devtools") + let skipConfirm = arguments.contains("--yes") || arguments.contains("-y") + let environment = ProcessInfo.processInfo.environment + + let asidePath = AsideCLILocator.resolve(environment: environment) { FileManager.default.isExecutableFile(atPath: $0) } + let (claudeExecutable, codexExecutable) = asideResolveClients(environment: environment) + + switch subcommand { + case "status": + if let asidePath { + print("Aside CLI: \(asidePath)") + } else { + print("Aside CLI: not found, install from https://docs.aside.com/help/developers") + } + if let endpoint = asideDevToolsEndpoint() { + print("DevTools: \(endpoint)") + } else { + print("DevTools: not reachable (is Aside running?)") + } + if let claudeExecutable { + let registered = asideIsRegistered(executable: claudeExecutable, serverName: "aside") + print("Claude Code: \(claudeExecutable): aside \(registered ? "registered" : "not registered")") + } else { + print("Claude Code: not found") + } + if let codexExecutable { + let registered = asideIsRegistered(executable: codexExecutable, serverName: "aside") + print("Codex: \(codexExecutable): aside \(registered ? "registered" : "not registered")") + } else { + print("Codex: not found") + } + return + + case "install-mcp", "uninstall-mcp": + let install = subcommand == "install-mcp" + // Removal only needs the client CLIs; the Aside binary may already be gone. + if install, asidePath == nil { + throw CLIError(message: "Aside CLI not found. Install it from https://docs.aside.com/help/developers") + } + let planAsidePath = asidePath ?? "aside" + if claudeExecutable == nil, codexExecutable == nil { + throw CLIError(message: "Neither Claude Code nor Codex CLI was found on PATH.") + } + + print("Aside CLI: \(asidePath ?? "not found")") + if let endpoint = asideDevToolsEndpoint() { + print("DevTools: \(endpoint)") + } + if claudeExecutable == nil { + print("Claude Code: not found, skipping") + } + if codexExecutable == nil { + print("Codex: not found, skipping") + } + + let plan = AsideMCPPlan.build( + asidePath: planAsidePath, + claudeExecutable: claudeExecutable, + codexExecutable: codexExecutable, + withDevTools: withDevTools, + install: install + ) + + var pendingCommands: [(client: String, serverName: String, command: [String])] = [] + for command in plan.claudeCommands { + let serverName = command.contains("aside-devtools") ? "aside-devtools" : "aside" + pendingCommands.append((client: "Claude Code", serverName: serverName, command: command)) + } + for command in plan.codexCommands { + let serverName = command.contains("aside-devtools") ? "aside-devtools" : "aside" + pendingCommands.append((client: "Codex", serverName: serverName, command: command)) + } + + guard !pendingCommands.isEmpty else { + print("Nothing to do.") + return + } + + print("") + print("The following commands will run:") + for entry in pendingCommands { + print(" \(entry.command.joined(separator: " "))") + } + + if !skipConfirm { + print("Apply these changes? [Y/n] ", terminator: "") + // EOF (closed or non-interactive stdin) counts as "no": never fail open. + guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + print("") + print("Aborted (no confirmation on stdin; pass --yes to skip the prompt).") + return + } + if !response.isEmpty && response != "y" && response != "yes" { + print("Aborted.") + return + } + } + + var failures: [String] = [] + for entry in pendingCommands { + guard let executable = entry.command.first else { continue } + let registered = asideIsRegistered(executable: executable, serverName: entry.serverName) + if install, registered { + print("\(entry.client): \(entry.serverName) already registered, skipping") + continue + } + if !install, !registered { + print("\(entry.client): \(entry.serverName) not registered, skipping") + continue + } + print("Running: \(entry.command.joined(separator: " "))") + do { + try asideRunCommand(entry.command) + } catch { + // Keep going so one client's failure never leaves the other client's + // registration untouched; report everything at the end. + failures.append("\(entry.client) \(entry.serverName): \(error)") + } + } + print("") + if failures.isEmpty { + print(install ? "Installed." : "Removed.") + return + } + throw CLIError(message: (install ? "Some registrations failed:\n " : "Some removals failed:\n ") + failures.joined(separator: "\n ")) + + default: + print("Usage: programa aside [--with-devtools] [--yes]") + throw CLIError(message: "Unknown aside subcommand: \(subcommand)") + } + } +} diff --git a/CLI/CLICommandDispatcher.swift b/CLI/CLICommandDispatcher.swift index 9fe6d9fd..bfde0ab2 100644 --- a/CLI/CLICommandDispatcher.swift +++ b/CLI/CLICommandDispatcher.swift @@ -187,6 +187,9 @@ struct CLICommandDispatcher { explicitPassword: socketPasswordArg ) return + case "aside": + try cli.runAside(arguments: commandArgs) + return default: break } diff --git a/CLI/programa.swift b/CLI/programa.swift index 96483a11..520bd5d6 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -1153,6 +1153,23 @@ struct ProgramaCLI { """, execute: nil ), + CommandDescriptor( + names: ["aside"], + helpLines: ["aside [--with-devtools] [--yes]"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa aside [--with-devtools] [--yes] + + Detect the Aside agent browser CLI and register its MCP server with + Claude Code and Codex. `status` reports the detected aside binary, + the DevTools endpoint if Aside is running, and each client's + registration state. `install-mcp` runs `claude mcp add` / `codex mcp add` + for each detected client; `--with-devtools` also registers a + chrome-devtools-mcp server pointed at Aside's DevTools port. + `uninstall-mcp` removes both. `--yes`/`-y` skips the confirmation prompt. + """, + execute: nil + ), CommandDescriptor( names: ["ping"], @@ -6470,6 +6487,8 @@ struct ProgramaCLI { return case "codex", "claude", "opencode": _ = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1) + case "aside": + _ = try parse(booleans: ["yes", "y", "with-devtools"], minPositionals: 1, maxPositionals: 1) // Commands with richer bespoke contracts are validated by their // dedicated cases in `validateArguments`. case "ping", "focus-panel", "read-screen", "wait-surface", "set-progress", "list-log", "watch-events": diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9e35f6ec..451acfc4 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -256,6 +256,7 @@ 6C9DA4528D8FCFF501A5FA8C /* programa-mcp in Copy CLI */ = {isa = PBXBuildFile; fileRef = 0AC9A9E0A68EF163F87A0C83 /* programa-mcp */; }; B9000031A1B2C3D4E5F60719 /* CLI+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */; }; B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */; }; + A51DE0700000000000000001 /* CLI+Aside.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51DE0700000000000000002 /* CLI+Aside.swift */; }; B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */; }; B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */; }; B900003BA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */; }; @@ -661,6 +662,7 @@ 30DC1E7B0A701824297403A5 /* FocusTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FocusTools.swift"; sourceTree = ""; }; B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Markdown.swift"; sourceTree = ""; }; B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Browser.swift"; sourceTree = ""; }; + A51DE0700000000000000002 /* CLI+Aside.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Aside.swift"; sourceTree = ""; }; B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Themes.swift"; sourceTree = ""; }; B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Tree.swift"; sourceTree = ""; }; B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+TmuxCompat.swift"; sourceTree = ""; }; @@ -1075,6 +1077,7 @@ B9000001A1B2C3D4E5F60719 /* programa.swift */, B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */, B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */, + A51DE0700000000000000002 /* CLI+Aside.swift */, B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */, B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */, B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */, @@ -1705,6 +1708,7 @@ RVPN00000000000000000010 /* CLI+Review.swift in Sources */, RCAP000001 /* CLI+Recap.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */, + A51DE0700000000000000001 /* CLI+Aside.swift in Sources */, B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */, THTM0003 /* TerminalThemeStore.swift in Sources */, B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */, diff --git a/README.md b/README.md index 38c34525..8d056917 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Choose separate light and dark Ghostty themes in `Settings → Appearance → Te Agents running inside programa (Claude Code, Codex, OpenCode) can drive the app itself, splitting panes, reading a sibling pane's output, spawning and coordinating a helper agent, all without stealing your focus. `programa claude/codex/opencode install-integration` installs [`SKILL.md`](SKILL.md) alongside the existing hooks; see [docs/agent-skill.md](docs/agent-skill.md) for the full walkthrough. -The same control surface is also available over MCP, for agents that speak it natively. Point your client at `Programa.app/Contents/Resources/bin/programa-mcp`; see [docs/mcp-server.md](docs/mcp-server.md). +The same control surface is also available over MCP, for agents that speak it natively. Point your client at `Programa.app/Contents/Resources/bin/programa-mcp`; see [docs/mcp-server.md](docs/mcp-server.md). The MCP server also exposes programa's embedded browser as `browser_*` tools, and `programa aside install-mcp` registers the [Aside](https://aside.com) browser with Claude Code and Codex for logged-in sites; see [docs/aside-browser.md](docs/aside-browser.md). ## Community diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cf3f5cc2..562e48eb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -8599,6 +8599,74 @@ } } }, + "settings.browser.externalBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open External Links With" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外部リンクを開くブラウザ" + } + } + } + }, + "settings.browser.externalBrowser.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used for terminal links and browser links that open outside Programa." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Programa の外部で開くターミナルリンクやブラウザリンクに使用されます。" + } + } + } + }, + "settings.browser.externalBrowser.systemDefault": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムのデフォルト" + } + } + } + }, + "menu.file.installAsideMCP": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Aside Browser MCP…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Aside ブラウザ MCP をインストール…" + } + } + } + }, "settings.browser.externalPatterns": { "extractionState": "manual", "localizations": { diff --git a/SKILL.md b/SKILL.md index 0adb776d..66b039df 100644 --- a/SKILL.md +++ b/SKILL.md @@ -213,6 +213,30 @@ Use the same panel that renders `programa markdown open` for it, so lean on its `programa recap list` shows the slugs already saved. Keep the recap itself short and plain, the same way you'd summarize the change in chat. +## Browser work + +Two browsers, two jobs. Do not reach for a Chrome extension for either. + +- **Local previews, smoke tests, screenshots you read back, DOM checks, console errors:** use programa's embedded browser. It opens beside your pane, keeps its own profile, and never moves the user's focus: + + ```bash + programa browser open-split http://localhost:3000 # prints the new surface id + programa browser --surface surface:7 snapshot --interactive # interactive elements only + programa browser --surface surface:7 click "button.submit" --snapshot-after + programa browser --surface surface:7 screenshot --out /tmp/after.png + programa browser --surface surface:7 tab close + ``` + + `programa browser --help` lists the rest (wait, fill, eval, cookies, console, errors). Network routing, viewport, and raw input injection are not supported on WKWebView and return `not_supported`. Over MCP the same calls are the `browser_*` tools of `programa-mcp`. + +- **Logged-in sites, private dashboards, CI logs, anything that needs the user's real browser profile:** use Aside through its MCP server if it is registered (tools from the `aside` server, or `aside-devtools` for raw Chrome DevTools control), or delegate a whole task from the shell: + + ```bash + aside "Open the staging dashboard and tell me whether the last deploy is green" + ``` + + `programa aside status` says whether Aside is installed and registered; `programa aside install-mcp` registers it with Claude Code and Codex. Do not run the installer yourself unless the user asks, it edits their agent config. + ## Reference - `--workspace`/`--surface`/`--pane`/`--window` accept either a short ref (`workspace:2`, `surface:4`) or a raw UUID; omitted, they default to `$PROGRAMA_WORKSPACE_ID`/`$PROGRAMA_SURFACE_ID`. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 08d0a4d2..af5dae52 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4756,6 +4756,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return workspace.id } + /// Opens a new workspace and runs `programa aside install-mcp --with-devtools` in it, + /// mirroring openOpenCodeIntegrationInstaller: no working-directory override, since + /// the installer targets the user's global Claude Code / Codex MCP config, not a project. + @discardableResult + func openAsideMCPInstaller(event: NSEvent? = nil, debugSource: String = "unspecified") -> UUID? { + discardOrphanedMainWindowContexts() + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + openNewMainWindow(nil) + return nil + } + guard let window = resolvedWindow(for: context) else { + discardOrphanedMainWindowContext(context) + openNewMainWindow(nil) + return nil + } + setActiveMainWindow(window) + + let workspace = context.tabManager.addWorkspace( + initialTerminalInput: "programa aside install-mcp --with-devtools\n", + select: true + ) + return workspace.id + } + private func preferredMainWindowContextForWorkspaceCreation( event: NSEvent? = nil, debugSource: String = "unspecified" diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 35936d21..3484dfc5 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -2190,7 +2190,7 @@ class GhosttyApp { dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") #endif return performOnMain { - NSWorkspace.shared.open(target.url) + BrowserLinkOpenSettings.openExternally(target.url) } } switch target { @@ -2199,7 +2199,7 @@ class GhosttyApp { dlog("link.openURL target=external, opening externally url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } case let .embeddedBrowser(url): if BrowserLinkOpenSettings.shouldOpenExternally(url) { @@ -2207,7 +2207,7 @@ class GhosttyApp { dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { @@ -2215,7 +2215,7 @@ class GhosttyApp { dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } @@ -2225,7 +2225,7 @@ class GhosttyApp { dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } let sourceWorkspaceId = callbackTabId @@ -2262,7 +2262,7 @@ class GhosttyApp { "tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" ) #endif - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) return } let workspace = resolved.workspace @@ -2279,14 +2279,14 @@ class GhosttyApp { dlog("link.openURL opening in existing browser pane=\(targetPane)") #endif if workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } else { #if DEBUG dlog("link.openURL opening as new browser split from surface=\(sourcePanelId)") #endif if workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index bcaac8fa..11f2650e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -466,7 +466,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserPasskeyHandoffAlertBuilder.configure(alert) let handleResponse: @MainActor @Sendable (NSApplication.ModalResponse) -> Void = { response in guard response == .alertFirstButtonReturn else { return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } if let alertWindow = insecureHTTPAlertWindowProvider() { alert.beginSheetModal(for: alertWindow, completionHandler: handleResponse) @@ -2553,7 +2553,7 @@ final class BrowserPanel: Panel, ObservableObject { } switch response { case .alertFirstButtonReturn: - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) case .alertSecondButtonReturn: switch intent { case .currentTab: diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index e33c8505..e45f6348 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -240,7 +240,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { if let opener = self?.openerPanel { opener.openLinkInNewTab(url: url) } else { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } @@ -369,7 +369,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { if let openerPanel { openerPanel.openLinkInNewTab(url: url) } else { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } @@ -384,7 +384,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { let alert = BrowserPasskeyHandoffAlertBuilder.makeAlert() let handleResponse: @MainActor @Sendable (NSApplication.ModalResponse) -> Void = { response in guard response == .alertFirstButtonReturn else { return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } alert.beginSheetModal(for: panel, completionHandler: handleResponse) } @@ -414,8 +414,8 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { } switch response { case .alertFirstButtonReturn: - // Open in default browser, cancel popup navigation - NSWorkspace.shared.open(url) + // Open outside Programa, cancel popup navigation + BrowserLinkOpenSettings.openExternally(url) decisionHandler(.cancel) case .alertSecondButtonReturn: // Proceed in popup @@ -454,7 +454,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { // External URL check if let url = navigationAction.request.url, browserShouldOpenURLExternally(url) { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) return nil } diff --git a/Sources/Panels/BrowserSettings.swift b/Sources/Panels/BrowserSettings.swift index 49cd7abf..e7e7e665 100644 --- a/Sources/Panels/BrowserSettings.swift +++ b/Sources/Panels/BrowserSettings.swift @@ -217,6 +217,9 @@ enum BrowserLinkOpenSettings { static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns" static let defaultBrowserExternalOpenPatterns: String = "" + static let externalBrowserBundleIdentifierKey = "browserExternalBrowserBundleIdentifier" + static let defaultExternalBrowserBundleIdentifier: String = "" + static func openTerminalLinksInProgramaBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInProgramaBrowserKey) == nil { return defaultOpenTerminalLinksInProgramaBrowser @@ -281,6 +284,58 @@ enum BrowserLinkOpenSettings { return false } + static func externalBrowserBundleIdentifier(defaults: UserDefaults = .standard) -> String { + let raw = defaults.string(forKey: externalBrowserBundleIdentifierKey) ?? defaultExternalBrowserBundleIdentifier + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func externalBrowserApplicationURL(bundleIdentifier: String, workspace: NSWorkspace = .shared) -> URL? { + let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return workspace.urlForApplication(withBundleIdentifier: trimmed) + } + + /// Opens a web link outside Programa. Only http(s) URLs go to the preferred browser; + /// every other scheme (mailto:, slack://, file:) keeps its macOS-registered handler. + /// The preferred-browser launch is asynchronous, so `true` means the launch was + /// dispatched, not that it finished; a launch error is logged, not surfaced. + @discardableResult + static func openExternally(_ url: URL, defaults: UserDefaults = .standard, workspace: NSWorkspace = .shared) -> Bool { + let scheme = url.scheme?.lowercased() + let isWebLink = scheme == "http" || scheme == "https" + let bundleIdentifier = externalBrowserBundleIdentifier(defaults: defaults) + if isWebLink, + let appURL = externalBrowserApplicationURL(bundleIdentifier: bundleIdentifier, workspace: workspace) { + let configuration = NSWorkspace.OpenConfiguration() + workspace.open([url], withApplicationAt: appURL, configuration: configuration) { _, error in + if let error { + NSLog("BrowserLinkOpenSettings.openExternally: launching %@ for %@ failed: %@", appURL.path, url.absoluteString, error.localizedDescription) + } + } + return true + } + return workspace.open(url) + } + + static func installedBrowsers(workspace: NSWorkspace = .shared) -> [(bundleIdentifier: String, name: String)] { + guard let exampleURL = URL(string: "https://example.com") else { return [] } + let ownBundleIdentifier = Bundle.main.bundleIdentifier + var seen = Set() + var results: [(bundleIdentifier: String, name: String)] = [] + for appURL in workspace.urlsForApplications(toOpen: exampleURL) { + guard let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier else { continue } + if bundleIdentifier == ownBundleIdentifier { continue } + guard !seen.contains(bundleIdentifier) else { continue } + seen.insert(bundleIdentifier) + var name = FileManager.default.displayName(atPath: appURL.path) + if name.hasSuffix(".app") { + name = String(name.dropLast(4)) + } + results.append((bundleIdentifier: bundleIdentifier, name: name)) + } + return results.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + /// Check whether a hostname matches the configured whitelist. /// Empty whitelist means "allow all" (no filtering). /// Supports exact match and wildcard prefix (`*.example.com`). diff --git a/Sources/Panels/ProgramaWebView.swift b/Sources/Panels/ProgramaWebView.swift index 881eeeeb..56bb4494 100644 --- a/Sources/Panels/ProgramaWebView.swift +++ b/Sources/Panels/ProgramaWebView.swift @@ -1171,7 +1171,7 @@ final class ProgramaWebView: WKWebView { _ = contextMenuDefaultBrowserOpener(url) return } - _ = NSWorkspace.shared.open(url) + _ = BrowserLinkOpenSettings.openExternally(url) } private func runContextMenuFallback( diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index bcd2b2f3..8df4e9eb 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -555,6 +555,15 @@ struct programaApp: App { ) { AppDelegate.shared?.openOpenCodeIntegrationInstaller(debugSource: "menu.installOpenCodeIntegration") } + + Button( + String( + localized: "menu.file.installAsideMCP", + defaultValue: "Install Aside Browser MCP…" + ) + ) { + AppDelegate.shared?.openAsideMCPInstaller(debugSource: "menu.installAsideMCP") + } } // Close tab/workspace diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index 333d024f..da98e2f7 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -781,6 +781,14 @@ final class ProgramaSettingsFileStore { } else if section.keys.contains("urlsToAlwaysOpenExternally") { logInvalid("browser.urlsToAlwaysOpenExternally", sourcePath: sourcePath) } + if let raw = section["externalBrowser"] { + if let value = jsonString(raw) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + snapshot.managedUserDefaults[BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey] = .string(trimmed) + } else { + logInvalid("browser.externalBrowser", sourcePath: sourcePath) + } + } if let values = jsonStringArray(section["insecureHttpHostsAllowedInEmbeddedBrowser"]) { let normalized = values .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -1618,6 +1626,7 @@ final class ProgramaSettingsFileStore { "interceptTerminalOpenCommandInProgramaBrowser": BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInProgramaBrowser, "hostsToOpenInEmbeddedBrowser": [String](), "urlsToAlwaysOpenExternally": [String](), + "externalBrowser": BrowserLinkOpenSettings.defaultExternalBrowserBundleIdentifier, "insecureHttpHostsAllowedInEmbeddedBrowser": BrowserInsecureHTTPSettings.defaultAllowlistPatterns, ], ], diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 47a386ab..69f3b09f 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -62,6 +62,8 @@ struct SettingsView: View { @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns + @AppStorage(BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + private var externalBrowserBundleIdentifier = BrowserLinkOpenSettings.defaultExternalBrowserBundleIdentifier @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @@ -105,6 +107,7 @@ struct SettingsView: View { @State private var socketPasswordStatusMessage: String? @State private var socketPasswordStatusIsError = false @State private var trustedDirectoriesDraft: String = ProgramaDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") + @State private var installedExternalBrowsers: [(bundleIdentifier: String, name: String)] = [] private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -1205,6 +1208,23 @@ struct SettingsView: View { .controlSize(.small) } + SettingsCardDivider() + + SettingsPickerRow( + String(localized: "settings.browser.externalBrowser", defaultValue: "Open External Links With"), + subtitle: String(localized: "settings.browser.externalBrowser.subtitle", defaultValue: "Used for terminal links and browser links that open outside Programa."), + controlWidth: pickerColumnWidth, + selection: $externalBrowserBundleIdentifier + ) { + Text(String(localized: "settings.browser.externalBrowser.systemDefault", defaultValue: "System Default")).tag("") + ForEach(installedExternalBrowsers, id: \.bundleIdentifier) { browser in + Text(browser.name).tag(browser.bundleIdentifier) + } + } + .onAppear { + installedExternalBrowsers = BrowserLinkOpenSettings.installedBrowsers() + } + if openTerminalLinksInProgramaBrowser || interceptTerminalOpenCommandInProgramaBrowser { SettingsCardDivider() @@ -1430,6 +1450,7 @@ struct SettingsView: View { interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInProgramaBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns + externalBrowserBundleIdentifier = BrowserLinkOpenSettings.defaultExternalBrowserBundleIdentifier browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationSound = NotificationSoundSettings.defaultValue diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 73da3314..13142aa4 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -1478,11 +1478,11 @@ struct TabItemView: View, Equatable { preferSplitRight: true, insertAtEnd: true ) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } private func openPortLink(_ port: Int) { @@ -1495,11 +1495,11 @@ struct TabItemView: View, Equatable { preferSplitRight: true, insertAtEnd: true ) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } private func pullRequestStatusLabel( @@ -1982,7 +1982,7 @@ private struct SidebarMetadataEntryRow: View { if let url = entry.url { Button { onFocus() - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } label: { rowContent(underlined: true) } diff --git a/Sources/TerminalController+BrowserAutomation.swift b/Sources/TerminalController+BrowserAutomation.swift index 25424365..bb1f06b2 100644 --- a/Sources/TerminalController+BrowserAutomation.swift +++ b/Sources/TerminalController+BrowserAutomation.swift @@ -2071,7 +2071,7 @@ extension TerminalController { if let url, respectExternalOpenRules, BrowserLinkOpenSettings.shouldOpenExternally(url) { - guard NSWorkspace.shared.open(url) else { + guard BrowserLinkOpenSettings.openExternally(url) else { result = .err( code: "external_open_failed", message: "Failed to open URL externally", diff --git a/docs/agent-skill.md b/docs/agent-skill.md index 940b7fcb..f2eb9356 100644 --- a/docs/agent-skill.md +++ b/docs/agent-skill.md @@ -35,6 +35,7 @@ The first thing the skill does is check `PROGRAMA_SURFACE_ID` and `PROGRAMA_SOCK - **Waiting** — `wait-surface` gives a server-owned blocking wait on a surface: `--pattern ` resolves when new output matches, `--exit` when the process exits, both with `--timeout`. Waiting on *agent state* (idle/working/blocked) is implemented too — the underlying `surface.wait` socket method takes an `agent_state` condition (see `docs/v2-api-migration.md`), and `prompt-agent` is built directly on it to send a prompt and wait for the agent to go idle again. The tmux-compatible `wait-for` / `wait-for -S` named-signal rendezvous covers two cooperating processes. - **Authoring screen-detection manifests for an unsupported agent** — programa's agent-state detection reads a pane's own lifecycle hooks when available (Claude Code, Codex, OpenCode), and otherwise falls back to regex-matching visible screen text against a manifest. For any other agent, `programa agent-detection scaffold ` captures the current screen and writes a starter manifest to `~/.config/programa/agent-detection/.json`; fill in its `patterns` arrays, then `programa agent-detection test ` to check them against the live screen. `programa agent-detection list` shows every loaded manifest (bundled + your overrides). - **Writing a recap** — when the user asks for a summary of a change, write markdown to `.programa/recaps/.md` and open it with `programa recap open ` (`programa recap list` shows what's saved). The markdown panel renders mermaid diagrams, GitHub-style alerts, and `:::compare` before/after blocks, so the skill tells the agent to reach for those instead of plain prose when they fit. +- **Browser work** — two browsers for two jobs. `programa browser open-split` / `snapshot` / `click` / `screenshot` drive programa's embedded browser for local previews and smoke tests without moving focus; Aside (via its MCP server, or `aside ""` from the shell) handles logged-in sites. `programa aside install-mcp` registers Aside with Claude Code and Codex. See [`docs/aside-browser.md`](aside-browser.md). ## Verifying it @@ -42,4 +43,4 @@ There's no automated test for "does an agent actually behave correctly" — this ## Command reference -The skill only covers the commands relevant to agent coordination. For the full CLI surface (the in-app browser, tmux-compat commands, hooks) run `programa help`, or see [`docs/v2-api-migration.md`](v2-api-migration.md) for the underlying socket API. +The skill only covers the commands relevant to agent coordination. For the full CLI surface (the in-app browser, tmux-compat commands, hooks) run `programa help`, see [`aside-browser.md`](aside-browser.md) for the browser split between programa's panel and Aside, or see [`docs/v2-api-migration.md`](v2-api-migration.md) for the underlying socket API. diff --git a/docs/aside-browser.md b/docs/aside-browser.md new file mode 100644 index 00000000..82ede5b5 --- /dev/null +++ b/docs/aside-browser.md @@ -0,0 +1,81 @@ +# Two browsers for agents: Programa's panel and Aside + +Coding agents need a browser for two different jobs, and Programa treats them as two +different tools: + +| Job | Use | Why | +| --- | --- | --- | +| Local previews, smoke tests, screenshots the agent reads back, DOM inspection, console and error logs | **Programa's embedded browser** | It lives next to the agent's pane, has its own per-workspace profile, never moves the user's focus, and every action is a socket method (`browser.*`) that the CLI and `programa-mcp` both expose. | +| Logged-in sites, private dashboards, CI logs, anything that depends on your real browsing profile and memory | **Aside** ([aside.com](https://aside.com)), a Chromium-based agent browser | Aside owns the sessions and cookies. Programa registers it with Claude Code and Codex so the agent can hand that work off instead of driving a Chrome extension. | + +Neither replaces the other. Programa's panel is WKWebView, so it has no Chrome DevTools +Protocol and does not share cookies with Aside. Aside has the logins but is not a pane in +your workspace. + +## Programa's browser from an agent + +From a shell inside a Programa pane, the `programa browser` CLI covers the whole surface: + +```bash +programa browser open-split https://localhost:3000 # new browser split beside this pane +programa browser --surface surface:7 snapshot --interactive # accessibility tree of interactive elements +programa browser --surface surface:7 click "button.submit" --snapshot-after +programa browser --surface surface:7 screenshot --out /tmp/after.png +programa browser --surface surface:7 tab close +``` + +Over MCP, the same methods are the `browser_*` tools in `programa-mcp` (`browser_open_split`, +`browser_navigate`, `browser_snapshot`, `browser_get_text`, `browser_screenshot`, +`browser_console_list`, and so on). A few Playwright-shaped tools (network routing, viewport, +raw input injection) return `not_supported` because WKWebView has no DevTools Protocol. Only the three `focus_browser_*` tools move focus; +everything else leaves the user where they are. See [mcp-server.md](mcp-server.md) for the +full list and the setup. + +## Aside from an agent + +Aside ships a CLI (`aside`) and an MCP server (`aside mcp`, stdio). Programa can register +that server with Claude Code and Codex for you: + +```bash +programa aside status # where the aside binary is, whether Aside is running, what is registered +programa aside install-mcp # registers `aside` (aside mcp) with Claude Code and Codex +programa aside install-mcp --with-devtools +programa aside uninstall-mcp +``` + +`File > Install Aside Browser MCP…` runs the same installer in a new workspace. + +The `--with-devtools` flag adds a second server named `aside-devtools`. While Aside is +running it exposes the Chrome DevTools Protocol on `127.0.0.1:9223`, and +`chrome-devtools-mcp` pointed at that URL gives an agent full DevTools control of Aside's +tabs (navigation, clicks, console, network, performance traces) with no extension involved. +`programa aside status` reports the endpoint when it is reachable. + +Once registered, an agent can also delegate a whole task from a pane without MCP: + +```bash +aside "Open the staging dashboard and check whether the last deploy is green" +aside --session "Continue and report any failures" +``` + +Install the Aside CLI from Settings > Developer inside Aside, or with the command on +[docs.aside.com/help/developers](https://docs.aside.com/help/developers). Programa looks for +it at `~/.local/bin/aside`, then `~/.aside/cli/Aside CLI.app/Contents/MacOS/aside`, then on +`PATH`. + +## Sending links to Aside + +Terminal links open in Programa's panel when the host is local or allowlisted, and in an +external browser otherwise. `Settings > Browser > Open External Links With` picks that +external browser; choose Aside there and every cmd-click that leaves Programa lands in the +browser that has your logins and agent memory. The same setting is `browser.externalBrowser` +in `~/.config/programa/settings.json` (a bundle identifier; empty means the macOS default). + +## Aside driving Programa + +The reverse direction, Aside dispatching work into a Claude Code or Codex pane, needs Aside +to act as an MCP client. Aside does not document that today. When it does, point it at +`Programa.app/Contents/Resources/bin/programa-mcp`: the `surface_send_text`, +`surface_read_text`, `surface_split`, and `surface_wait` tools already let a client start an +agent in a pane, send it a prompt, wait for it to go idle, and read the result, without +scraping a terminal screen. Nothing on Programa's side needs to change for that. diff --git a/docs/settings-json.md b/docs/settings-json.md index 6060596e..ba6ea63e 100644 --- a/docs/settings-json.md +++ b/docs/settings-json.md @@ -92,6 +92,7 @@ Embedded browser settings from Settings > Browser. | `interceptTerminalOpenCommandInProgramaBrowser` | boolean | `true` | Intercept terminal open http(s) commands and route them through the embedded browser. | | `hostsToOpenInEmbeddedBrowser` | array | `[]` | Allowlist of hosts that should stay inside the embedded browser. | | `urlsToAlwaysOpenExternally` | array | `[]` | Rules that always open matching URLs in the system browser. | +| `externalBrowser` | string | `""` | Bundle identifier of the browser used for links that open outside Programa (for example `at.studio.AsideBrowser`, replace with the real Aside bundle id you discovered). Empty uses the macOS default browser. | | `insecureHttpHostsAllowedInEmbeddedBrowser` | array | `["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"]` | HTTP hosts allowed in the embedded browser without a warning prompt. | | `proxy` | object | | Route the embedded browser through a proxy. Requires host and port; type defaults to socks5. | diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index 17ba4edf..9425e860 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -4125,6 +4125,89 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { ) ) } + + func testExternalBrowserApplicationURLReturnsNilForEmptyBundleIdentifier() { + XCTAssertNil(BrowserLinkOpenSettings.externalBrowserApplicationURL(bundleIdentifier: "")) + } + + func testExternalBrowserApplicationURLReturnsNilForUnknownBundleIdentifier() { + XCTAssertNil( + BrowserLinkOpenSettings.externalBrowserApplicationURL(bundleIdentifier: "com.example.does-not-exist") + ) + } + + func testExternalBrowserApplicationURLResolvesInstalledApplication() throws { + let url = try XCTUnwrap( + BrowserLinkOpenSettings.externalBrowserApplicationURL(bundleIdentifier: "com.apple.Safari") + ) + XCTAssertTrue(url.path.hasSuffix(".app")) + } + + func testOpenExternallyFallsBackToSystemOpenForUnknownBundleIdentifier() { + defaults.set("com.example.does-not-exist", forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + let workspace = BrowserExternalOpenRecordingWorkspace() + let url = try! XCTUnwrap(URL(string: "https://example.com")) + + let opened = BrowserLinkOpenSettings.openExternally(url, defaults: defaults, workspace: workspace) + + XCTAssertTrue(opened) + XCTAssertEqual(workspace.openedURLs, [url]) + XCTAssertTrue(workspace.openedWithApplicationURLs.isEmpty) + } + + func testOpenExternallyLaunchesResolvedApplicationForKnownBundleIdentifier() throws { + defaults.set("com.apple.Safari", forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + let workspace = BrowserExternalOpenRecordingWorkspace() + workspace.applicationURLOverride = URL(fileURLWithPath: "/Applications/Safari.app") + let url = try XCTUnwrap(URL(string: "https://example.com")) + + let opened = BrowserLinkOpenSettings.openExternally(url, defaults: defaults, workspace: workspace) + + XCTAssertTrue(opened) + XCTAssertTrue(workspace.openedURLs.isEmpty) + XCTAssertEqual(workspace.openedWithApplicationURLs.map(\.0), [url]) + XCTAssertEqual(workspace.openedWithApplicationURLs.map(\.1), [workspace.applicationURLOverride]) + } + + func testOpenExternallyKeepsNonWebSchemesOnSystemHandlerEvenWithPreferredBrowser() throws { + defaults.set("com.apple.Safari", forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + let workspace = BrowserExternalOpenRecordingWorkspace() + workspace.applicationURLOverride = URL(fileURLWithPath: "/Applications/Safari.app") + let mailto = try XCTUnwrap(URL(string: "mailto:someone@example.com")) + let deepLink = try XCTUnwrap(URL(string: "slack://open?team=T1")) + + XCTAssertTrue(BrowserLinkOpenSettings.openExternally(mailto, defaults: defaults, workspace: workspace)) + XCTAssertTrue(BrowserLinkOpenSettings.openExternally(deepLink, defaults: defaults, workspace: workspace)) + + XCTAssertEqual(workspace.openedURLs, [mailto, deepLink]) + XCTAssertTrue(workspace.openedWithApplicationURLs.isEmpty) + } +} + +private final class BrowserExternalOpenRecordingWorkspace: NSWorkspace { + var openedURLs: [URL] = [] + var openedWithApplicationURLs: [(URL, URL)] = [] + var applicationURLOverride: URL? + + override func open(_ url: URL) -> Bool { + openedURLs.append(url) + return true + } + + override func urlForApplication(withBundleIdentifier bundleIdentifier: String) -> URL? { + applicationURLOverride + } + + override func open( + _ urls: [URL], + withApplicationAt applicationURL: URL, + configuration: NSWorkspace.OpenConfiguration, + completionHandler: (@Sendable (NSRunningApplication?, Error?) -> Void)? = nil + ) { + for url in urls { + openedWithApplicationURLs.append((url, applicationURL)) + } + } } diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 3ff5c709..443bfdff 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -1445,6 +1445,62 @@ final class TerminalThemeSettingsTests: XCTestCase { XCTAssertEqual(reloadRequestCount, 2) } + func testSettingsFileMapsExternalBrowserToManagedDefaults() throws { + let defaults = UserDefaults.standard + let previousValue = defaults.string(forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + defer { + restoreDefaultsValue( + previousValue, + key: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey, + defaults: defaults + ) + } + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + let configURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + let themeStore = TerminalThemeStore( + fileManager: .default, + managedConfigURL: configURL, + configSearchURLs: [configURL] + ) + try writeSettingsFile( + """ + { + "browser": { + "externalBrowser": "com.apple.Safari" + } + } + """, + to: settingsURL + ) + + _ = ProgramaSettingsFileStore( + primaryPath: settingsURL.path, + fallbackPath: nil, + fileManager: .default, + notificationCenter: .default, + terminalThemeStore: themeStore, + terminalThemeReloadHandler: {}, + startWatching: false + ) + + XCTAssertEqual( + defaults.string(forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey), + "com.apple.Safari" + ) + } + + private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + func testAppearanceOverridesRoundTripAllFourDirectives() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) }