Merge origin/main into fix-dragdrop-overlay
This commit is contained in:
commit
23979d8c02
29 changed files with 5475 additions and 292 deletions
602
CLI/cmux.swift
602
CLI/cmux.swift
|
|
@ -860,6 +860,19 @@ struct CMUXCLI {
|
|||
let payload = try client.sendV2(method: "workspace.select", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
|
||||
|
||||
case "rename-workspace", "rename-window":
|
||||
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
|
||||
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0)
|
||||
let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw CLIError(message: "\(command) requires a title")
|
||||
}
|
||||
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
|
||||
let params: [String: Any] = ["title": title, "workspace_id": wsId]
|
||||
let payload = try client.sendV2(method: "workspace.rename", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
|
||||
|
||||
case "current-workspace":
|
||||
let response = try client.send(command: "current_workspace")
|
||||
if jsonOutput {
|
||||
|
|
@ -868,6 +881,43 @@ struct CMUXCLI {
|
|||
print(response)
|
||||
}
|
||||
|
||||
case "read-screen":
|
||||
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
|
||||
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
|
||||
let (linesArg, rem2) = parseOption(rem1, name: "--lines")
|
||||
let trailing = rem2.filter { $0 != "--scrollback" }
|
||||
if !trailing.isEmpty {
|
||||
throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))")
|
||||
}
|
||||
|
||||
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
|
||||
var params: [String: Any] = [:]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
|
||||
let includeScrollback = rem2.contains("--scrollback")
|
||||
if includeScrollback {
|
||||
params["scrollback"] = true
|
||||
}
|
||||
if let linesArg {
|
||||
guard let lineCount = Int(linesArg), lineCount > 0 else {
|
||||
throw CLIError(message: "--lines must be greater than 0")
|
||||
}
|
||||
params["lines"] = lineCount
|
||||
params["scrollback"] = true
|
||||
}
|
||||
|
||||
let payload = try client.sendV2(method: "surface.read_text", params: params)
|
||||
if jsonOutput {
|
||||
print(jsonString(payload))
|
||||
} else {
|
||||
print((payload["text"] as? String) ?? "")
|
||||
}
|
||||
|
||||
case "send":
|
||||
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
|
||||
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
|
||||
|
|
@ -988,6 +1038,38 @@ struct CMUXCLI {
|
|||
let response = try client.send(command: "simulate_app_active")
|
||||
print(response)
|
||||
|
||||
case "capture-pane",
|
||||
"resize-pane",
|
||||
"pipe-pane",
|
||||
"wait-for",
|
||||
"swap-pane",
|
||||
"break-pane",
|
||||
"join-pane",
|
||||
"last-window",
|
||||
"last-pane",
|
||||
"next-window",
|
||||
"previous-window",
|
||||
"find-window",
|
||||
"clear-history",
|
||||
"set-hook",
|
||||
"popup",
|
||||
"bind-key",
|
||||
"unbind-key",
|
||||
"copy-mode",
|
||||
"set-buffer",
|
||||
"paste-buffer",
|
||||
"list-buffers",
|
||||
"respawn-pane",
|
||||
"display-message":
|
||||
try runTmuxCompatCommand(
|
||||
command: command,
|
||||
commandArgs: commandArgs,
|
||||
client: client,
|
||||
jsonOutput: jsonOutput,
|
||||
idFormat: idFormat,
|
||||
windowOverride: windowId
|
||||
)
|
||||
|
||||
case "help":
|
||||
print(usage())
|
||||
|
||||
|
|
@ -2803,6 +2885,70 @@ struct CMUXCLI {
|
|||
cmux select-workspace --workspace workspace:2
|
||||
cmux select-workspace --workspace 0
|
||||
"""
|
||||
case "rename-workspace", "rename-window":
|
||||
return """
|
||||
Usage: cmux rename-workspace [--workspace <id|ref>] [--] <title>
|
||||
|
||||
Rename a workspace. Defaults to the current workspace.
|
||||
tmux-compatible alias: rename-window
|
||||
|
||||
Flags:
|
||||
--workspace <id|ref> Workspace to rename (default: current workspace)
|
||||
|
||||
Example:
|
||||
cmux rename-workspace "backend logs"
|
||||
cmux rename-window --workspace workspace:2 "agent run"
|
||||
"""
|
||||
case "capture-pane":
|
||||
return """
|
||||
Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
|
||||
|
||||
tmux-compatible alias for reading terminal text from a pane.
|
||||
|
||||
Example:
|
||||
cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200
|
||||
"""
|
||||
case "resize-pane":
|
||||
return """
|
||||
Usage: cmux resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>]
|
||||
|
||||
tmux-compatible pane resize command.
|
||||
Note: currently returns not_supported until programmable divider resize is implemented.
|
||||
"""
|
||||
case "pipe-pane":
|
||||
return """
|
||||
Usage: cmux pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>]
|
||||
|
||||
Capture pane text and pipe it to a shell command via stdin.
|
||||
"""
|
||||
case "wait-for":
|
||||
return """
|
||||
Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>]
|
||||
|
||||
Wait for or signal a named synchronization token.
|
||||
"""
|
||||
case "swap-pane", "break-pane", "join-pane", "next-window", "previous-window", "last-window", "last-pane", "find-window", "clear-history", "set-hook", "popup", "bind-key", "unbind-key", "copy-mode", "set-buffer", "paste-buffer", "list-buffers", "respawn-pane", "display-message":
|
||||
return """
|
||||
Usage: cmux \(command) --help
|
||||
|
||||
tmux compatibility command. See `cmux --help` for exact syntax.
|
||||
"""
|
||||
case "read-screen":
|
||||
return """
|
||||
Usage: cmux read-screen [flags]
|
||||
|
||||
Read terminal text from a surface as plain text.
|
||||
|
||||
Flags:
|
||||
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
|
||||
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
|
||||
--scrollback Include scrollback (not just visible viewport)
|
||||
--lines <n> Limit to the last n lines (implies --scrollback)
|
||||
|
||||
Example:
|
||||
cmux read-screen
|
||||
cmux read-screen --surface surface:2 --scrollback --lines 200
|
||||
"""
|
||||
case "send":
|
||||
return """
|
||||
Usage: cmux send [flags] [--] <text>
|
||||
|
|
@ -3025,6 +3171,438 @@ struct CMUXCLI {
|
|||
return output
|
||||
}
|
||||
|
||||
private struct TmuxCompatStore: Codable {
|
||||
var buffers: [String: String] = [:]
|
||||
var hooks: [String: String] = [:]
|
||||
}
|
||||
|
||||
private func tmuxCompatStoreURL() -> URL {
|
||||
let root = NSString(string: "~/.cmuxterm").expandingTildeInPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json")
|
||||
}
|
||||
|
||||
private func loadTmuxCompatStore() -> TmuxCompatStore {
|
||||
let url = tmuxCompatStoreURL()
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode(TmuxCompatStore.self, from: data) else {
|
||||
return TmuxCompatStore()
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func saveTmuxCompatStore(_ store: TmuxCompatStore) throws {
|
||||
let url = tmuxCompatStoreURL()
|
||||
let parent = url.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil)
|
||||
let data = try JSONEncoder().encode(store)
|
||||
try data.write(to: url, options: .atomic)
|
||||
}
|
||||
|
||||
private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.arguments = ["-lc", command]
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
try process.run()
|
||||
if let data = stdinText.data(using: .utf8) {
|
||||
stdinPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
stdinPipe.fileHandleForWriting.closeFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
return (process.terminationStatus, stdout, stderr)
|
||||
}
|
||||
|
||||
private func tmuxWaitForSignalURL(name: String) -> URL {
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
||||
let sanitized = name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return URL(fileURLWithPath: "/tmp/cmux-wait-for-\(String(sanitized)).sig")
|
||||
}
|
||||
|
||||
private func runTmuxCompatCommand(
|
||||
command: String,
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
jsonOutput: Bool,
|
||||
idFormat: CLIIDFormat,
|
||||
windowOverride: String?
|
||||
) throws {
|
||||
switch command {
|
||||
case "capture-pane":
|
||||
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
|
||||
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
|
||||
let (linesArg, rem2) = parseOption(rem1, name: "--lines")
|
||||
let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = sfArg ?? (wsArg == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
|
||||
var params: [String: Any] = [:]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
|
||||
let includeScrollback = rem2.contains("--scrollback")
|
||||
if includeScrollback {
|
||||
params["scrollback"] = true
|
||||
}
|
||||
if let linesArg {
|
||||
guard let lineCount = Int(linesArg), lineCount > 0 else {
|
||||
throw CLIError(message: "--lines must be greater than 0")
|
||||
}
|
||||
params["lines"] = lineCount
|
||||
params["scrollback"] = true
|
||||
}
|
||||
|
||||
let payload = try client.sendV2(method: "surface.read_text", params: params)
|
||||
if jsonOutput {
|
||||
print(jsonString(payload))
|
||||
} else {
|
||||
print((payload["text"] as? String) ?? "")
|
||||
}
|
||||
|
||||
case "resize-pane":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
let paneArg = optionValue(commandArgs, name: "--pane")
|
||||
let amountArg = optionValue(commandArgs, name: "--amount")
|
||||
let amount = Int(amountArg ?? "1") ?? 1
|
||||
if amount <= 0 {
|
||||
throw CLIError(message: "--amount must be greater than 0")
|
||||
}
|
||||
|
||||
let direction: String = {
|
||||
if commandArgs.contains("-L") { return "left" }
|
||||
if commandArgs.contains("-R") { return "right" }
|
||||
if commandArgs.contains("-U") { return "up" }
|
||||
if commandArgs.contains("-D") { return "down" }
|
||||
return "right"
|
||||
}()
|
||||
|
||||
var params: [String: Any] = ["direction": direction, "amount": amount]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId, allowFocused: true)
|
||||
if let paneId { params["pane_id"] = paneId }
|
||||
let payload = try client.sendV2(method: "pane.resize", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"]))
|
||||
|
||||
case "pipe-pane":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface")
|
||||
let (cmdOpt, rem0) = parseOption(commandArgs, name: "--command")
|
||||
let commandText: String = {
|
||||
if let cmdOpt { return cmdOpt }
|
||||
let trimmed = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed
|
||||
}()
|
||||
guard !commandText.isEmpty else {
|
||||
throw CLIError(message: "pipe-pane requires --command <shell-command>")
|
||||
}
|
||||
|
||||
var params: [String: Any] = ["scrollback": true]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
let payload = try client.sendV2(method: "surface.read_text", params: params)
|
||||
let text = (payload["text"] as? String) ?? ""
|
||||
let shell = try runShellCommand(commandText, stdinText: text)
|
||||
if shell.status != 0 {
|
||||
throw CLIError(message: "pipe-pane command failed (\(shell.status)): \(shell.stderr)")
|
||||
}
|
||||
if jsonOutput {
|
||||
print(jsonString([
|
||||
"ok": true,
|
||||
"status": shell.status,
|
||||
"stdout": shell.stdout,
|
||||
"stderr": shell.stderr
|
||||
]))
|
||||
} else {
|
||||
if !shell.stdout.isEmpty {
|
||||
print(shell.stdout, terminator: "")
|
||||
}
|
||||
print("OK")
|
||||
}
|
||||
|
||||
case "wait-for":
|
||||
let signal = commandArgs.contains("-S") || commandArgs.contains("--signal")
|
||||
let timeoutRaw = optionValue(commandArgs, name: "--timeout")
|
||||
let timeout = timeoutRaw.flatMap { Double($0) } ?? 30.0
|
||||
let name = commandArgs.first(where: { !$0.hasPrefix("-") }) ?? ""
|
||||
guard !name.isEmpty else {
|
||||
throw CLIError(message: "wait-for requires a name")
|
||||
}
|
||||
let signalURL = tmuxWaitForSignalURL(name: name)
|
||||
if signal {
|
||||
FileManager.default.createFile(atPath: signalURL.path, contents: Data())
|
||||
print("OK")
|
||||
return
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if FileManager.default.fileExists(atPath: signalURL.path) {
|
||||
try? FileManager.default.removeItem(at: signalURL)
|
||||
print("OK")
|
||||
return
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
throw CLIError(message: "wait-for timed out waiting for '\(name)'")
|
||||
|
||||
case "swap-pane":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
guard let sourcePaneRaw = optionValue(commandArgs, name: "--pane") else {
|
||||
throw CLIError(message: "swap-pane requires --pane")
|
||||
}
|
||||
guard let targetPaneRaw = optionValue(commandArgs, name: "--target-pane") else {
|
||||
throw CLIError(message: "swap-pane requires --target-pane")
|
||||
}
|
||||
var params: [String: Any] = [:]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sourcePane = try normalizePaneHandle(sourcePaneRaw, client: client, workspaceHandle: wsId)
|
||||
let targetPane = try normalizePaneHandle(targetPaneRaw, client: client, workspaceHandle: wsId)
|
||||
if let sourcePane { params["pane_id"] = sourcePane }
|
||||
if let targetPane { params["target_pane_id"] = targetPane }
|
||||
let payload = try client.sendV2(method: "pane.swap", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
|
||||
|
||||
case "break-pane":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
let paneArg = optionValue(commandArgs, name: "--pane")
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface")
|
||||
var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId)
|
||||
if let paneId { params["pane_id"] = paneId }
|
||||
let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
|
||||
if let surfaceId { params["surface_id"] = surfaceId }
|
||||
let payload = try client.sendV2(method: "pane.break", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
|
||||
|
||||
case "join-pane":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
let sourcePaneArg = optionValue(commandArgs, name: "--pane")
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface")
|
||||
guard let targetPaneArg = optionValue(commandArgs, name: "--target-pane") else {
|
||||
throw CLIError(message: "join-pane requires --target-pane")
|
||||
}
|
||||
var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sourcePaneId = try normalizePaneHandle(sourcePaneArg, client: client, workspaceHandle: wsId)
|
||||
if let sourcePaneId { params["pane_id"] = sourcePaneId }
|
||||
let targetPaneId = try normalizePaneHandle(targetPaneArg, client: client, workspaceHandle: wsId)
|
||||
if let targetPaneId { params["target_pane_id"] = targetPaneId }
|
||||
let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
|
||||
if let surfaceId { params["surface_id"] = surfaceId }
|
||||
let payload = try client.sendV2(method: "pane.join", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
|
||||
|
||||
case "last-window":
|
||||
let payload = try client.sendV2(method: "workspace.last")
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
|
||||
|
||||
case "next-window":
|
||||
let payload = try client.sendV2(method: "workspace.next")
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
|
||||
|
||||
case "previous-window":
|
||||
let payload = try client.sendV2(method: "workspace.previous")
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
|
||||
|
||||
case "last-pane":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
var params: [String: Any] = [:]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let payload = try client.sendV2(method: "pane.last", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"]))
|
||||
|
||||
case "find-window":
|
||||
let includeContent = commandArgs.contains("--content")
|
||||
let shouldSelect = commandArgs.contains("--select")
|
||||
let query = commandArgs
|
||||
.filter { !$0.hasPrefix("-") }
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let listPayload = try client.sendV2(method: "workspace.list")
|
||||
let workspaces = listPayload["workspaces"] as? [[String: Any]] ?? []
|
||||
|
||||
var matches: [[String: Any]] = []
|
||||
for ws in workspaces {
|
||||
let title = (ws["title"] as? String) ?? ""
|
||||
let titleMatch = query.isEmpty || title.localizedCaseInsensitiveContains(query)
|
||||
var contentMatch = false
|
||||
if includeContent && !query.isEmpty, let wsId = ws["id"] as? String {
|
||||
let textPayload = try? client.sendV2(method: "surface.read_text", params: ["workspace_id": wsId])
|
||||
let text = (textPayload?["text"] as? String) ?? ""
|
||||
contentMatch = text.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
if titleMatch || contentMatch {
|
||||
matches.append(ws)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSelect, let first = matches.first, let wsId = first["id"] as? String {
|
||||
_ = try client.sendV2(method: "workspace.select", params: ["workspace_id": wsId])
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
let formatted = formatIDs(["matches": matches], mode: idFormat) as? [String: Any]
|
||||
print(jsonString(["matches": formatted?["matches"] ?? []]))
|
||||
} else if matches.isEmpty {
|
||||
print("No matches")
|
||||
} else {
|
||||
for item in matches {
|
||||
let handle = textHandle(item, idFormat: idFormat)
|
||||
let title = (item["title"] as? String) ?? ""
|
||||
print("\(handle) \"\(title)\"")
|
||||
}
|
||||
}
|
||||
|
||||
case "clear-history":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface")
|
||||
var params: [String: Any] = [:]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
let payload = try client.sendV2(method: "surface.clear_history", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
|
||||
|
||||
case "set-hook":
|
||||
var store = loadTmuxCompatStore()
|
||||
if commandArgs.contains("--list") {
|
||||
if jsonOutput {
|
||||
print(jsonString(["hooks": store.hooks]))
|
||||
} else if store.hooks.isEmpty {
|
||||
print("No hooks configured")
|
||||
} else {
|
||||
for (event, hookCmd) in store.hooks.sorted(by: { $0.key < $1.key }) {
|
||||
print("\(event) -> \(hookCmd)")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if commandArgs.contains("--unset") {
|
||||
guard let event = commandArgs.last else {
|
||||
throw CLIError(message: "set-hook --unset requires an event name")
|
||||
}
|
||||
store.hooks.removeValue(forKey: event)
|
||||
try saveTmuxCompatStore(store)
|
||||
print("OK")
|
||||
return
|
||||
}
|
||||
guard let event = commandArgs.first(where: { !$0.hasPrefix("-") }) else {
|
||||
throw CLIError(message: "set-hook requires <event> <command>")
|
||||
}
|
||||
let commandText = commandArgs.drop(while: { $0 != event }).dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !commandText.isEmpty else {
|
||||
throw CLIError(message: "set-hook requires <event> <command>")
|
||||
}
|
||||
store.hooks[event] = commandText
|
||||
try saveTmuxCompatStore(store)
|
||||
print("OK")
|
||||
|
||||
case "popup":
|
||||
throw CLIError(message: "popup is not supported yet in cmux CLI parity mode")
|
||||
|
||||
case "bind-key", "unbind-key", "copy-mode":
|
||||
throw CLIError(message: "\(command) is not supported yet in cmux CLI parity mode")
|
||||
|
||||
case "set-buffer":
|
||||
let (nameArg, rem0) = parseOption(commandArgs, name: "--name")
|
||||
let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default"
|
||||
let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else {
|
||||
throw CLIError(message: "set-buffer requires text")
|
||||
}
|
||||
var store = loadTmuxCompatStore()
|
||||
store.buffers[name] = content
|
||||
try saveTmuxCompatStore(store)
|
||||
print("OK")
|
||||
|
||||
case "list-buffers":
|
||||
let store = loadTmuxCompatStore()
|
||||
if jsonOutput {
|
||||
let payload = store.buffers.map { key, value in ["name": key, "size": value.count] }
|
||||
print(jsonString(["buffers": payload.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") }]))
|
||||
} else if store.buffers.isEmpty {
|
||||
print("No buffers")
|
||||
} else {
|
||||
for key in store.buffers.keys.sorted() {
|
||||
let size = store.buffers[key]?.count ?? 0
|
||||
print("\(key)\t\(size)")
|
||||
}
|
||||
}
|
||||
|
||||
case "paste-buffer":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface")
|
||||
let name = optionValue(commandArgs, name: "--name") ?? "default"
|
||||
let store = loadTmuxCompatStore()
|
||||
guard let buffer = store.buffers[name] else {
|
||||
throw CLIError(message: "Buffer not found: \(name)")
|
||||
}
|
||||
var params: [String: Any] = ["text": buffer]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
let payload = try client.sendV2(method: "surface.send_text", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
|
||||
|
||||
case "respawn-pane":
|
||||
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface")
|
||||
let (commandOpt, rem0) = parseOption(commandArgs, name: "--command")
|
||||
let commandText = (commandOpt ?? rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText
|
||||
var params: [String: Any] = ["text": finalCommand + "\n"]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
let payload = try client.sendV2(method: "surface.send_text", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
|
||||
|
||||
case "display-message":
|
||||
let printOnly = commandArgs.contains("-p") || commandArgs.contains("--print")
|
||||
let message = commandArgs
|
||||
.filter { !$0.hasPrefix("-") }
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else {
|
||||
throw CLIError(message: "display-message requires text")
|
||||
}
|
||||
if printOnly {
|
||||
print(message)
|
||||
return
|
||||
}
|
||||
let payload = try client.sendV2(method: "notification.create", params: ["title": "cmux", "body": message])
|
||||
if jsonOutput {
|
||||
print(jsonString(payload))
|
||||
} else {
|
||||
print(message)
|
||||
}
|
||||
|
||||
default:
|
||||
throw CLIError(message: "Unsupported tmux compatibility command: \(command)")
|
||||
}
|
||||
}
|
||||
|
||||
private func runClaudeHook(commandArgs: [String], client: SocketClient) throws {
|
||||
let subcommand = commandArgs.first?.lowercased() ?? "help"
|
||||
let hookArgs = Array(commandArgs.dropFirst())
|
||||
|
|
@ -3462,7 +4040,10 @@ struct CMUXCLI {
|
|||
focus-panel --panel <id|ref> [--workspace <id|ref>]
|
||||
close-workspace --workspace <id|ref>
|
||||
select-workspace --workspace <id|ref>
|
||||
rename-workspace [--workspace <id|ref>] <title>
|
||||
rename-window [--workspace <id|ref>] <title>
|
||||
current-workspace
|
||||
read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
|
||||
send [--workspace <id|ref>] [--surface <id|ref>] <text>
|
||||
send-key [--workspace <id|ref>] [--surface <id|ref>] <key>
|
||||
send-panel --panel <id|ref> [--workspace <id|ref>] <text>
|
||||
|
|
@ -3474,6 +4055,27 @@ struct CMUXCLI {
|
|||
set-app-focus <active|inactive|clear>
|
||||
simulate-app-active
|
||||
|
||||
# tmux compatibility commands
|
||||
capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
|
||||
resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>]
|
||||
pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>]
|
||||
wait-for [-S|--signal] <name> [--timeout <seconds>]
|
||||
swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>]
|
||||
break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
|
||||
join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
|
||||
next-window | previous-window | last-window
|
||||
last-pane [--workspace <id|ref>]
|
||||
find-window [--content] [--select] <query>
|
||||
clear-history [--workspace <id|ref>] [--surface <id|ref>]
|
||||
set-hook [--list] [--unset <event>] | <event> <command>
|
||||
popup
|
||||
bind-key | unbind-key | copy-mode
|
||||
set-buffer [--name <name>] <text>
|
||||
list-buffers
|
||||
paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>]
|
||||
respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>]
|
||||
display-message [-p|--print] <text>
|
||||
|
||||
browser [--surface <id|ref|index> | <surface>] <subcommand> ...
|
||||
browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate)
|
||||
browser open-split [url]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
|
||||
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
|
||||
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
|
||||
A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; };
|
||||
A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; };
|
||||
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
|
||||
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
|
||||
|
|
@ -137,6 +138,7 @@
|
|||
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
|
||||
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
|
||||
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
|
||||
A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; };
|
||||
A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; };
|
||||
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
|
||||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||
|
|
@ -313,6 +315,7 @@
|
|||
A5001014 /* GhosttyConfig.swift */,
|
||||
A5001015 /* GhosttyTerminalView.swift */,
|
||||
A5001531 /* TerminalWindowPortal.swift */,
|
||||
A5001533 /* BrowserWindowPortal.swift */,
|
||||
A5001019 /* TerminalController.swift */,
|
||||
A5001541 /* PortScanner.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
|
|
@ -541,6 +544,7 @@
|
|||
A5001004 /* GhosttyConfig.swift in Sources */,
|
||||
A5001005 /* GhosttyTerminalView.swift in Sources */,
|
||||
A5001532 /* TerminalWindowPortal.swift in Sources */,
|
||||
A5001534 /* BrowserWindowPortal.swift in Sources */,
|
||||
A5001007 /* TerminalController.swift in Sources */,
|
||||
A5001540 /* PortScanner.swift in Sources */,
|
||||
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -136,6 +136,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
|
|||
|
||||
### Browser
|
||||
|
||||
Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`.
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Open browser in split |
|
||||
|
|
@ -143,7 +145,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
|
|||
| ⌘ [ | Back |
|
||||
| ⌘ ] | Forward |
|
||||
| ⌘ R | Reload page |
|
||||
| ⌥ ⌘ I | Open Developer Tools |
|
||||
| ⌥ ⌘ I | Toggle Developer Tools (Safari default) |
|
||||
| ⌥ ⌘ C | Show JavaScript Console (Safari default) |
|
||||
|
||||
### Notifications
|
||||
|
||||
|
|
@ -186,6 +189,14 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
|
|||
|
||||
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.com/invite/QRxkhZgY)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0 or later (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
|
|
@ -92,6 +92,11 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
|
|
|
|||
|
|
@ -96,6 +96,13 @@ func browserOmnibarSelectionDeltaForArrowNavigation(
|
|||
}
|
||||
}
|
||||
|
||||
func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
return normalizedFlags == [] || normalizedFlags == [.shift]
|
||||
}
|
||||
|
||||
enum BrowserZoomShortcutAction: Equatable {
|
||||
case zoomIn
|
||||
case zoomOut
|
||||
|
|
@ -187,6 +194,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private var workspaceObserver: NSObjectProtocol?
|
||||
private var windowKeyObserver: NSObjectProtocol?
|
||||
private var shortcutMonitor: Any?
|
||||
private var shortcutDefaultsObserver: NSObjectProtocol?
|
||||
private var ghosttyConfigObserver: NSObjectProtocol?
|
||||
private var ghosttyGotoSplitLeftShortcut: StoredShortcut?
|
||||
private var ghosttyGotoSplitRightShortcut: StoredShortcut?
|
||||
|
|
@ -336,6 +344,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
installWindowKeyEquivalentSwizzle()
|
||||
installBrowserAddressBarFocusObservers()
|
||||
installShortcutMonitor()
|
||||
installShortcutDefaultsObserver()
|
||||
NSApp.servicesProvider = self
|
||||
#if DEBUG
|
||||
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
|
||||
|
|
@ -1442,6 +1451,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
|
||||
if let probeKind = self.developerToolsShortcutProbeKind(event: event) {
|
||||
self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event)
|
||||
}
|
||||
#endif
|
||||
if self.handleCustomShortcut(event: event) {
|
||||
#if DEBUG
|
||||
|
|
@ -1460,6 +1472,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
private func installShortcutDefaultsObserver() {
|
||||
guard shortcutDefaultsObserver == nil else { return }
|
||||
shortcutDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.refreshSplitButtonTooltipsAcrossWorkspaces()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSplitButtonTooltipsAcrossWorkspaces() {
|
||||
var refreshedManagers: Set<ObjectIdentifier> = []
|
||||
if let manager = tabManager {
|
||||
manager.refreshSplitButtonTooltips()
|
||||
refreshedManagers.insert(ObjectIdentifier(manager))
|
||||
}
|
||||
for context in mainWindowContexts.values {
|
||||
let manager = context.tabManager
|
||||
let identifier = ObjectIdentifier(manager)
|
||||
guard refreshedManagers.insert(identifier).inserted else { continue }
|
||||
manager.refreshSplitButtonTooltips()
|
||||
}
|
||||
}
|
||||
|
||||
private func installGhosttyConfigObserver() {
|
||||
guard ghosttyConfigObserver == nil else { return }
|
||||
ghosttyConfigObserver = NotificationCenter.default.addObserver(
|
||||
|
|
@ -1861,6 +1898,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) {
|
||||
_ = performBrowserSplitShortcut(direction: .right)
|
||||
return true
|
||||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) {
|
||||
_ = performBrowserSplitShortcut(direction: .down)
|
||||
return true
|
||||
}
|
||||
|
||||
// Surface navigation (legacy Ctrl+Tab support)
|
||||
if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) {
|
||||
tabManager?.selectNextSurface()
|
||||
|
|
@ -1885,6 +1932,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
// Safari defaults:
|
||||
// - Option+Command+I => Show/Toggle Web Inspector
|
||||
// - Option+Command+C => Show JavaScript Console
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
|
||||
#if DEBUG
|
||||
logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event)
|
||||
#endif
|
||||
let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false
|
||||
#if DEBUG
|
||||
logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle)
|
||||
}
|
||||
#endif
|
||||
if !didHandle { NSSound.beep() }
|
||||
return true
|
||||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
|
||||
#if DEBUG
|
||||
logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event)
|
||||
#endif
|
||||
let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false
|
||||
#if DEBUG
|
||||
logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle)
|
||||
}
|
||||
#endif
|
||||
if !didHandle { NSSound.beep() }
|
||||
return true
|
||||
}
|
||||
|
||||
// Focus browser address bar: Cmd+L
|
||||
if flags == [.command] && chars == "l" {
|
||||
if let focusedPanel = tabManager?.focusedBrowserPanel {
|
||||
|
|
@ -2032,15 +2112,174 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
|
||||
guard let responder else { return false }
|
||||
let responderType = String(describing: type(of: responder))
|
||||
if responderType.contains("WKInspector") {
|
||||
return true
|
||||
}
|
||||
guard let view = responder as? NSView else { return false }
|
||||
var node: NSView? = view
|
||||
var hops = 0
|
||||
while let current = node, hops < 64 {
|
||||
if String(describing: type(of: current)).contains("WKInspector") {
|
||||
return true
|
||||
}
|
||||
node = current.superview
|
||||
hops += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func developerToolsShortcutProbeKind(event: NSEvent) -> String? {
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
|
||||
return "toggle.configured"
|
||||
}
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
|
||||
return "console.configured"
|
||||
}
|
||||
|
||||
let chars = (event.charactersIgnoringModifiers ?? "").lowercased()
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
if flags == [.command, .option] {
|
||||
if chars == "i" || event.keyCode == 34 {
|
||||
return "toggle.literal"
|
||||
}
|
||||
if chars == "c" || event.keyCode == 8 {
|
||||
return "console.literal"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func logDeveloperToolsShortcutSnapshot(
|
||||
phase: String,
|
||||
event: NSEvent? = nil,
|
||||
didHandle: Bool? = nil
|
||||
) {
|
||||
let keyWindow = NSApp.keyWindow
|
||||
let firstResponder = keyWindow?.firstResponder
|
||||
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
||||
let eventDescription = event.map(NSWindow.keyDescription) ?? "none"
|
||||
if let browser = tabManager?.focusedBrowserPanel {
|
||||
var line =
|
||||
"browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " +
|
||||
"\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " +
|
||||
"keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
|
||||
if let didHandle {
|
||||
line += " handled=\(didHandle ? 1 : 0)"
|
||||
}
|
||||
dlog(line)
|
||||
return
|
||||
}
|
||||
var line =
|
||||
"browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " +
|
||||
"fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
|
||||
if let didHandle {
|
||||
line += " handled=\(didHandle ? 1 : 0)"
|
||||
}
|
||||
dlog(line)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) {
|
||||
guard let browser = tabManager?.focusedBrowserPanel else { return }
|
||||
guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return }
|
||||
guard let keyWindow = NSApp.keyWindow else { return }
|
||||
guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return }
|
||||
|
||||
let beforeResponder = keyWindow.firstResponder
|
||||
let movedToWebView = keyWindow.makeFirstResponder(browser.webView)
|
||||
let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil)
|
||||
|
||||
#if DEBUG
|
||||
let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
||||
let afterResponder = keyWindow.firstResponder
|
||||
let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
||||
dlog(
|
||||
"split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " +
|
||||
"before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " +
|
||||
"moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func performSplitShortcut(direction: SplitDirection) -> Bool {
|
||||
let directionLabel: String
|
||||
switch direction {
|
||||
case .left: directionLabel = "left"
|
||||
case .right: directionLabel = "right"
|
||||
case .up: directionLabel = "up"
|
||||
case .down: directionLabel = "down"
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let keyWindow = NSApp.keyWindow
|
||||
let firstResponder = keyWindow?.firstResponder
|
||||
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
||||
let firstResponderWindow: Int = {
|
||||
if let v = firstResponder as? NSView {
|
||||
return v.window?.windowNumber ?? -1
|
||||
}
|
||||
if let w = firstResponder as? NSWindow {
|
||||
return w.windowNumber
|
||||
}
|
||||
return -1
|
||||
}()
|
||||
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
|
||||
if let browser = tabManager?.focusedBrowserPanel {
|
||||
let webWindow = browser.webView.window?.windowNumber ?? -1
|
||||
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
||||
dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
|
||||
} else {
|
||||
dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)")
|
||||
}
|
||||
#endif
|
||||
|
||||
prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel)
|
||||
tabManager?.createSplit(direction: direction)
|
||||
#if DEBUG
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
||||
let keyWindow = NSApp.keyWindow
|
||||
let firstResponder = keyWindow?.firstResponder
|
||||
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
||||
let firstResponderWindow: Int = {
|
||||
if let v = firstResponder as? NSView {
|
||||
return v.window?.windowNumber ?? -1
|
||||
}
|
||||
if let w = firstResponder as? NSWindow {
|
||||
return w.windowNumber
|
||||
}
|
||||
return -1
|
||||
}()
|
||||
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
|
||||
if let browser = self?.tabManager?.focusedBrowserPanel {
|
||||
let webWindow = browser.webView.window?.windowNumber ?? -1
|
||||
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
|
||||
dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
|
||||
} else {
|
||||
dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)")
|
||||
}
|
||||
}
|
||||
recordGotoSplitSplitIfNeeded(direction: direction)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func performBrowserSplitShortcut(direction: SplitDirection) -> Bool {
|
||||
guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false }
|
||||
_ = focusBrowserAddressBar(panelId: panelId)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts
|
||||
/// through the same app-level shortcut handler used by the local key monitor.
|
||||
@discardableResult
|
||||
|
|
|
|||
867
Sources/BrowserWindowPortal.swift
Normal file
867
Sources/BrowserWindowPortal.swift
Normal file
|
|
@ -0,0 +1,867 @@
|
|||
import AppKit
|
||||
import ObjectiveC
|
||||
import WebKit
|
||||
#if DEBUG
|
||||
import Bonsplit
|
||||
#endif
|
||||
|
||||
private var cmuxWindowBrowserPortalKey: UInt8 = 0
|
||||
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
|
||||
|
||||
#if DEBUG
|
||||
private func browserPortalDebugToken(_ view: NSView?) -> String {
|
||||
guard let view else { return "nil" }
|
||||
let ptr = Unmanaged.passUnretained(view).toOpaque()
|
||||
return String(describing: ptr)
|
||||
}
|
||||
|
||||
private func browserPortalDebugFrame(_ rect: NSRect) -> String {
|
||||
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
|
||||
}
|
||||
#endif
|
||||
|
||||
final class WindowBrowserHostView: NSView {
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
if shouldPassThroughToSplitDivider(at: point) {
|
||||
return nil
|
||||
}
|
||||
let hitView = super.hitTest(point)
|
||||
return hitView === self ? nil : hitView
|
||||
}
|
||||
|
||||
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
|
||||
guard let window else { return false }
|
||||
let windowPoint = convert(point, to: nil)
|
||||
guard let rootView = window.contentView else { return false }
|
||||
return Self.containsSplitDivider(at: windowPoint, in: rootView)
|
||||
}
|
||||
|
||||
private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool {
|
||||
guard !view.isHidden else { return false }
|
||||
|
||||
if let splitView = view as? NSSplitView {
|
||||
let pointInSplit = splitView.convert(windowPoint, from: nil)
|
||||
if splitView.bounds.contains(pointInSplit) {
|
||||
let expansion: CGFloat = 5
|
||||
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
|
||||
for dividerIndex in 0..<dividerCount {
|
||||
let first = splitView.arrangedSubviews[dividerIndex].frame
|
||||
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
|
||||
let thickness = splitView.dividerThickness
|
||||
let dividerRect: NSRect
|
||||
if splitView.isVertical {
|
||||
guard first.width > 1, second.width > 1 else { continue }
|
||||
let x = max(0, first.maxX)
|
||||
dividerRect = NSRect(
|
||||
x: x,
|
||||
y: 0,
|
||||
width: thickness,
|
||||
height: splitView.bounds.height
|
||||
)
|
||||
} else {
|
||||
guard first.height > 1, second.height > 1 else { continue }
|
||||
let y = max(0, first.maxY)
|
||||
dividerRect = NSRect(
|
||||
x: 0,
|
||||
y: y,
|
||||
width: splitView.bounds.width,
|
||||
height: thickness
|
||||
)
|
||||
}
|
||||
let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion)
|
||||
if expanded.contains(pointInSplit) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for subview in view.subviews.reversed() {
|
||||
if containsSplitDivider(at: windowPoint, in: subview) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
final class WindowBrowserSlotView: NSView {
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
layer?.masksToBounds = true
|
||||
translatesAutoresizingMaskIntoConstraints = true
|
||||
autoresizingMask = []
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowBrowserPortal: NSObject {
|
||||
private weak var window: NSWindow?
|
||||
private let hostView = WindowBrowserHostView(frame: .zero)
|
||||
private weak var installedContainerView: NSView?
|
||||
private weak var installedReferenceView: NSView?
|
||||
private var hasDeferredFullSyncScheduled = false
|
||||
|
||||
private struct Entry {
|
||||
weak var webView: WKWebView?
|
||||
weak var containerView: WindowBrowserSlotView?
|
||||
weak var anchorView: NSView?
|
||||
var visibleInUI: Bool
|
||||
var zPriority: Int
|
||||
}
|
||||
|
||||
private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:]
|
||||
private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
||||
|
||||
init(window: NSWindow) {
|
||||
self.window = window
|
||||
super.init()
|
||||
hostView.wantsLayer = true
|
||||
hostView.layer?.masksToBounds = true
|
||||
hostView.translatesAutoresizingMaskIntoConstraints = true
|
||||
hostView.autoresizingMask = []
|
||||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func ensureInstalled() -> Bool {
|
||||
guard let window else { return false }
|
||||
guard let (container, reference) = installationTarget(for: window) else { return false }
|
||||
|
||||
if hostView.superview !== container ||
|
||||
installedContainerView !== container ||
|
||||
installedReferenceView !== reference {
|
||||
hostView.removeFromSuperview()
|
||||
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
||||
installedContainerView = container
|
||||
installedReferenceView = reference
|
||||
} else if !Self.isView(hostView, above: reference, in: container) {
|
||||
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
||||
}
|
||||
|
||||
synchronizeHostFrameToReference()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func synchronizeHostFrameToReference() -> Bool {
|
||||
guard let container = installedContainerView,
|
||||
let reference = installedReferenceView else {
|
||||
return false
|
||||
}
|
||||
let frameInContainer = container.convert(reference.bounds, from: reference)
|
||||
let hasFiniteFrame =
|
||||
frameInContainer.origin.x.isFinite &&
|
||||
frameInContainer.origin.y.isFinite &&
|
||||
frameInContainer.size.width.isFinite &&
|
||||
frameInContainer.size.height.isFinite
|
||||
guard hasFiniteFrame else { return false }
|
||||
|
||||
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostView.frame = frameInContainer
|
||||
CATransaction.commit()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " +
|
||||
"frame=\(browserPortalDebugFrame(frameInContainer))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
return frameInContainer.width > 1 && frameInContainer.height > 1
|
||||
}
|
||||
|
||||
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
|
||||
guard let contentView = window.contentView else { return nil }
|
||||
|
||||
if contentView.className == "NSGlassEffectView",
|
||||
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
|
||||
return (contentView, foreground)
|
||||
}
|
||||
|
||||
guard let themeFrame = contentView.superview else { return nil }
|
||||
return (themeFrame, contentView)
|
||||
}
|
||||
|
||||
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
|
||||
if view.isHidden { return true }
|
||||
var current = view.superview
|
||||
while let v = current {
|
||||
if v.isHidden { return true }
|
||||
current = v.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
||||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
frame.minX < bounds.minX - epsilon ||
|
||||
frame.minY < bounds.minY - epsilon ||
|
||||
frame.maxX > bounds.maxX + epsilon ||
|
||||
frame.maxY > bounds.maxY + epsilon
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private static func inspectorSubviewCount(in root: NSView) -> Int {
|
||||
var stack: [NSView] = [root]
|
||||
var count = 0
|
||||
while let current = stack.popLast() {
|
||||
for subview in current.subviews {
|
||||
if String(describing: type(of: subview)).contains("WKInspector") {
|
||||
count += 1
|
||||
}
|
||||
stack.append(subview)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
|
||||
guard let viewIndex = container.subviews.firstIndex(of: view),
|
||||
let referenceIndex = container.subviews.firstIndex(of: reference) else {
|
||||
return false
|
||||
}
|
||||
return viewIndex > referenceIndex
|
||||
}
|
||||
|
||||
private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView {
|
||||
if let existing = entry.containerView {
|
||||
return existing
|
||||
}
|
||||
let created = WindowBrowserSlotView(frame: .zero)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.container.create web=\(browserPortalDebugToken(webView)) " +
|
||||
"container=\(browserPortalDebugToken(created))"
|
||||
)
|
||||
#endif
|
||||
return created
|
||||
}
|
||||
|
||||
private func moveWebKitRelatedSubviewsIfNeeded(
|
||||
from sourceSuperview: NSView,
|
||||
to containerView: WindowBrowserSlotView,
|
||||
primaryWebView: WKWebView,
|
||||
reason: String
|
||||
) {
|
||||
guard sourceSuperview !== containerView else { return }
|
||||
// When Web Inspector is docked, WebKit can inject companion WK* subviews
|
||||
// next to the primary WKWebView. Move those with the web view so inspector
|
||||
// UI state does not get orphaned in the old host during split churn.
|
||||
let relatedSubviews = sourceSuperview.subviews.filter { view in
|
||||
if view === primaryWebView { return true }
|
||||
return String(describing: type(of: view)).contains("WK")
|
||||
}
|
||||
guard !relatedSubviews.isEmpty else { return }
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " +
|
||||
"sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " +
|
||||
"sourceFlipped=\(sourceSuperview.isFlipped ? 1 : 0) targetFlipped=\(containerView.isFlipped ? 1 : 0) " +
|
||||
"sourceBounds=\(browserPortalDebugFrame(sourceSuperview.bounds)) targetBounds=\(browserPortalDebugFrame(containerView.bounds))"
|
||||
)
|
||||
#endif
|
||||
for view in relatedSubviews {
|
||||
let frameInWindow = sourceSuperview.convert(view.frame, to: nil)
|
||||
let className = String(describing: type(of: view))
|
||||
view.removeFromSuperview()
|
||||
containerView.addSubview(view, positioned: .above, relativeTo: nil)
|
||||
let convertedFrame = containerView.convert(frameInWindow, from: nil)
|
||||
view.frame = convertedFrame
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent.batch.item reason=\(reason) class=\(className) " +
|
||||
"view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " +
|
||||
"converted=\(browserPortalDebugFrame(convertedFrame))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func detachWebView(withId webViewId: ObjectIdentifier) {
|
||||
guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return }
|
||||
if let anchor = entry.anchorView {
|
||||
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
|
||||
}
|
||||
#if DEBUG
|
||||
let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0
|
||||
let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1
|
||||
dlog(
|
||||
"browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " +
|
||||
"container=\(browserPortalDebugToken(entry.containerView)) " +
|
||||
"anchor=\(browserPortalDebugToken(entry.anchorView)) " +
|
||||
"hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)"
|
||||
)
|
||||
#endif
|
||||
entry.webView?.removeFromSuperview()
|
||||
entry.containerView?.removeFromSuperview()
|
||||
}
|
||||
|
||||
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard ensureInstalled() else { return }
|
||||
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
let anchorId = ObjectIdentifier(anchorView)
|
||||
let previousEntry = entriesByWebViewId[webViewId]
|
||||
let containerView = ensureContainerView(
|
||||
for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0),
|
||||
webView: webView
|
||||
)
|
||||
|
||||
if let previousWebViewId = webViewByAnchorId[anchorId], previousWebViewId != webViewId {
|
||||
#if DEBUG
|
||||
let previousToken = entriesByWebViewId[previousWebViewId]
|
||||
.map { browserPortalDebugToken($0.webView) }
|
||||
?? String(describing: previousWebViewId)
|
||||
dlog(
|
||||
"browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " +
|
||||
"oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))"
|
||||
)
|
||||
#endif
|
||||
detachWebView(withId: previousWebViewId)
|
||||
}
|
||||
|
||||
if let oldEntry = entriesByWebViewId[webViewId],
|
||||
let oldAnchor = oldEntry.anchorView,
|
||||
oldAnchor !== anchorView {
|
||||
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
|
||||
}
|
||||
|
||||
webViewByAnchorId[anchorId] = webViewId
|
||||
entriesByWebViewId[webViewId] = Entry(
|
||||
webView: webView,
|
||||
containerView: containerView,
|
||||
anchorView: anchorView,
|
||||
visibleInUI: visibleInUI,
|
||||
zPriority: zPriority
|
||||
)
|
||||
|
||||
let didChangeAnchor: Bool = {
|
||||
guard let previousAnchor = previousEntry?.anchorView else { return true }
|
||||
return previousAnchor !== anchorView
|
||||
}()
|
||||
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
|
||||
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
|
||||
#if DEBUG
|
||||
if previousEntry == nil ||
|
||||
didChangeAnchor ||
|
||||
becameVisible ||
|
||||
priorityIncreased ||
|
||||
webView.superview !== containerView ||
|
||||
containerView.superview !== hostView {
|
||||
dlog(
|
||||
"browser.portal.bind web=\(browserPortalDebugToken(webView)) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) " +
|
||||
"anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " +
|
||||
"visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " +
|
||||
"z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
if webView.superview !== containerView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " +
|
||||
"container=\(browserPortalDebugToken(containerView))"
|
||||
)
|
||||
#endif
|
||||
if let sourceSuperview = webView.superview {
|
||||
moveWebKitRelatedSubviewsIfNeeded(
|
||||
from: sourceSuperview,
|
||||
to: containerView,
|
||||
primaryWebView: webView,
|
||||
reason: "bind.attachContainer"
|
||||
)
|
||||
} else {
|
||||
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = containerView.bounds
|
||||
webView.needsLayout = true
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
if containerView.superview !== hostView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
|
||||
"reason=attach super=\(browserPortalDebugToken(containerView.superview))"
|
||||
)
|
||||
#endif
|
||||
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
|
||||
} else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " +
|
||||
"didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " +
|
||||
"priorityIncreased=\(priorityIncreased ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
|
||||
synchronizeWebView(withId: webViewId, source: "bind")
|
||||
pruneDeadEntries()
|
||||
}
|
||||
|
||||
func synchronizeWebViewForAnchor(_ anchorView: NSView) {
|
||||
pruneDeadEntries()
|
||||
let anchorId = ObjectIdentifier(anchorView)
|
||||
let primaryWebViewId = webViewByAnchorId[anchorId]
|
||||
if let primaryWebViewId {
|
||||
synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary")
|
||||
}
|
||||
|
||||
synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary")
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
}
|
||||
|
||||
private func scheduleDeferredFullSynchronizeAll() {
|
||||
guard !hasDeferredFullSyncScheduled else { return }
|
||||
hasDeferredFullSyncScheduled = true
|
||||
#if DEBUG
|
||||
dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)")
|
||||
#endif
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.hasDeferredFullSyncScheduled = false
|
||||
#if DEBUG
|
||||
dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)")
|
||||
#endif
|
||||
self.synchronizeAllWebViews(excluding: nil, source: "deferredTick")
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeAllWebViews(excluding webViewIdToSkip: ObjectIdentifier?, source: String) {
|
||||
guard ensureInstalled() else { return }
|
||||
pruneDeadEntries()
|
||||
let webViewIds = Array(entriesByWebViewId.keys)
|
||||
for webViewId in webViewIds {
|
||||
if webViewId == webViewIdToSkip { continue }
|
||||
synchronizeWebView(withId: webViewId, source: source)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) {
|
||||
guard ensureInstalled() else { return }
|
||||
guard let entry = entriesByWebViewId[webViewId] else { return }
|
||||
guard let webView = entry.webView else {
|
||||
entriesByWebViewId.removeValue(forKey: webViewId)
|
||||
return
|
||||
}
|
||||
guard let containerView = entry.containerView else {
|
||||
entriesByWebViewId.removeValue(forKey: webViewId)
|
||||
if let anchor = entry.anchorView {
|
||||
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let anchorView = entry.anchorView, let window else {
|
||||
#if DEBUG
|
||||
if !containerView.isHidden {
|
||||
dlog(
|
||||
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
containerView.isHidden = true
|
||||
return
|
||||
}
|
||||
guard anchorView.window === window else {
|
||||
#if DEBUG
|
||||
if !containerView.isHidden {
|
||||
dlog(
|
||||
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) value=1 " +
|
||||
"reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
containerView.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
if containerView.superview !== hostView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
|
||||
"reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))"
|
||||
)
|
||||
#endif
|
||||
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
if webView.superview !== containerView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " +
|
||||
"container=\(browserPortalDebugToken(containerView))"
|
||||
)
|
||||
#endif
|
||||
if let sourceSuperview = webView.superview {
|
||||
moveWebKitRelatedSubviewsIfNeeded(
|
||||
from: sourceSuperview,
|
||||
to: containerView,
|
||||
primaryWebView: webView,
|
||||
reason: "sync.attachContainer"
|
||||
)
|
||||
} else {
|
||||
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = containerView.bounds
|
||||
webView.needsLayout = true
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
_ = synchronizeHostFrameToReference()
|
||||
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
let frameInHost = hostView.convert(frameInWindow, from: nil)
|
||||
let hostBounds = hostView.bounds
|
||||
let hasFiniteHostBounds =
|
||||
hostBounds.origin.x.isFinite &&
|
||||
hostBounds.origin.y.isFinite &&
|
||||
hostBounds.size.width.isFinite &&
|
||||
hostBounds.size.height.isFinite
|
||||
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
|
||||
if !hostBoundsReady {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " +
|
||||
"anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
containerView.isHidden = true
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
return
|
||||
}
|
||||
let oldFrame = containerView.frame
|
||||
let hasFiniteFrame =
|
||||
frameInHost.origin.x.isFinite &&
|
||||
frameInHost.origin.y.isFinite &&
|
||||
frameInHost.size.width.isFinite &&
|
||||
frameInHost.size.height.isFinite
|
||||
let clampedFrame = frameInHost.intersection(hostBounds)
|
||||
let hasVisibleIntersection =
|
||||
!clampedFrame.isNull &&
|
||||
clampedFrame.width > 1 &&
|
||||
clampedFrame.height > 1
|
||||
let targetFrame = hasVisibleIntersection ? clampedFrame : frameInHost
|
||||
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
|
||||
let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1
|
||||
let outsideHostBounds = !hasVisibleIntersection
|
||||
let shouldHide =
|
||||
!entry.visibleInUI ||
|
||||
anchorHidden ||
|
||||
tinyFrame ||
|
||||
!hasFiniteFrame ||
|
||||
outsideHostBounds
|
||||
#if DEBUG
|
||||
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
|
||||
if frameWasClamped {
|
||||
dlog(
|
||||
"browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
|
||||
"raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " +
|
||||
"host=\(browserPortalDebugFrame(hostBounds))"
|
||||
)
|
||||
}
|
||||
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
|
||||
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
|
||||
if collapsedToTiny {
|
||||
dlog(
|
||||
"browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
|
||||
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
|
||||
)
|
||||
} else if restoredFromTiny {
|
||||
dlog(
|
||||
"browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
|
||||
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
containerView.frame = targetFrame
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
|
||||
if !Self.rectApproximatelyEqual(containerView.bounds, expectedContainerBounds) {
|
||||
let oldContainerBounds = containerView.bounds
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
containerView.bounds = expectedContainerBounds
|
||||
CATransaction.commit()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " +
|
||||
"target=\(browserPortalDebugFrame(expectedContainerBounds))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
let containerBounds = containerView.bounds
|
||||
let preNormalizeWebFrame = webView.frame
|
||||
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
|
||||
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
|
||||
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
|
||||
#if DEBUG
|
||||
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
|
||||
#endif
|
||||
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
|
||||
let oldWebFrame = preNormalizeWebFrame
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
webView.frame = containerBounds
|
||||
CATransaction.commit()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " +
|
||||
"new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " +
|
||||
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
|
||||
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
|
||||
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
|
||||
"inspectorSubviews=\(inspectorSubviews) " +
|
||||
"source=\(source)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
if containerView.isHidden != shouldHide {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
|
||||
"web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " +
|
||||
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
|
||||
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " +
|
||||
"host=\(browserPortalDebugFrame(hostBounds))"
|
||||
)
|
||||
#endif
|
||||
containerView.isHidden = shouldHide
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) " +
|
||||
"anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " +
|
||||
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
|
||||
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
|
||||
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
|
||||
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
|
||||
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
|
||||
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
|
||||
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
|
||||
"webFrame=\(browserPortalDebugFrame(webView.frame)) webBounds=\(browserPortalDebugFrame(webView.bounds)) " +
|
||||
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
|
||||
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
|
||||
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
|
||||
"inspectorSubviews=\(inspectorSubviews)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func pruneDeadEntries() {
|
||||
let currentWindow = window
|
||||
let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in
|
||||
guard entry.webView != nil else { return webViewId }
|
||||
guard let container = entry.containerView else { return webViewId }
|
||||
guard let anchor = entry.anchorView else { return webViewId }
|
||||
if container.superview == nil || !container.isDescendant(of: hostView) {
|
||||
return webViewId
|
||||
}
|
||||
if anchor.window !== currentWindow || anchor.superview == nil {
|
||||
return webViewId
|
||||
}
|
||||
if let reference = installedReferenceView,
|
||||
!anchor.isDescendant(of: reference) {
|
||||
return webViewId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for webViewId in deadWebViewIds {
|
||||
detachWebView(withId: webViewId)
|
||||
}
|
||||
|
||||
let validAnchorIds = Set(entriesByWebViewId.compactMap { _, entry in
|
||||
entry.anchorView.map { ObjectIdentifier($0) }
|
||||
})
|
||||
webViewByAnchorId = webViewByAnchorId.filter { validAnchorIds.contains($0.key) }
|
||||
}
|
||||
|
||||
func webViewIds() -> Set<ObjectIdentifier> {
|
||||
Set(entriesByWebViewId.keys)
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
for webViewId in Array(entriesByWebViewId.keys) {
|
||||
detachWebView(withId: webViewId)
|
||||
}
|
||||
hostView.removeFromSuperview()
|
||||
installedContainerView = nil
|
||||
installedReferenceView = nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func debugEntryCount() -> Int {
|
||||
entriesByWebViewId.count
|
||||
}
|
||||
|
||||
func debugHostedSubviewCount() -> Int {
|
||||
hostView.subviews.count
|
||||
}
|
||||
#endif
|
||||
|
||||
func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? {
|
||||
guard ensureInstalled() else { return nil }
|
||||
let point = hostView.convert(windowPoint, from: nil)
|
||||
for subview in hostView.subviews.reversed() {
|
||||
guard let container = subview as? WindowBrowserSlotView else { continue }
|
||||
guard !container.isHidden else { continue }
|
||||
guard container.frame.contains(point) else { continue }
|
||||
guard let webView = entriesByWebViewId
|
||||
.first(where: { _, entry in entry.containerView === container })?
|
||||
.value
|
||||
.webView else { continue }
|
||||
return webView
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum BrowserWindowPortalRegistry {
|
||||
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
|
||||
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
||||
|
||||
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
|
||||
guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return }
|
||||
let windowId = ObjectIdentifier(window)
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak window] _ in
|
||||
MainActor.assumeIsolated {
|
||||
if let window {
|
||||
removePortal(for: window)
|
||||
} else {
|
||||
removePortal(windowId: windowId, window: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&cmuxWindowBrowserPortalCloseObserverKey,
|
||||
observer,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
|
||||
private static func removePortal(for window: NSWindow) {
|
||||
removePortal(windowId: ObjectIdentifier(window), window: window)
|
||||
}
|
||||
|
||||
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
|
||||
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
|
||||
portal.tearDown()
|
||||
}
|
||||
webViewToWindowId = webViewToWindowId.filter { $0.value != windowId }
|
||||
|
||||
guard let window else { return }
|
||||
if let observer = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
|
||||
}
|
||||
|
||||
private static func pruneWebViewMappings(for windowId: ObjectIdentifier, validWebViewIds: Set<ObjectIdentifier>) {
|
||||
webViewToWindowId = webViewToWindowId.filter { webViewId, mappedWindowId in
|
||||
mappedWindowId != windowId || validWebViewIds.contains(webViewId)
|
||||
}
|
||||
}
|
||||
|
||||
private static func portal(for window: NSWindow) -> WindowBrowserPortal {
|
||||
if let existing = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalKey) as? WindowBrowserPortal {
|
||||
portalsByWindowId[ObjectIdentifier(window)] = existing
|
||||
installWindowCloseObserverIfNeeded(for: window)
|
||||
return existing
|
||||
}
|
||||
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
|
||||
portalsByWindowId[ObjectIdentifier(window)] = portal
|
||||
installWindowCloseObserverIfNeeded(for: window)
|
||||
return portal
|
||||
}
|
||||
|
||||
static func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard let window = anchorView.window else { return }
|
||||
|
||||
let windowId = ObjectIdentifier(window)
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
let nextPortal = portal(for: window)
|
||||
|
||||
if let oldWindowId = webViewToWindowId[webViewId],
|
||||
oldWindowId != windowId {
|
||||
portalsByWindowId[oldWindowId]?.detachWebView(withId: webViewId)
|
||||
}
|
||||
|
||||
nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
|
||||
webViewToWindowId[webViewId] = windowId
|
||||
pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds())
|
||||
}
|
||||
|
||||
static func synchronizeForAnchor(_ anchorView: NSView) {
|
||||
guard let window = anchorView.window else { return }
|
||||
let portal = portal(for: window)
|
||||
portal.synchronizeWebViewForAnchor(anchorView)
|
||||
}
|
||||
|
||||
static func detach(webView: WKWebView) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
|
||||
portalsByWindowId[windowId]?.detachWebView(withId: webViewId)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func debugPortalCount() -> Int {
|
||||
portalsByWindowId.count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import Bonsplit
|
|||
import SwiftUI
|
||||
import ObjectiveC
|
||||
import UniformTypeIdentifiers
|
||||
import WebKit
|
||||
|
||||
struct ShortcutHintPillBackground: View {
|
||||
var emphasis: Double = 1.0
|
||||
|
|
@ -224,6 +225,9 @@ final class FileDropOverlayView: NSView {
|
|||
/// Fallback handler when no terminal is found under the drop point.
|
||||
var onDrop: (([URL]) -> Bool)?
|
||||
private var isForwardingMouseEvent = false
|
||||
/// The WKWebView currently receiving forwarded drag events, so we can
|
||||
/// synthesize draggingExited/draggingEntered as the cursor moves.
|
||||
private weak var activeDragWebView: WKWebView?
|
||||
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
|
||||
|
|
@ -293,14 +297,26 @@ final class FileDropOverlayView: NSView {
|
|||
override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||||
override func scrollWheel(with event: NSEvent) { forwardEvent(event) }
|
||||
|
||||
// MARK: NSDraggingDestination – only accept file drops over terminal views.
|
||||
// MARK: NSDraggingDestination – accept file drops over terminal and browser views.
|
||||
//
|
||||
// AppKit sends draggingEntered once when the drag enters this overlay, then
|
||||
// draggingUpdated as the cursor moves within it. We track which WKWebView (if
|
||||
// any) is under the cursor and synthesize enter/exit calls so the browser's
|
||||
// HTML5 drag events (dragenter, dragleave, drop) fire correctly.
|
||||
|
||||
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
return dragOperationForSender(sender, phase: "entered")
|
||||
return updateDragTarget(sender, phase: "entered")
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
return dragOperationForSender(sender, phase: "updated")
|
||||
return updateDragTarget(sender, phase: "updated")
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
|
||||
if let prev = activeDragWebView {
|
||||
prev.draggingExited(sender)
|
||||
activeDragWebView = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||
|
|
@ -310,38 +326,58 @@ final class FileDropOverlayView: NSView {
|
|||
pasteboardTypes: types,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource
|
||||
)
|
||||
let webView = activeDragWebView
|
||||
activeDragWebView = nil
|
||||
#if DEBUG
|
||||
if shouldCapture || (types?.contains(.fileURL) ?? false) {
|
||||
dlog(
|
||||
"overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " +
|
||||
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
|
||||
"types=\(debugPasteboardTypes(types))"
|
||||
)
|
||||
}
|
||||
dlog(
|
||||
"overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " +
|
||||
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
|
||||
"hasWebView=\(webView != nil ? 1 : 0) " +
|
||||
"types=\(debugPasteboardTypes(types))"
|
||||
)
|
||||
#endif
|
||||
guard shouldCapture else { return false }
|
||||
if let webView {
|
||||
return webView.performDragOperation(sender)
|
||||
}
|
||||
guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false }
|
||||
return terminal.performDragOperation(sender)
|
||||
}
|
||||
|
||||
private func dragOperationForSender(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
|
||||
private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
|
||||
let loc = sender.draggingLocation
|
||||
let hasLocalDraggingSource = sender.draggingSource != nil
|
||||
let types = sender.draggingPasteboard.types
|
||||
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
|
||||
pasteboardTypes: types,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource
|
||||
)
|
||||
#if DEBUG
|
||||
if shouldCapture || (types?.contains(.fileURL) ?? false) {
|
||||
dlog(
|
||||
"overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " +
|
||||
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
|
||||
"types=\(debugPasteboardTypes(types))"
|
||||
)
|
||||
let webView = shouldCapture ? webViewUnderPoint(loc) : nil
|
||||
|
||||
if let prev = activeDragWebView, prev !== webView {
|
||||
prev.draggingExited(sender)
|
||||
activeDragWebView = nil
|
||||
}
|
||||
|
||||
if let webView {
|
||||
if activeDragWebView !== webView {
|
||||
activeDragWebView = webView
|
||||
return webView.draggingEntered(sender)
|
||||
}
|
||||
return webView.draggingUpdated(sender)
|
||||
}
|
||||
|
||||
let hasTerminalTarget = terminalUnderPoint(loc) != nil
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " +
|
||||
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
|
||||
"hasWebView=\(webView != nil ? 1 : 0) " +
|
||||
"hasTerminal=\(hasTerminalTarget ? 1 : 0) " +
|
||||
"types=\(debugPasteboardTypes(types))"
|
||||
)
|
||||
#endif
|
||||
guard shouldCapture,
|
||||
terminalUnderPoint(sender.draggingLocation) != nil else { return [] }
|
||||
guard shouldCapture, hasTerminalTarget else { return [] }
|
||||
return .copy
|
||||
}
|
||||
|
||||
|
|
@ -350,6 +386,22 @@ final class FileDropOverlayView: NSView {
|
|||
return types.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Hit-tests the window to find a WKWebView (browser panel) under the cursor.
|
||||
private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? {
|
||||
guard let window, let contentView = window.contentView else { return nil }
|
||||
isHidden = true
|
||||
defer { isHidden = false }
|
||||
let point = contentView.convert(windowPoint, from: nil)
|
||||
let hitView = contentView.hitTest(point)
|
||||
|
||||
var current: NSView? = hitView
|
||||
while let view = current {
|
||||
if let webView = view as? WKWebView { return webView }
|
||||
current = view.superview
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Hit-tests the window to find the GhosttyNSView under the cursor.
|
||||
func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
|
||||
if let window,
|
||||
|
|
|
|||
|
|
@ -679,7 +679,6 @@ class GhosttyApp {
|
|||
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
||||
let body = actionBody
|
||||
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
||||
tabManager.moveTabToTop(tabId)
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
|
|
@ -883,7 +882,6 @@ class GhosttyApp {
|
|||
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
|
||||
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
||||
let body = actionBody
|
||||
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
|
|
|
|||
|
|
@ -26,9 +26,13 @@ enum KeyboardShortcutSettings {
|
|||
case focusDown
|
||||
case splitRight
|
||||
case splitDown
|
||||
case splitBrowserRight
|
||||
case splitBrowserDown
|
||||
|
||||
// Panels
|
||||
case openBrowser
|
||||
case toggleBrowserDeveloperTools
|
||||
case showBrowserJavaScriptConsole
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
|
|
@ -51,7 +55,11 @@ enum KeyboardShortcutSettings {
|
|||
case .focusDown: return "Focus Pane Down"
|
||||
case .splitRight: return "Split Right"
|
||||
case .splitDown: return "Split Down"
|
||||
case .splitBrowserRight: return "Split Browser Right"
|
||||
case .splitBrowserDown: return "Split Browser Down"
|
||||
case .openBrowser: return "Open Browser"
|
||||
case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools"
|
||||
case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,10 +79,14 @@ enum KeyboardShortcutSettings {
|
|||
case .focusDown: return "shortcut.focusDown"
|
||||
case .splitRight: return "shortcut.splitRight"
|
||||
case .splitDown: return "shortcut.splitDown"
|
||||
case .splitBrowserRight: return "shortcut.splitBrowserRight"
|
||||
case .splitBrowserDown: return "shortcut.splitBrowserDown"
|
||||
case .nextSurface: return "shortcut.nextSurface"
|
||||
case .prevSurface: return "shortcut.prevSurface"
|
||||
case .newSurface: return "shortcut.newSurface"
|
||||
case .openBrowser: return "shortcut.openBrowser"
|
||||
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
|
||||
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +120,10 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
|
||||
case .splitDown:
|
||||
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
|
||||
case .splitBrowserRight:
|
||||
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
|
||||
case .splitBrowserDown:
|
||||
return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false)
|
||||
case .nextSurface:
|
||||
return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false)
|
||||
case .prevSurface:
|
||||
|
|
@ -116,6 +132,12 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
|
||||
case .openBrowser:
|
||||
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
|
||||
case .toggleBrowserDeveloperTools:
|
||||
// Safari default: Show Web Inspector.
|
||||
return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false)
|
||||
case .showBrowserJavaScriptConsole:
|
||||
// Safari default: Show JavaScript Console.
|
||||
return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,12 +198,16 @@ enum KeyboardShortcutSettings {
|
|||
|
||||
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
|
||||
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
|
||||
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
|
||||
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }
|
||||
|
||||
static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) }
|
||||
static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) }
|
||||
static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) }
|
||||
|
||||
static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) }
|
||||
static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) }
|
||||
static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) }
|
||||
}
|
||||
|
||||
/// A keyboard shortcut that can be stored in UserDefaults
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Foundation
|
|||
import Combine
|
||||
import WebKit
|
||||
import AppKit
|
||||
import Bonsplit
|
||||
|
||||
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
|
||||
case google
|
||||
|
|
@ -75,6 +76,164 @@ enum BrowserLinkOpenSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum BrowserInsecureHTTPSettings {
|
||||
static let allowlistKey = "browserInsecureHTTPAllowlist"
|
||||
static let defaultAllowlistPatterns = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"*.localtest.me",
|
||||
]
|
||||
static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n")
|
||||
|
||||
static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] {
|
||||
normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey))
|
||||
}
|
||||
|
||||
static func normalizedAllowlistPatterns(rawValue: String?) -> [String] {
|
||||
let source: String
|
||||
if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
source = rawValue
|
||||
} else {
|
||||
source = defaultAllowlistText
|
||||
}
|
||||
let parsed = parsePatterns(from: source)
|
||||
return parsed.isEmpty ? defaultAllowlistPatterns : parsed
|
||||
}
|
||||
|
||||
static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool {
|
||||
isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey))
|
||||
}
|
||||
|
||||
static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool {
|
||||
guard let normalizedHost = normalizeHost(host) else { return false }
|
||||
return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in
|
||||
hostMatchesPattern(normalizedHost, pattern: pattern)
|
||||
}
|
||||
}
|
||||
|
||||
static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) {
|
||||
guard let normalizedHost = normalizeHost(host) else { return }
|
||||
var patterns = normalizedAllowlistPatterns(defaults: defaults)
|
||||
guard !patterns.contains(normalizedHost) else { return }
|
||||
patterns.append(normalizedHost)
|
||||
defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey)
|
||||
}
|
||||
|
||||
static func normalizeHost(_ rawHost: String) -> String? {
|
||||
var value = rawHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard !value.isEmpty else { return nil }
|
||||
|
||||
if let parsed = URL(string: value)?.host {
|
||||
return trimHost(parsed)
|
||||
}
|
||||
|
||||
if let schemeRange = value.range(of: "://") {
|
||||
value = String(value[schemeRange.upperBound...])
|
||||
}
|
||||
|
||||
if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) {
|
||||
value = String(value[..<slash])
|
||||
}
|
||||
|
||||
if value.hasPrefix("[") {
|
||||
if let closing = value.firstIndex(of: "]") {
|
||||
value = String(value[value.index(after: value.startIndex)..<closing])
|
||||
} else {
|
||||
value.removeFirst()
|
||||
}
|
||||
} else if let colon = value.lastIndex(of: ":"),
|
||||
value[value.index(after: colon)...].allSatisfy(\.isNumber),
|
||||
value.filter({ $0 == ":" }).count == 1 {
|
||||
value = String(value[..<colon])
|
||||
}
|
||||
|
||||
return trimHost(value)
|
||||
}
|
||||
|
||||
private static func parsePatterns(from rawValue: String) -> [String] {
|
||||
let separators = CharacterSet(charactersIn: ",;\n\r\t")
|
||||
var out: [String] = []
|
||||
var seen = Set<String>()
|
||||
for token in rawValue.components(separatedBy: separators) {
|
||||
guard let normalized = normalizePattern(token) else { continue }
|
||||
guard seen.insert(normalized).inserted else { continue }
|
||||
out.append(normalized)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func normalizePattern(_ rawPattern: String) -> String? {
|
||||
let trimmed = rawPattern
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("*.") {
|
||||
let suffixRaw = String(trimmed.dropFirst(2))
|
||||
guard let suffix = normalizeHost(suffixRaw) else { return nil }
|
||||
return "*.\(suffix)"
|
||||
}
|
||||
|
||||
return normalizeHost(trimmed)
|
||||
}
|
||||
|
||||
private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool {
|
||||
if pattern.hasPrefix("*.") {
|
||||
let suffix = String(pattern.dropFirst(2))
|
||||
return host == suffix || host.hasSuffix(".\(suffix)")
|
||||
}
|
||||
return host == pattern
|
||||
}
|
||||
|
||||
private static func trimHost(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func browserShouldBlockInsecureHTTPURL(
|
||||
_ url: URL,
|
||||
defaults: UserDefaults = .standard
|
||||
) -> Bool {
|
||||
browserShouldBlockInsecureHTTPURL(
|
||||
url,
|
||||
rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
|
||||
)
|
||||
}
|
||||
|
||||
func browserShouldBlockInsecureHTTPURL(
|
||||
_ url: URL,
|
||||
rawAllowlist: String?
|
||||
) -> Bool {
|
||||
guard url.scheme?.lowercased() == "http" else { return false }
|
||||
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true }
|
||||
return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist)
|
||||
}
|
||||
|
||||
func browserShouldConsumeOneTimeInsecureHTTPBypass(
|
||||
_ url: URL,
|
||||
bypassHostOnce: inout String?
|
||||
) -> Bool {
|
||||
guard let bypassHost = bypassHostOnce else { return false }
|
||||
guard url.scheme?.lowercased() == "http",
|
||||
let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
|
||||
return false
|
||||
}
|
||||
guard host == bypassHost else { return false }
|
||||
bypassHostOnce = nil
|
||||
return true
|
||||
}
|
||||
|
||||
func browserShouldPersistInsecureHTTPAllowlistSelection(
|
||||
response: NSApplication.ModalResponse,
|
||||
suppressionEnabled: Bool
|
||||
) -> Bool {
|
||||
guard suppressionEnabled else { return false }
|
||||
return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn
|
||||
}
|
||||
|
||||
enum BrowserUserAgentSettings {
|
||||
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
|
||||
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
|
||||
|
|
@ -769,6 +928,11 @@ actor BrowserSearchSuggestionService {
|
|||
|
||||
/// BrowserPanel provides a WKWebView-based browser panel.
|
||||
/// All browser panels share a WKProcessPool for cookie sharing.
|
||||
private enum BrowserInsecureHTTPNavigationIntent {
|
||||
case currentTab
|
||||
case newTab
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserPanel: Panel, ObservableObject {
|
||||
/// Shared process pool for cookie sharing across all browser panels
|
||||
|
|
@ -837,6 +1001,14 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
private let minPageZoom: CGFloat = 0.25
|
||||
private let maxPageZoom: CGFloat = 5.0
|
||||
private let pageZoomStep: CGFloat = 0.1
|
||||
private var insecureHTTPBypassHostOnce: String?
|
||||
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
|
||||
private var preferredDeveloperToolsVisible: Bool = false
|
||||
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
|
||||
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
||||
private var developerToolsRestoreRetryAttempt: Int = 0
|
||||
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
|
||||
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
||||
|
||||
var displayTitle: String {
|
||||
if !pageTitle.isEmpty {
|
||||
|
|
@ -856,9 +1028,10 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
false
|
||||
}
|
||||
|
||||
init(workspaceId: UUID, initialURL: URL? = nil) {
|
||||
init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) {
|
||||
self.id = UUID()
|
||||
self.workspaceId = workspaceId
|
||||
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
|
||||
|
||||
// Configure web view
|
||||
let config = WKWebViewConfiguration()
|
||||
|
|
@ -877,6 +1050,11 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
let webView = CmuxWebView(frame: .zero, configuration: config)
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
|
||||
// Required for Web Inspector support on recent WebKit SDKs.
|
||||
if #available(macOS 13.3, *) {
|
||||
webView.isInspectable = true
|
||||
}
|
||||
|
||||
// Match the empty-page background to the window so newly-created browsers
|
||||
// don't flash white before content loads.
|
||||
webView.underPageBackgroundColor = .windowBackgroundColor
|
||||
|
|
@ -907,6 +1085,12 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
navDelegate.openInNewTab = { [weak self] url in
|
||||
self?.openLinkInNewTab(url: url)
|
||||
}
|
||||
navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in
|
||||
self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false
|
||||
}
|
||||
navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in
|
||||
self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
|
||||
}
|
||||
webView.navigationDelegate = navDelegate
|
||||
self.navigationDelegate = navDelegate
|
||||
|
||||
|
|
@ -916,6 +1100,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
guard let self else { return }
|
||||
self.openLinkInNewTab(url: url)
|
||||
}
|
||||
browserUIDelegate.requestNavigation = { [weak self] url, intent in
|
||||
self?.requestNavigation(url, intent: intent)
|
||||
}
|
||||
webView.uiDelegate = browserUIDelegate
|
||||
self.uiDelegate = browserUIDelegate
|
||||
|
||||
|
|
@ -1208,9 +1395,20 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
// MARK: - Navigation
|
||||
|
||||
/// Navigate to a URL
|
||||
func navigate(to url: URL) {
|
||||
func navigate(to url: URL, recordTypedNavigation: Bool = false) {
|
||||
if shouldBlockInsecureHTTPNavigation(to: url) {
|
||||
presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation)
|
||||
return
|
||||
}
|
||||
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
|
||||
}
|
||||
|
||||
private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) {
|
||||
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
|
||||
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
||||
if recordTypedNavigation {
|
||||
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
|
||||
}
|
||||
navigationDelegate?.lastAttemptedURL = url
|
||||
var request = URLRequest(url: url)
|
||||
// Behave like a normal browser (respect HTTP caching). Reload is handled separately.
|
||||
|
|
@ -1226,8 +1424,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
if let url = resolveNavigableURL(from: trimmed) {
|
||||
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
|
||||
navigate(to: url)
|
||||
navigate(to: url, recordTypedNavigation: true)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1240,7 +1437,77 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
resolveBrowserNavigableURL(input)
|
||||
}
|
||||
|
||||
private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool {
|
||||
if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) {
|
||||
return false
|
||||
}
|
||||
return browserShouldBlockInsecureHTTPURL(url)
|
||||
}
|
||||
|
||||
private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) {
|
||||
if shouldBlockInsecureHTTPNavigation(to: url) {
|
||||
presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
|
||||
return
|
||||
}
|
||||
switch intent {
|
||||
case .currentTab:
|
||||
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false)
|
||||
case .newTab:
|
||||
openLinkInNewTab(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentInsecureHTTPAlert(
|
||||
for url: URL,
|
||||
intent: BrowserInsecureHTTPNavigationIntent,
|
||||
recordTypedNavigation: Bool
|
||||
) {
|
||||
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return }
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Connection isn't secure"
|
||||
alert.informativeText = """
|
||||
\(host) uses plain HTTP, so traffic can be read or modified on the network.
|
||||
|
||||
Open this URL in your default browser, or proceed in cmux.
|
||||
"""
|
||||
alert.addButton(withTitle: "Open in Default Browser")
|
||||
alert.addButton(withTitle: "Proceed in cmux")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.showsSuppressionButton = true
|
||||
alert.suppressionButton?.title = "Always allow this host in cmux"
|
||||
|
||||
let response = alert.runModal()
|
||||
if browserShouldPersistInsecureHTTPAllowlistSelection(
|
||||
response: response,
|
||||
suppressionEnabled: alert.suppressionButton?.state == .on
|
||||
) {
|
||||
BrowserInsecureHTTPSettings.addAllowedHost(host)
|
||||
}
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
NSWorkspace.shared.open(url)
|
||||
case .alertSecondButtonReturn:
|
||||
switch intent {
|
||||
case .currentTab:
|
||||
insecureHTTPBypassHostOnce = host
|
||||
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
|
||||
case .newTab:
|
||||
openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
developerToolsRestoreRetryWorkItem?.cancel()
|
||||
developerToolsRestoreRetryWorkItem = nil
|
||||
let webView = webView
|
||||
Task { @MainActor in
|
||||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
}
|
||||
webViewObservers.removeAll()
|
||||
}
|
||||
}
|
||||
|
|
@ -1290,11 +1557,16 @@ extension BrowserPanel {
|
|||
}
|
||||
|
||||
/// Open a link in a new browser surface in the same pane
|
||||
func openLinkInNewTab(url: URL) {
|
||||
func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) {
|
||||
guard let tabManager = AppDelegate.shared?.tabManager,
|
||||
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }),
|
||||
let paneId = workspace.paneId(forPanelId: id) else { return }
|
||||
workspace.newBrowserSurface(inPane: paneId, url: url, focus: true)
|
||||
workspace.newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
|
||||
)
|
||||
}
|
||||
|
||||
/// Reload the current page
|
||||
|
|
@ -1308,6 +1580,183 @@ extension BrowserPanel {
|
|||
webView.stopLoading()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleDeveloperTools() -> Bool {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " +
|
||||
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
|
||||
)
|
||||
#endif
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
let targetVisible = !visible
|
||||
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
preferredDeveloperToolsVisible = targetVisible
|
||||
if targetVisible {
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
} else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " +
|
||||
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
|
||||
)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
dlog(
|
||||
"browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " +
|
||||
"\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func showDeveloperTools() -> Bool {
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if !visible {
|
||||
let showSelector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: showSelector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func showDeveloperToolsConsole() -> Bool {
|
||||
guard showDeveloperTools() else { return false }
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return true }
|
||||
// WebKit private inspector API differs by OS; try known console selectors.
|
||||
let consoleSelectors = [
|
||||
"showConsole",
|
||||
"showConsoleTab",
|
||||
"showConsoleView",
|
||||
]
|
||||
for raw in consoleSelectors {
|
||||
let selector = NSSelectorFromString(raw)
|
||||
if inspector.responds(to: selector) {
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Called before WKWebView detaches so manual inspector closes are respected.
|
||||
func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) {
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return }
|
||||
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
|
||||
if visible {
|
||||
preferredDeveloperToolsVisible = true
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
if preserveVisibleIntent && preferredDeveloperToolsVisible {
|
||||
return
|
||||
}
|
||||
preferredDeveloperToolsVisible = false
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
||||
/// Called after WKWebView reattaches to keep inspector stable across split/layout churn.
|
||||
func restoreDeveloperToolsAfterAttachIfNeeded() {
|
||||
guard preferredDeveloperToolsVisible else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
return
|
||||
}
|
||||
guard let inspector = webView.cmuxInspectorObject() else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
|
||||
let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach
|
||||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visible {
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
}
|
||||
#endif
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
|
||||
let selector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: selector) else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
}
|
||||
#endif
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
preferredDeveloperToolsVisible = true
|
||||
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visibleAfterShow {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func isDeveloperToolsVisible() -> Bool {
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func hideDeveloperTools() -> Bool {
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visible {
|
||||
let selector = NSSelectorFromString("close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = false
|
||||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return true
|
||||
}
|
||||
|
||||
/// During split/layout transitions SwiftUI can briefly mark the browser surface hidden
|
||||
/// while its container is off-window. Avoid detaching in that transient phase if
|
||||
/// DevTools is intended to remain open, because detach/reattach can blank inspector content.
|
||||
func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool {
|
||||
preferredDeveloperToolsVisible
|
||||
}
|
||||
|
||||
func requestDeveloperToolsRefreshAfterNextAttach(reason: String) {
|
||||
guard preferredDeveloperToolsVisible else { return }
|
||||
forceDeveloperToolsRefreshOnNextAttach = true
|
||||
#if DEBUG
|
||||
dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())")
|
||||
#endif
|
||||
}
|
||||
|
||||
func hasPendingDeveloperToolsRefreshAfterAttach() -> Bool {
|
||||
forceDeveloperToolsRefreshOnNextAttach
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func zoomIn() -> Bool {
|
||||
applyPageZoom(webView.pageZoom + pageZoomStep)
|
||||
|
|
@ -1416,6 +1865,84 @@ extension BrowserPanel {
|
|||
|
||||
}
|
||||
|
||||
private extension BrowserPanel {
|
||||
func scheduleDeveloperToolsRestoreRetry() {
|
||||
guard preferredDeveloperToolsVisible else { return }
|
||||
guard developerToolsRestoreRetryWorkItem == nil else { return }
|
||||
guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return }
|
||||
|
||||
developerToolsRestoreRetryAttempt += 1
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.developerToolsRestoreRetryWorkItem = nil
|
||||
self.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
}
|
||||
developerToolsRestoreRetryWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work)
|
||||
}
|
||||
|
||||
func cancelDeveloperToolsRestoreRetry() {
|
||||
developerToolsRestoreRetryWorkItem?.cancel()
|
||||
developerToolsRestoreRetryWorkItem = nil
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BrowserPanel {
|
||||
private static func debugRectDescription(_ rect: NSRect) -> String {
|
||||
String(
|
||||
format: "%.1f,%.1f %.1fx%.1f",
|
||||
rect.origin.x,
|
||||
rect.origin.y,
|
||||
rect.size.width,
|
||||
rect.size.height
|
||||
)
|
||||
}
|
||||
|
||||
private static func debugObjectToken(_ object: AnyObject?) -> String {
|
||||
guard let object else { return "nil" }
|
||||
return String(describing: Unmanaged.passUnretained(object).toOpaque())
|
||||
}
|
||||
|
||||
private static func debugInspectorSubviewCount(in root: NSView) -> Int {
|
||||
var stack: [NSView] = [root]
|
||||
var count = 0
|
||||
while let current = stack.popLast() {
|
||||
for subview in current.subviews {
|
||||
if String(describing: type(of: subview)).contains("WKInspector") {
|
||||
count += 1
|
||||
}
|
||||
stack.append(subview)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func debugDeveloperToolsStateSummary() -> String {
|
||||
let preferred = preferredDeveloperToolsVisible ? 1 : 0
|
||||
let visible = isDeveloperToolsVisible() ? 1 : 0
|
||||
let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1
|
||||
let attached = webView.superview == nil ? 0 : 1
|
||||
let inWindow = webView.window == nil ? 0 : 1
|
||||
let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0
|
||||
return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)"
|
||||
}
|
||||
|
||||
func debugDeveloperToolsGeometrySummary() -> String {
|
||||
let container = webView.superview
|
||||
let containerBounds = container?.bounds ?? .zero
|
||||
let webFrame = webView.frame
|
||||
let inspectorInsets = max(0, containerBounds.height - webFrame.height)
|
||||
let inspectorOverflow = max(0, webFrame.maxY - containerBounds.maxY)
|
||||
let inspectorHeightApprox = max(inspectorInsets, inspectorOverflow)
|
||||
let inspectorSubviews = container.map { Self.debugInspectorSubviewCount(in: $0) } ?? 0
|
||||
let containerType = container.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private extension BrowserPanel {
|
||||
@discardableResult
|
||||
func applyPageZoom(_ candidate: CGFloat) -> Bool {
|
||||
|
|
@ -1439,12 +1966,41 @@ private extension BrowserPanel {
|
|||
}
|
||||
}
|
||||
|
||||
private extension WKWebView {
|
||||
func cmuxInspectorObject() -> NSObject? {
|
||||
let selector = NSSelectorFromString("_inspector")
|
||||
guard responds(to: selector),
|
||||
let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else {
|
||||
return nil
|
||||
}
|
||||
return inspector
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSObject {
|
||||
func cmuxCallBool(selector: Selector) -> Bool? {
|
||||
guard responds(to: selector) else { return nil }
|
||||
typealias Fn = @convention(c) (AnyObject, Selector) -> Bool
|
||||
let fn = unsafeBitCast(method(for: selector), to: Fn.self)
|
||||
return fn(self, selector)
|
||||
}
|
||||
|
||||
func cmuxCallVoid(selector: Selector) {
|
||||
guard responds(to: selector) else { return }
|
||||
typealias Fn = @convention(c) (AnyObject, Selector) -> Void
|
||||
let fn = unsafeBitCast(method(for: selector), to: Fn.self)
|
||||
fn(self, selector)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Delegate
|
||||
|
||||
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
var didFinish: ((WKWebView) -> Void)?
|
||||
var didFailNavigation: ((WKWebView, String) -> Void)?
|
||||
var openInNewTab: ((URL) -> Void)?
|
||||
var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)?
|
||||
var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)?
|
||||
/// The URL of the last navigation that was attempted. Used to preserve the omnibar URL
|
||||
/// when a provisional navigation fails (e.g. connection refused on localhost:3000).
|
||||
var lastAttemptedURL: URL?
|
||||
|
|
@ -1564,6 +2120,21 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
if let url = navigationAction.request.url,
|
||||
navigationAction.targetFrame?.isMainFrame != false,
|
||||
shouldBlockInsecureHTTPNavigation?(url) == true {
|
||||
let intent: BrowserInsecureHTTPNavigationIntent
|
||||
if navigationAction.navigationType == .linkActivated,
|
||||
navigationAction.modifierFlags.contains(.command) {
|
||||
intent = .newTab
|
||||
} else {
|
||||
intent = .currentTab
|
||||
}
|
||||
handleBlockedInsecureHTTPNavigation?(url, intent)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// target=_blank or window.open() — navigate in the current webview
|
||||
if navigationAction.targetFrame == nil,
|
||||
let url = navigationAction.request.url {
|
||||
|
|
@ -1589,6 +2160,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||
|
||||
private class BrowserUIDelegate: NSObject, WKUIDelegate {
|
||||
var openInNewTab: ((URL) -> Void)?
|
||||
var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)?
|
||||
|
||||
/// Returning nil tells WebKit not to open a new window.
|
||||
/// Cmd+click opens in a new tab; regular target=_blank navigates in-place.
|
||||
|
|
@ -1599,7 +2171,11 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
|
|||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
if let url = navigationAction.request.url {
|
||||
if navigationAction.modifierFlags.contains(.command) {
|
||||
if let requestNavigation {
|
||||
let intent: BrowserInsecureHTTPNavigationIntent =
|
||||
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab
|
||||
requestNavigation(url, intent)
|
||||
} else if navigationAction.modifierFlags.contains(.command) {
|
||||
openInNewTab?(url)
|
||||
} else {
|
||||
webView.load(URLRequest(url: url))
|
||||
|
|
@ -1607,4 +2183,20 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Handle <input type="file"> elements by presenting the native file picker.
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping ([URL]?) -> Void
|
||||
) {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||
panel.canChooseDirectories = parameters.allowsDirectories
|
||||
panel.canChooseFiles = true
|
||||
panel.begin { result in
|
||||
completionHandler(result == .OK ? panel.urls : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,113 @@ import SwiftUI
|
|||
import WebKit
|
||||
import AppKit
|
||||
|
||||
enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable {
|
||||
case wrenchAndScrewdriver = "wrench.and.screwdriver"
|
||||
case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill"
|
||||
case curlyBracesSquare = "curlybraces.square"
|
||||
case curlyBraces = "curlybraces"
|
||||
case terminalFill = "terminal.fill"
|
||||
case terminal = "terminal"
|
||||
case hammer = "hammer"
|
||||
case hammerCircle = "hammer.circle"
|
||||
case ladybug = "ladybug"
|
||||
case ladybugFill = "ladybug.fill"
|
||||
case scope = "scope"
|
||||
case codeChevrons = "chevron.left.slash.chevron.right"
|
||||
case gearshape = "gearshape"
|
||||
case gearshapeFill = "gearshape.fill"
|
||||
case globe = "globe"
|
||||
case globeAmericas = "globe.americas.fill"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .wrenchAndScrewdriver: return "Wrench + Screwdriver"
|
||||
case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)"
|
||||
case .curlyBracesSquare: return "Curly Braces"
|
||||
case .curlyBraces: return "Curly Braces (Plain)"
|
||||
case .terminalFill: return "Terminal (Fill)"
|
||||
case .terminal: return "Terminal"
|
||||
case .hammer: return "Hammer"
|
||||
case .hammerCircle: return "Hammer Circle"
|
||||
case .ladybug: return "Bug"
|
||||
case .ladybugFill: return "Bug (Fill)"
|
||||
case .scope: return "Scope"
|
||||
case .codeChevrons: return "Code Chevrons"
|
||||
case .gearshape: return "Gear"
|
||||
case .gearshapeFill: return "Gear (Fill)"
|
||||
case .globe: return "Globe"
|
||||
case .globeAmericas: return "Globe Americas (Fill)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
|
||||
case bonsplitInactive
|
||||
case bonsplitActive
|
||||
case accent
|
||||
case tertiary
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)"
|
||||
case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)"
|
||||
case .accent: return "Accent"
|
||||
case .tertiary: return "Tertiary"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .bonsplitInactive:
|
||||
// Matches Bonsplit tab icon tint for inactive tabs.
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
case .bonsplitActive:
|
||||
// Matches Bonsplit tab icon tint for active tabs.
|
||||
return Color(nsColor: .labelColor)
|
||||
case .accent:
|
||||
return .accentColor
|
||||
case .tertiary:
|
||||
return Color(nsColor: .tertiaryLabelColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BrowserDevToolsButtonDebugSettings {
|
||||
static let iconNameKey = "browserDevToolsIconName"
|
||||
static let iconColorKey = "browserDevToolsIconColor"
|
||||
static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver
|
||||
static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive
|
||||
|
||||
static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption {
|
||||
guard let raw = defaults.string(forKey: iconNameKey),
|
||||
let option = BrowserDevToolsIconOption(rawValue: raw) else {
|
||||
return defaultIcon
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption {
|
||||
guard let raw = defaults.string(forKey: iconColorKey),
|
||||
let option = BrowserDevToolsIconColorOption(rawValue: raw) else {
|
||||
return defaultColor
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
static func copyPayload(defaults: UserDefaults = .standard) -> String {
|
||||
let icon = iconOption(defaults: defaults)
|
||||
let color = colorOption(defaults: defaults)
|
||||
return """
|
||||
browserDevToolsIconName=\(icon.rawValue)
|
||||
browserDevToolsIconColor=\(color.rawValue)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
struct OmnibarInlineCompletion: Equatable {
|
||||
let typedText: String
|
||||
let displayText: String
|
||||
|
|
@ -20,11 +127,14 @@ struct BrowserPanelView: View {
|
|||
@ObservedObject var panel: BrowserPanel
|
||||
let isFocused: Bool
|
||||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
let onRequestPanelFocus: () -> Void
|
||||
@State private var omnibarState = OmnibarState()
|
||||
@State private var addressBarFocused: Bool = false
|
||||
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
|
||||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
|
||||
@State private var suggestionTask: Task<Void, Never>?
|
||||
@State private var isLoadingRemoteSuggestions: Bool = false
|
||||
@State private var latestRemoteSuggestionQuery: String = ""
|
||||
|
|
@ -38,6 +148,8 @@ struct BrowserPanelView: View {
|
|||
@State private var omnibarPillFrame: CGRect = .zero
|
||||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
private let omnibarPillCornerRadius: CGFloat = 12
|
||||
private let addressBarButtonSize: CGFloat = 22
|
||||
private let devToolsButtonIconSize: CGFloat = 11
|
||||
|
||||
private var searchEngine: BrowserSearchEngine {
|
||||
BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine
|
||||
|
|
@ -63,6 +175,14 @@ struct BrowserPanelView: View {
|
|||
return searchSuggestionsEnabled
|
||||
}
|
||||
|
||||
private var devToolsIconOption: BrowserDevToolsIconOption {
|
||||
BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
|
||||
}
|
||||
|
||||
private var devToolsColorOption: BrowserDevToolsIconColorOption {
|
||||
BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
addressBar
|
||||
|
|
@ -210,6 +330,8 @@ struct BrowserPanelView: View {
|
|||
omnibarField
|
||||
.accessibilityIdentifier("BrowserOmnibarPill")
|
||||
.accessibilityLabel("Browser omnibar")
|
||||
|
||||
developerToolsButton
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
|
|
@ -219,8 +341,6 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private var addressBarButtonBar: some View {
|
||||
let navButtonSize: CGFloat = 22
|
||||
|
||||
return HStack(spacing: 0) {
|
||||
Button(action: {
|
||||
#if DEBUG
|
||||
|
|
@ -230,10 +350,10 @@ struct BrowserPanelView: View {
|
|||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.disabled(!panel.canGoBack)
|
||||
.opacity(panel.canGoBack ? 1.0 : 0.4)
|
||||
.help("Go Back")
|
||||
|
|
@ -246,10 +366,10 @@ struct BrowserPanelView: View {
|
|||
}) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.disabled(!panel.canGoForward)
|
||||
.opacity(panel.canGoForward ? 1.0 : 0.4)
|
||||
.help("Go Forward")
|
||||
|
|
@ -269,14 +389,29 @@ struct BrowserPanelView: View {
|
|||
}) {
|
||||
Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.help(panel.isLoading ? "Stop" : "Reload")
|
||||
}
|
||||
}
|
||||
|
||||
private var developerToolsButton: some View {
|
||||
Button(action: {
|
||||
openDevTools()
|
||||
}) {
|
||||
Image(systemName: devToolsIconOption.rawValue)
|
||||
.font(.system(size: devToolsButtonIconSize, weight: .medium))
|
||||
.foregroundStyle(devToolsColorOption.color)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.help("Toggle Developer Tools")
|
||||
.accessibilityIdentifier("BrowserToggleDevToolsButton")
|
||||
}
|
||||
|
||||
private var omnibarField: some View {
|
||||
let showSecureBadge = panel.currentURL?.scheme == "https"
|
||||
|
||||
|
|
@ -370,7 +505,8 @@ struct BrowserPanelView: View {
|
|||
panel: panel,
|
||||
shouldAttachWebView: isVisibleInUI,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority
|
||||
)
|
||||
// Keep the representable identity stable across bonsplit structural updates.
|
||||
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
|
||||
|
|
@ -384,12 +520,6 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
})
|
||||
.zIndex(0)
|
||||
.contextMenu {
|
||||
Button("Open Developer Tools") {
|
||||
openDevTools()
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerFocusFlashAnimation() {
|
||||
|
|
@ -453,10 +583,11 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private func openDevTools() {
|
||||
// WKWebView with developerExtrasEnabled allows right-click > Inspect Element
|
||||
// We can also trigger via JavaScript
|
||||
Task {
|
||||
try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')")
|
||||
#if DEBUG
|
||||
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
|
||||
#endif
|
||||
if !panel.toggleDeveloperTools() {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1071,7 +1202,19 @@ func buildOmnibarSuggestions(
|
|||
)
|
||||
order += 1
|
||||
if let existing = bestByCompletion[key] {
|
||||
if ranked.score > existing.score {
|
||||
let shouldReplaceExisting: Bool = {
|
||||
// For identical completions, keep "go to URL" over "switch to tab" so
|
||||
// pressing Enter performs navigation unless the user explicitly picks a tab row.
|
||||
switch (existing.suggestion.kind, ranked.suggestion.kind) {
|
||||
case (.navigate, .switchToTab):
|
||||
return false
|
||||
case (.switchToTab, .navigate):
|
||||
return true
|
||||
default:
|
||||
return ranked.score > existing.score
|
||||
}
|
||||
}()
|
||||
if shouldReplaceExisting {
|
||||
bestByCompletion[key] = ranked
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1970,6 +2113,8 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
parent.onMoveSelection(-1)
|
||||
return true
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
let currentFlags = NSApp.currentEvent?.modifierFlags ?? []
|
||||
guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false }
|
||||
parent.onSubmit()
|
||||
return true
|
||||
case #selector(NSResponder.cancelOperation(_:)):
|
||||
|
|
@ -2080,6 +2225,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
|
||||
switch keyCode {
|
||||
case 36, 76: // Return / keypad Enter
|
||||
guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false }
|
||||
parent.onSubmit()
|
||||
return true
|
||||
case 53: // Escape
|
||||
|
|
@ -2431,15 +2577,88 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
let shouldAttachWebView: Bool
|
||||
let shouldFocusWebView: Bool
|
||||
let isPanelFocused: Bool
|
||||
let portalZPriority: Int
|
||||
|
||||
final class Coordinator {
|
||||
weak var panel: BrowserPanel?
|
||||
weak var webView: WKWebView?
|
||||
var constraints: [NSLayoutConstraint] = []
|
||||
var attachRetryWorkItem: DispatchWorkItem?
|
||||
var attachRetryCount: Int = 0
|
||||
var attachGeneration: Int = 0
|
||||
var usesWindowPortal: Bool = false
|
||||
var desiredPortalVisibleInUI: Bool = true
|
||||
var desiredPortalZPriority: Int = 0
|
||||
var lastPortalHostId: ObjectIdentifier?
|
||||
}
|
||||
|
||||
private final class HostContainerView: NSView {
|
||||
var onDidMoveToWindow: (() -> Void)?
|
||||
var onGeometryChanged: (() -> Void)?
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
onDidMoveToWindow?()
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
super.viewDidMoveToSuperview()
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func setFrameOrigin(_ newOrigin: NSPoint) {
|
||||
super.setFrameOrigin(newOrigin)
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
onGeometryChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private static func logDevToolsState(
|
||||
_ panel: BrowserPanel,
|
||||
event: String,
|
||||
generation: Int,
|
||||
retryCount: Int,
|
||||
details: String? = nil
|
||||
) {
|
||||
var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())"
|
||||
if let details, !details.isEmpty {
|
||||
line += " \(details)"
|
||||
}
|
||||
dlog(line)
|
||||
}
|
||||
|
||||
private static func objectID(_ object: AnyObject?) -> String {
|
||||
guard let object else { return "nil" }
|
||||
return String(describing: Unmanaged.passUnretained(object).toOpaque())
|
||||
}
|
||||
|
||||
private static func responderDescription(_ responder: NSResponder?) -> String {
|
||||
guard let responder else { return "nil" }
|
||||
return "\(type(of: responder))@\(objectID(responder))"
|
||||
}
|
||||
|
||||
private static func rectDescription(_ rect: NSRect) -> String {
|
||||
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
|
||||
}
|
||||
|
||||
private static func attachContext(webView: WKWebView, host: NSView) -> String {
|
||||
let hostWindow = host.window?.windowNumber ?? -1
|
||||
let webWindow = webView.window?.windowNumber ?? -1
|
||||
let firstResponder = (webView.window ?? host.window)?.firstResponder
|
||||
return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))"
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
|
||||
var r = start
|
||||
var hops = 0
|
||||
|
|
@ -2451,22 +2670,141 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
return false
|
||||
}
|
||||
|
||||
private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool {
|
||||
guard let responder else { return false }
|
||||
let responderType = String(describing: type(of: responder))
|
||||
if responderType.contains("WKInspector") {
|
||||
return true
|
||||
}
|
||||
guard let view = responder as? NSView else { return false }
|
||||
var node: NSView? = view
|
||||
var hops = 0
|
||||
while let current = node, hops < 64 {
|
||||
if String(describing: type(of: current)).contains("WKInspector") {
|
||||
return true
|
||||
}
|
||||
node = current.superview
|
||||
hops += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func firstResponderResignState(
|
||||
_ responder: NSResponder?,
|
||||
webView: WKWebView
|
||||
) -> (needsResign: Bool, flags: String) {
|
||||
let inWebViewChain = responderChainContains(responder, target: webView)
|
||||
let inspectorResponder = isLikelyInspectorResponder(responder)
|
||||
let needsResign = inWebViewChain || inspectorResponder
|
||||
return (
|
||||
needsResign: needsResign,
|
||||
flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
let coordinator = Coordinator()
|
||||
coordinator.panel = panel
|
||||
return coordinator
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let container = NSView()
|
||||
let container = HostContainerView()
|
||||
container.wantsLayer = true
|
||||
return container
|
||||
}
|
||||
|
||||
private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) {
|
||||
private static func clearPortalCallbacks(for host: NSView) {
|
||||
guard let host = host as? HostContainerView else { return }
|
||||
host.onDidMoveToWindow = nil
|
||||
host.onGeometryChanged = nil
|
||||
}
|
||||
|
||||
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
|
||||
guard let host = nsView as? HostContainerView else { return }
|
||||
|
||||
let coordinator = context.coordinator
|
||||
let previousVisible = coordinator.desiredPortalVisibleInUI
|
||||
let previousZPriority = coordinator.desiredPortalZPriority
|
||||
coordinator.desiredPortalVisibleInUI = shouldAttachWebView
|
||||
coordinator.desiredPortalZPriority = portalZPriority
|
||||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
|
||||
guard let host, let webView, let coordinator else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard host.window != nil else { return }
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
webView: webView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastPortalHostId = ObjectIdentifier(host)
|
||||
}
|
||||
host.onGeometryChanged = { [weak host, weak coordinator] in
|
||||
guard let host, let coordinator else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
}
|
||||
|
||||
if !shouldAttachWebView {
|
||||
// In portal mode we no longer detach/re-attach to preserve DevTools state.
|
||||
// Sync the inspector preference directly so manual closes are respected.
|
||||
panel.syncDeveloperToolsPreferenceFromInspector()
|
||||
}
|
||||
|
||||
if host.window != nil {
|
||||
let hostId = ObjectIdentifier(host)
|
||||
let shouldBindNow =
|
||||
coordinator.lastPortalHostId != hostId ||
|
||||
webView.superview == nil ||
|
||||
previousVisible != shouldAttachWebView ||
|
||||
previousZPriority != portalZPriority
|
||||
if shouldBindNow {
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
webView: webView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastPortalHostId = hostId
|
||||
}
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
}
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "portal.update",
|
||||
generation: coordinator.attachGeneration,
|
||||
retryCount: coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: host)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func attachWebView(_ webView: WKWebView, to host: NSView) {
|
||||
// WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder
|
||||
// while being detached/reparented during bonsplit/SwiftUI structural updates.
|
||||
if let window = webView.window,
|
||||
responderChainContains(window.firstResponder, target: webView) {
|
||||
window.makeFirstResponder(nil)
|
||||
if let window = webView.window {
|
||||
let state = firstResponderResignState(window.firstResponder, webView: webView)
|
||||
if state.needsResign {
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// The target host can already be in-window while the source host is tearing down.
|
||||
// Re-check against the target window too (it can differ during split churn).
|
||||
if let window = host.window {
|
||||
let state = firstResponderResignState(window.firstResponder, webView: webView)
|
||||
if state.needsResign {
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Detach from any previous host (bonsplit/SwiftUI may rearrange views).
|
||||
|
|
@ -2474,15 +2812,11 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
host.subviews.forEach { $0.removeFromSuperview() }
|
||||
host.addSubview(webView)
|
||||
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.deactivate(coordinator.constraints)
|
||||
coordinator.constraints = [
|
||||
webView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
webView.topAnchor.constraint(equalTo: host.topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(coordinator.constraints)
|
||||
// Work around WebKit bug 272474 where Inspect Element can render blank/flicker
|
||||
// when WKWebView is edge-pinned using Auto Layout constraints.
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = host.bounds
|
||||
|
||||
// Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out.
|
||||
webView.needsLayout = true
|
||||
|
|
@ -2491,7 +2825,13 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
webView.displayIfNeeded()
|
||||
}
|
||||
|
||||
private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) {
|
||||
private static func scheduleAttachRetry(
|
||||
_ webView: WKWebView,
|
||||
panel: BrowserPanel,
|
||||
to host: NSView,
|
||||
coordinator: Coordinator,
|
||||
generation: Int
|
||||
) {
|
||||
// Don't schedule multiple overlapping retries.
|
||||
guard coordinator.attachRetryWorkItem == nil else { return }
|
||||
|
||||
|
|
@ -2510,18 +2850,54 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// is in a window during bonsplit tree updates; moving the webview too early can be flaky.
|
||||
guard host.window != nil else {
|
||||
coordinator.attachRetryCount += 1
|
||||
#if DEBUG
|
||||
if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 {
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "retry.waitingForWindow",
|
||||
generation: generation,
|
||||
retryCount: coordinator.attachRetryCount,
|
||||
details: attachContext(webView: webView, host: host)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
// Be generous here: bonsplit structural updates can keep a representable
|
||||
// container off-window longer than a few seconds under load.
|
||||
if coordinator.attachRetryCount < 400 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation)
|
||||
scheduleAttachRetry(
|
||||
webView,
|
||||
panel: panel,
|
||||
to: host,
|
||||
coordinator: coordinator,
|
||||
generation: generation
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
coordinator.attachRetryCount = 0
|
||||
attachWebView(webView, to: host, coordinator: coordinator)
|
||||
#if DEBUG
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "retry.attach.begin",
|
||||
generation: generation,
|
||||
retryCount: 0,
|
||||
details: attachContext(webView: webView, host: host)
|
||||
)
|
||||
#endif
|
||||
attachWebView(webView, to: host)
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
#if DEBUG
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "retry.attached",
|
||||
generation: generation,
|
||||
retryCount: 0,
|
||||
details: attachContext(webView: webView, host: host)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
coordinator.attachRetryWorkItem = work
|
||||
|
|
@ -2530,30 +2906,106 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let webView = panel.webView
|
||||
context.coordinator.panel = panel
|
||||
context.coordinator.webView = webView
|
||||
|
||||
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
|
||||
if shouldUseWindowPortal {
|
||||
context.coordinator.usesWindowPortal = true
|
||||
Self.clearPortalCallbacks(for: nsView)
|
||||
updateUsingWindowPortal(nsView, context: context, webView: webView)
|
||||
Self.applyFocus(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
nsView: nsView,
|
||||
shouldFocusWebView: shouldFocusWebView,
|
||||
isPanelFocused: isPanelFocused
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if context.coordinator.usesWindowPortal {
|
||||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
context.coordinator.usesWindowPortal = false
|
||||
context.coordinator.lastPortalHostId = nil
|
||||
}
|
||||
Self.clearPortalCallbacks(for: nsView)
|
||||
|
||||
// Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left
|
||||
// in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce
|
||||
// WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane.
|
||||
if !shouldAttachWebView {
|
||||
// Split/layout churn can briefly create an off-window phase while DevTools is open.
|
||||
// Detaching here can blank inspector content even when visibility preference stays true.
|
||||
if nsView.window == nil,
|
||||
webView.superview != nil,
|
||||
panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.skipped.offWindowDevTools",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.beforeSync",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.afterSync",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
context.coordinator.attachRetryWorkItem?.cancel()
|
||||
context.coordinator.attachRetryWorkItem = nil
|
||||
context.coordinator.attachRetryCount = 0
|
||||
context.coordinator.attachGeneration += 1
|
||||
|
||||
// Resign focus if WebKit currently owns first responder.
|
||||
if let window = webView.window,
|
||||
Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
window.makeFirstResponder(nil)
|
||||
if let window = webView.window ?? nsView.window {
|
||||
let state = Self.firstResponderResignState(window.firstResponder, webView: webView)
|
||||
if state.needsResign {
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.resignFirstResponder",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags
|
||||
)
|
||||
#endif
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
||||
NSLayoutConstraint.deactivate(context.coordinator.constraints)
|
||||
context.coordinator.constraints.removeAll()
|
||||
|
||||
if webView.superview != nil {
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
nsView.subviews.forEach { $0.removeFromSuperview() }
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "detach.done",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2563,17 +3015,83 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
context.coordinator.attachRetryWorkItem = nil
|
||||
context.coordinator.attachGeneration += 1
|
||||
|
||||
if let window = webView.window ?? nsView.window {
|
||||
let state = Self.firstResponderResignState(window.firstResponder, webView: webView)
|
||||
if state.needsResign {
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.reparent.resignFirstResponder.begin",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags
|
||||
)
|
||||
#endif
|
||||
let resigned = window.makeFirstResponder(nil)
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.reparent.resignFirstResponder.end",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
if nsView.window == nil {
|
||||
// Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI
|
||||
// can create containers that are never inserted into the window.
|
||||
if panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
|
||||
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow")
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.defer.requestRefresh",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.defer.offWindow",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
Self.scheduleAttachRetry(
|
||||
webView,
|
||||
panel: panel,
|
||||
to: nsView,
|
||||
coordinator: context.coordinator,
|
||||
generation: context.coordinator.attachGeneration
|
||||
)
|
||||
} else {
|
||||
Self.attachWebView(webView, to: nsView, coordinator: context.coordinator)
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.immediate.begin",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
Self.attachWebView(webView, to: nsView)
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
#if DEBUG
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.immediate",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Already attached; no need for any pending retry.
|
||||
|
|
@ -2581,25 +3099,59 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
context.coordinator.attachRetryWorkItem = nil
|
||||
context.coordinator.attachRetryCount = 0
|
||||
context.coordinator.attachGeneration += 1
|
||||
let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach()
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
#if DEBUG
|
||||
if hadPendingRefresh {
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.alreadyAttached.consumePendingRefresh",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
}
|
||||
Self.logDevToolsState(
|
||||
panel,
|
||||
event: "attach.alreadyAttached",
|
||||
generation: context.coordinator.attachGeneration,
|
||||
retryCount: context.coordinator.attachRetryCount,
|
||||
details: Self.attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
Self.applyFocus(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
nsView: nsView,
|
||||
shouldFocusWebView: shouldFocusWebView,
|
||||
isPanelFocused: isPanelFocused
|
||||
)
|
||||
}
|
||||
|
||||
private static func applyFocus(
|
||||
panel: BrowserPanel,
|
||||
webView: WKWebView,
|
||||
nsView: NSView,
|
||||
shouldFocusWebView: Bool,
|
||||
isPanelFocused: Bool
|
||||
) {
|
||||
// Focus handling. Avoid fighting the address bar when it is focused.
|
||||
guard let window = nsView.window else { return }
|
||||
if shouldFocusWebView {
|
||||
if panel.shouldSuppressWebViewFocus() {
|
||||
return
|
||||
}
|
||||
if Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
if responderChainContains(window.firstResponder, target: webView) {
|
||||
return
|
||||
}
|
||||
window.makeFirstResponder(webView)
|
||||
} else {
|
||||
} else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
|
||||
// Only force-resign WebView focus when this panel itself is not focused.
|
||||
// If the panel is focused but the omnibar-focus state is briefly stale, aggressively
|
||||
// clearing first responder here can undo programmatic webview focus (socket tests).
|
||||
if !isPanelFocused && Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2608,20 +3160,85 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.attachRetryWorkItem = nil
|
||||
coordinator.attachRetryCount = 0
|
||||
coordinator.attachGeneration += 1
|
||||
|
||||
NSLayoutConstraint.deactivate(coordinator.constraints)
|
||||
coordinator.constraints.removeAll()
|
||||
clearPortalCallbacks(for: nsView)
|
||||
|
||||
guard let webView = coordinator.webView else { return }
|
||||
let panel = coordinator.panel
|
||||
|
||||
if coordinator.usesWindowPortal {
|
||||
coordinator.usesWindowPortal = false
|
||||
coordinator.lastPortalHostId = nil
|
||||
|
||||
// During split/layout churn we keep the WKWebView portal-hosted so DevTools
|
||||
// does not lose state. BrowserPanel deinit explicitly detaches on real teardown.
|
||||
if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
|
||||
#if DEBUG
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "dismantle.portal.keepAttached",
|
||||
generation: coordinator.attachGeneration,
|
||||
retryCount: coordinator.attachRetryCount,
|
||||
details: attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
return
|
||||
}
|
||||
|
||||
// If we're being torn down while the WKWebView (or one of its subviews) is first responder,
|
||||
// resign it before detaching.
|
||||
let window = webView.window ?? nsView.window
|
||||
if let window, responderChainContains(window.firstResponder, target: webView) {
|
||||
window.makeFirstResponder(nil)
|
||||
if let window {
|
||||
let state = firstResponderResignState(window.firstResponder, webView: webView)
|
||||
if state.needsResign {
|
||||
#if DEBUG
|
||||
if let panel {
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "dismantle.resignFirstResponder",
|
||||
generation: coordinator.attachGeneration,
|
||||
retryCount: coordinator.attachRetryCount,
|
||||
details: attachContext(webView: webView, host: nsView) + " " + state.flags
|
||||
)
|
||||
}
|
||||
#endif
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// During split/layout churn, SwiftUI may tear down a host view while a new one is still
|
||||
// coming online. When DevTools is intended open, avoid eagerly detaching here.
|
||||
if let panel,
|
||||
panel.shouldPreserveWebViewAttachmentDuringTransientHide(),
|
||||
webView.superview === nsView {
|
||||
#if DEBUG
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "dismantle.skipDetach.devTools",
|
||||
generation: coordinator.attachGeneration,
|
||||
retryCount: coordinator.attachRetryCount,
|
||||
details: attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if webView.superview === nsView {
|
||||
webView.removeFromSuperview()
|
||||
#if DEBUG
|
||||
if let panel {
|
||||
logDevToolsState(
|
||||
panel,
|
||||
event: "dismantle.detached",
|
||||
generation: coordinator.attachGeneration,
|
||||
retryCount: coordinator.attachRetryCount,
|
||||
details: attachContext(webView: webView, host: nsView)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ import WebKit
|
|||
/// the first responder.
|
||||
final class CmuxWebView: WKWebView {
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
|
||||
// route it through app/menu key equivalents, which can trigger unintended actions.
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
return true
|
||||
|
|
@ -110,10 +117,21 @@ final class CmuxWebView: WKWebView {
|
|||
// of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because
|
||||
// AppKit only bubbles up through superviews, not siblings.
|
||||
//
|
||||
// Fix: prevent WKWebView from registering as a drag destination entirely. AppKit won't
|
||||
// route drags here, so they reach the SwiftUI overlay drop zones as intended.
|
||||
// Fix: filter out text-based types that conflict with bonsplit tab drags, but keep
|
||||
// file URL types so Finder file drops and HTML drag-and-drop work.
|
||||
private static let blockedDragTypes: Set<NSPasteboard.PasteboardType> = [
|
||||
.string, // public.utf8-plain-text — matches bonsplit's NSString tab drags
|
||||
NSPasteboard.PasteboardType("public.text"),
|
||||
NSPasteboard.PasteboardType("public.plain-text"),
|
||||
NSPasteboard.PasteboardType("com.splittabbar.tabtransfer"),
|
||||
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
|
||||
]
|
||||
|
||||
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
|
||||
// No-op: suppress WKWebView's automatic drag type registration.
|
||||
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
|
||||
if !filtered.isEmpty {
|
||||
super.registerForDraggedTypes(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ struct PanelContentView: View {
|
|||
panel: browserPanel,
|
||||
isFocused: isFocused,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalPriority: portalPriority,
|
||||
onRequestPanelFocus: onRequestPanelFocus
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,18 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
enum WorkspaceAutoReorderSettings {
|
||||
static let key = "workspaceAutoReorderOnNotification"
|
||||
static let defaultValue = true
|
||||
|
||||
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: key) == nil {
|
||||
return defaultValue
|
||||
}
|
||||
return defaults.bool(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkspacePlacementSettings {
|
||||
static let placementKey = "newWorkspacePlacement"
|
||||
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
|
||||
|
|
@ -829,6 +841,16 @@ class TabManager: ObservableObject {
|
|||
focusedBrowserPanel?.resetZoom() ?? false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleDeveloperToolsFocusedBrowser() -> Bool {
|
||||
focusedBrowserPanel?.toggleDeveloperTools() ?? false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func showJavaScriptConsoleFocusedBrowser() -> Bool {
|
||||
focusedBrowserPanel?.showDeveloperToolsConsole() ?? false
|
||||
}
|
||||
|
||||
/// Backwards compatibility: returns the focused surface ID
|
||||
func focusedSurfaceId(for tabId: UUID) -> UUID? {
|
||||
focusedPanelId(for: tabId)
|
||||
|
|
@ -1253,6 +1275,28 @@ class TabManager: ObservableObject {
|
|||
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
|
||||
}
|
||||
|
||||
/// Create a new browser split from the currently focused panel.
|
||||
@discardableResult
|
||||
func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? {
|
||||
guard let selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
||||
let focusedPanelId = tab.focusedPanelId else { return nil }
|
||||
return newBrowserSplit(
|
||||
tabId: selectedTabId,
|
||||
fromPanelId: focusedPanelId,
|
||||
orientation: direction.orientation,
|
||||
insertFirst: direction.insertFirst,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
|
||||
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
|
||||
func refreshSplitButtonTooltips() {
|
||||
for workspace in tabs {
|
||||
workspace.refreshSplitButtonTooltips()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pane Focus Navigation
|
||||
|
||||
/// Move focus to an adjacent pane in the specified direction
|
||||
|
|
@ -1393,9 +1437,20 @@ class TabManager: ObservableObject {
|
|||
// MARK: - Browser Panel Operations
|
||||
|
||||
/// Create a new browser panel in a split
|
||||
func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? {
|
||||
func newBrowserSplit(
|
||||
tabId: UUID,
|
||||
fromPanelId: UUID,
|
||||
orientation: SplitOrientation,
|
||||
insertFirst: Bool = false,
|
||||
url: URL? = nil
|
||||
) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id
|
||||
return tab.newBrowserSplit(
|
||||
from: fromPanelId,
|
||||
orientation: orientation,
|
||||
insertFirst: insertFirst,
|
||||
url: url
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Create a new browser surface in a pane
|
||||
|
|
|
|||
|
|
@ -461,6 +461,9 @@ class TerminalController {
|
|||
case "reset_sidebar":
|
||||
return resetSidebar(args)
|
||||
|
||||
case "read_screen":
|
||||
return readScreenText(args)
|
||||
|
||||
|
||||
#if DEBUG
|
||||
case "set_shortcut":
|
||||
|
|
@ -508,9 +511,6 @@ class TerminalController {
|
|||
case "read_terminal_text":
|
||||
return readTerminalText(args)
|
||||
|
||||
case "read_screen":
|
||||
return readScreen(args)
|
||||
|
||||
case "render_stats":
|
||||
return renderStats(args)
|
||||
|
||||
|
|
@ -679,6 +679,14 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params))
|
||||
case "workspace.reorder":
|
||||
return v2Result(id: id, self.v2WorkspaceReorder(params: params))
|
||||
case "workspace.rename":
|
||||
return v2Result(id: id, self.v2WorkspaceRename(params: params))
|
||||
case "workspace.next":
|
||||
return v2Result(id: id, self.v2WorkspaceNext(params: params))
|
||||
case "workspace.previous":
|
||||
return v2Result(id: id, self.v2WorkspacePrevious(params: params))
|
||||
case "workspace.last":
|
||||
return v2Result(id: id, self.v2WorkspaceLast(params: params))
|
||||
|
||||
|
||||
// Surfaces / input
|
||||
|
|
@ -708,6 +716,8 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2SurfaceSendText(params: params))
|
||||
case "surface.send_key":
|
||||
return v2Result(id: id, self.v2SurfaceSendKey(params: params))
|
||||
case "surface.clear_history":
|
||||
return v2Result(id: id, self.v2SurfaceClearHistory(params: params))
|
||||
case "surface.trigger_flash":
|
||||
return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params))
|
||||
|
||||
|
|
@ -720,6 +730,16 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2PaneSurfaces(params: params))
|
||||
case "pane.create":
|
||||
return v2Result(id: id, self.v2PaneCreate(params: params))
|
||||
case "pane.resize":
|
||||
return v2Result(id: id, self.v2PaneResize(params: params))
|
||||
case "pane.swap":
|
||||
return v2Result(id: id, self.v2PaneSwap(params: params))
|
||||
case "pane.break":
|
||||
return v2Result(id: id, self.v2PaneBreak(params: params))
|
||||
case "pane.join":
|
||||
return v2Result(id: id, self.v2PaneJoin(params: params))
|
||||
case "pane.last":
|
||||
return v2Result(id: id, self.v2PaneLast(params: params))
|
||||
|
||||
// Notifications
|
||||
case "notification.create":
|
||||
|
|
@ -908,6 +928,8 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2BrowserInputKeyboard(params: params))
|
||||
case "browser.input_touch":
|
||||
return v2Result(id: id, self.v2BrowserInputTouch(params: params))
|
||||
case "surface.read_text":
|
||||
return v2Result(id: id, self.v2SurfaceReadText(params: params))
|
||||
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -972,6 +994,10 @@ class TerminalController {
|
|||
"workspace.close",
|
||||
"workspace.move_to_window",
|
||||
"workspace.reorder",
|
||||
"workspace.rename",
|
||||
"workspace.next",
|
||||
"workspace.previous",
|
||||
"workspace.last",
|
||||
"surface.list",
|
||||
"surface.current",
|
||||
"surface.focus",
|
||||
|
|
@ -985,11 +1011,18 @@ class TerminalController {
|
|||
"surface.health",
|
||||
"surface.send_text",
|
||||
"surface.send_key",
|
||||
"surface.read_text",
|
||||
"surface.clear_history",
|
||||
"surface.trigger_flash",
|
||||
"pane.list",
|
||||
"pane.focus",
|
||||
"pane.surfaces",
|
||||
"pane.create",
|
||||
"pane.resize",
|
||||
"pane.swap",
|
||||
"pane.break",
|
||||
"pane.join",
|
||||
"pane.last",
|
||||
"notification.create",
|
||||
"notification.create_for_surface",
|
||||
"notification.create_for_target",
|
||||
|
|
@ -1695,6 +1728,116 @@ class TerminalController {
|
|||
"index": v2OrNull(newIndex)
|
||||
])
|
||||
}
|
||||
private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
guard let workspaceId = v2UUID(params, "workspace_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
||||
}
|
||||
guard let titleRaw = v2String(params, "title"),
|
||||
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
|
||||
}
|
||||
|
||||
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var renamed = false
|
||||
v2MainSync {
|
||||
guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return }
|
||||
tabManager.setCustomTitle(tabId: workspaceId, title: title)
|
||||
renamed = true
|
||||
}
|
||||
|
||||
guard renamed else {
|
||||
return .err(code: "not_found", message: "Workspace not found", data: [
|
||||
"workspace_id": workspaceId.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId)
|
||||
])
|
||||
}
|
||||
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
return .ok([
|
||||
"workspace_id": workspaceId.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"title": title
|
||||
])
|
||||
}
|
||||
private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
|
||||
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
|
||||
v2MainSync {
|
||||
guard tabManager.selectedTabId != nil else { return }
|
||||
if let windowId = v2ResolveWindowId(tabManager: tabManager) {
|
||||
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
|
||||
setActiveTabManager(tabManager)
|
||||
}
|
||||
tabManager.selectNextTab()
|
||||
guard let workspaceId = tabManager.selectedTabId else { return }
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"workspace_id": workspaceId.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
|
||||
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
|
||||
v2MainSync {
|
||||
guard tabManager.selectedTabId != nil else { return }
|
||||
if let windowId = v2ResolveWindowId(tabManager: tabManager) {
|
||||
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
|
||||
setActiveTabManager(tabManager)
|
||||
}
|
||||
tabManager.selectPreviousTab()
|
||||
guard let workspaceId = tabManager.selectedTabId else { return }
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"workspace_id": workspaceId.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
|
||||
var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil)
|
||||
v2MainSync {
|
||||
guard let before = tabManager.selectedTabId else { return }
|
||||
if let windowId = v2ResolveWindowId(tabManager: tabManager) {
|
||||
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
|
||||
setActiveTabManager(tabManager)
|
||||
}
|
||||
tabManager.navigateBack()
|
||||
guard let after = tabManager.selectedTabId, after != before else { return }
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"workspace_id": after.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: after),
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - V2 Surface Methods
|
||||
|
||||
|
|
@ -2399,6 +2542,155 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
|
||||
var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil)
|
||||
v2MainSync {
|
||||
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
||||
guard let surfaceId else {
|
||||
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
||||
return
|
||||
}
|
||||
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
|
||||
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
}
|
||||
|
||||
guard terminalPanel.performBindingAction("clear_screen") else {
|
||||
result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
terminalPanel.surface.forceRefresh()
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"workspace_id": ws.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
||||
])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
|
||||
var includeScrollback = v2Bool(params, "scrollback") ?? false
|
||||
let lineLimit = v2Int(params, "lines")
|
||||
if let lineLimit, lineLimit <= 0 {
|
||||
return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil)
|
||||
}
|
||||
if lineLimit != nil {
|
||||
includeScrollback = true
|
||||
}
|
||||
|
||||
var result: V2CallResult = .err(code: "internal_error", message: "Failed to read terminal text", data: nil)
|
||||
v2MainSync {
|
||||
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
||||
guard let surfaceId else {
|
||||
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
||||
return
|
||||
}
|
||||
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
|
||||
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
}
|
||||
|
||||
let response = readTerminalTextBase64(
|
||||
terminalPanel: terminalPanel,
|
||||
includeScrollback: includeScrollback,
|
||||
lineLimit: lineLimit
|
||||
)
|
||||
guard response.hasPrefix("OK ") else {
|
||||
result = .err(code: "internal_error", message: response, data: nil)
|
||||
return
|
||||
}
|
||||
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) }
|
||||
guard let text = decoded ?? (base64.isEmpty ? "" : nil) else {
|
||||
result = .err(code: "internal_error", message: "Failed to decode terminal text", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"text": text,
|
||||
"base64": base64,
|
||||
"workspace_id": ws.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
|
||||
guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" }
|
||||
|
||||
let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT
|
||||
let topLeft = ghostty_point_s(
|
||||
tag: pointTag,
|
||||
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
|
||||
x: 0,
|
||||
y: 0
|
||||
)
|
||||
let bottomRight = ghostty_point_s(
|
||||
tag: pointTag,
|
||||
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
||||
x: 0,
|
||||
y: 0
|
||||
)
|
||||
let selection = ghostty_selection_s(
|
||||
top_left: topLeft,
|
||||
bottom_right: bottomRight,
|
||||
rectangle: true
|
||||
)
|
||||
var text = ghostty_text_s()
|
||||
|
||||
guard ghostty_surface_read_text(surface, selection, &text) else {
|
||||
return "ERROR: Failed to read terminal text"
|
||||
}
|
||||
defer {
|
||||
ghostty_surface_free_text(surface, &text)
|
||||
}
|
||||
|
||||
let rawData: Data
|
||||
if let ptr = text.text, text.text_len > 0 {
|
||||
rawData = Data(bytes: ptr, count: Int(text.text_len))
|
||||
} else {
|
||||
rawData = Data()
|
||||
}
|
||||
|
||||
var output = String(decoding: rawData, as: UTF8.self)
|
||||
if let lineLimit {
|
||||
output = tailTerminalLines(output, maxLines: lineLimit)
|
||||
}
|
||||
|
||||
let base64 = output.data(using: .utf8)?.base64EncodedString() ?? ""
|
||||
return "OK \(base64)"
|
||||
}
|
||||
|
||||
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
|
|
@ -2626,6 +2918,269 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
|
||||
let direction = (v2String(params, "direction") ?? "").lowercased()
|
||||
let amount = v2Int(params, "amount") ?? 1
|
||||
guard ["left", "right", "up", "down"].contains(direction), amount > 0 else {
|
||||
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
|
||||
}
|
||||
return .err(
|
||||
code: "not_supported",
|
||||
message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API",
|
||||
data: [
|
||||
"direction": direction,
|
||||
"amount": amount
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
|
||||
guard let sourcePaneUUID = v2UUID(params, "pane_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil)
|
||||
}
|
||||
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
|
||||
}
|
||||
if sourcePaneUUID == targetPaneUUID {
|
||||
return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil)
|
||||
}
|
||||
let focus = v2Bool(params, "focus") ?? true
|
||||
|
||||
var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil)
|
||||
v2MainSync {
|
||||
guard let located = v2LocatePane(sourcePaneUUID) else {
|
||||
result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString])
|
||||
return
|
||||
}
|
||||
guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else {
|
||||
result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString])
|
||||
return
|
||||
}
|
||||
let workspace = located.workspace
|
||||
let sourcePane = located.paneId
|
||||
|
||||
guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane),
|
||||
let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane),
|
||||
let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id),
|
||||
let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else {
|
||||
result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep pane identities stable during swap when one side has a single surface.
|
||||
var sourcePlaceholder: UUID?
|
||||
var targetPlaceholder: UUID?
|
||||
if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 {
|
||||
sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id
|
||||
if sourcePlaceholder == nil {
|
||||
result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 {
|
||||
targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id
|
||||
if targetPlaceholder == nil {
|
||||
result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else {
|
||||
result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil)
|
||||
return
|
||||
}
|
||||
guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else {
|
||||
result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let sourcePlaceholder {
|
||||
_ = workspace.closePanel(sourcePlaceholder, force: true)
|
||||
}
|
||||
if let targetPlaceholder {
|
||||
_ = workspace.closePanel(targetPlaceholder, force: true)
|
||||
}
|
||||
|
||||
if focus {
|
||||
workspace.bonsplitController.focusPane(targetPane)
|
||||
}
|
||||
let windowId = located.windowId
|
||||
result = .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"workspace_id": workspace.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
|
||||
"pane_id": sourcePane.id.uuidString,
|
||||
"pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id),
|
||||
"target_pane_id": targetPane.id.uuidString,
|
||||
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id),
|
||||
"source_surface_id": sourceSurfaceId.uuidString,
|
||||
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
|
||||
"target_surface_id": targetSurfaceId.uuidString,
|
||||
"target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId)
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2PaneBreak(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
let focus = v2Bool(params, "focus") ?? true
|
||||
|
||||
var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil)
|
||||
v2MainSync {
|
||||
guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let sourcePaneUUID = v2UUID(params, "pane_id")
|
||||
let sourcePane: PaneID? = {
|
||||
if let sourcePaneUUID {
|
||||
return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID })
|
||||
}
|
||||
return sourceWorkspace.bonsplitController.focusedPaneId
|
||||
}()
|
||||
|
||||
let surfaceId: UUID? = {
|
||||
if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface }
|
||||
if let sourcePane,
|
||||
let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) {
|
||||
return sourceWorkspace.panelIdFromSurfaceId(selected.id)
|
||||
}
|
||||
return sourceWorkspace.focusedPanelId
|
||||
}()
|
||||
guard let surfaceId else {
|
||||
result = .err(code: "not_found", message: "No source surface to break", data: nil)
|
||||
return
|
||||
}
|
||||
guard sourceWorkspace.panels[surfaceId] != nil else {
|
||||
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
}
|
||||
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId)
|
||||
let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId)
|
||||
|
||||
guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else {
|
||||
result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let destinationWorkspace = tabManager.addWorkspace()
|
||||
guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId
|
||||
?? destinationWorkspace.bonsplitController.allPaneIds.first else {
|
||||
if let sourcePaneForRollback {
|
||||
_ = sourceWorkspace.attachDetachedSurface(
|
||||
detached,
|
||||
inPane: sourcePaneForRollback,
|
||||
atIndex: sourceIndex,
|
||||
focus: true
|
||||
)
|
||||
}
|
||||
result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else {
|
||||
if let sourcePaneForRollback {
|
||||
_ = sourceWorkspace.attachDetachedSurface(
|
||||
detached,
|
||||
inPane: sourcePaneForRollback,
|
||||
atIndex: sourceIndex,
|
||||
focus: true
|
||||
)
|
||||
}
|
||||
result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !focus {
|
||||
tabManager.selectWorkspace(sourceWorkspace)
|
||||
}
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"workspace_id": destinationWorkspace.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id),
|
||||
"pane_id": destinationPane.id.uuidString,
|
||||
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2PaneJoin(params: [String: Any]) -> V2CallResult {
|
||||
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
|
||||
}
|
||||
|
||||
var surfaceId = v2UUID(params, "surface_id")
|
||||
if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") {
|
||||
guard let sourceLocated = v2LocatePane(sourcePaneUUID),
|
||||
let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId),
|
||||
let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else {
|
||||
return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [
|
||||
"pane_id": sourcePaneUUID.uuidString
|
||||
])
|
||||
}
|
||||
surfaceId = selectedSurface
|
||||
}
|
||||
guard let surfaceId else {
|
||||
return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil)
|
||||
}
|
||||
|
||||
var moveParams: [String: Any] = [
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"pane_id": targetPaneUUID.uuidString
|
||||
]
|
||||
if let focus = v2Bool(params, "focus") {
|
||||
moveParams["focus"] = focus
|
||||
}
|
||||
return v2SurfaceMove(params: moveParams)
|
||||
}
|
||||
|
||||
private func v2PaneLast(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
|
||||
var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil)
|
||||
v2MainSync {
|
||||
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
guard let focused = ws.bonsplitController.focusedPaneId else {
|
||||
result = .err(code: "not_found", message: "No focused pane", data: nil)
|
||||
return
|
||||
}
|
||||
guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else {
|
||||
result = .err(code: "not_found", message: "No alternate pane available", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
ws.bonsplitController.focusPane(target)
|
||||
let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) }
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"workspace_id": ws.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||
"pane_id": target.id.uuidString,
|
||||
"pane_ref": v2Ref(kind: .pane, uuid: target.id),
|
||||
"surface_id": v2OrNull(selectedSurfaceId?.uuidString),
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId)
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - V2 Notification Methods
|
||||
|
||||
private func v2NotificationCreate(params: [String: Any]) -> V2CallResult {
|
||||
|
|
@ -6205,6 +6760,123 @@ class TerminalController {
|
|||
}
|
||||
#endif
|
||||
|
||||
private struct ReadScreenOptions {
|
||||
let surfaceArg: String
|
||||
let includeScrollback: Bool
|
||||
let lineLimit: Int?
|
||||
}
|
||||
|
||||
private struct ReadScreenParseError: Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private func parseReadScreenArgs(_ args: String) -> Result<ReadScreenOptions, ReadScreenParseError> {
|
||||
let tokens = args
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map(String.init)
|
||||
var surfaceArg: String?
|
||||
var includeScrollback = false
|
||||
var lineLimit: Int?
|
||||
var idx = 0
|
||||
|
||||
while idx < tokens.count {
|
||||
let token = tokens[idx]
|
||||
switch token {
|
||||
case "--scrollback":
|
||||
includeScrollback = true
|
||||
idx += 1
|
||||
case "--lines":
|
||||
guard idx + 1 < tokens.count, let parsed = Int(tokens[idx + 1]), parsed > 0 else {
|
||||
return .failure(ReadScreenParseError(message: "ERROR: --lines must be greater than 0"))
|
||||
}
|
||||
lineLimit = parsed
|
||||
includeScrollback = true
|
||||
idx += 2
|
||||
default:
|
||||
guard surfaceArg == nil else {
|
||||
return .failure(ReadScreenParseError(message: "ERROR: Usage: read_screen [id|idx] [--scrollback] [--lines <n>]"))
|
||||
}
|
||||
surfaceArg = token
|
||||
idx += 1
|
||||
}
|
||||
}
|
||||
|
||||
return .success(
|
||||
ReadScreenOptions(
|
||||
surfaceArg: surfaceArg ?? "",
|
||||
includeScrollback: includeScrollback,
|
||||
lineLimit: lineLimit
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func tailTerminalLines(_ text: String, maxLines: Int) -> String {
|
||||
guard maxLines > 0 else { return "" }
|
||||
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
guard lines.count > maxLines else { return text }
|
||||
return lines.suffix(maxLines).joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var result = "ERROR: No tab selected"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tabId = tabManager.selectedTabId,
|
||||
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let panelId: UUID?
|
||||
if trimmedSurfaceArg.isEmpty {
|
||||
panelId = tab.focusedPanelId
|
||||
} else {
|
||||
panelId = resolveSurfaceId(from: trimmedSurfaceArg, tab: tab)
|
||||
}
|
||||
|
||||
guard let panelId,
|
||||
let terminalPanel = tab.terminalPanel(for: panelId) else {
|
||||
result = "ERROR: Terminal surface not found"
|
||||
return
|
||||
}
|
||||
|
||||
result = readTerminalTextBase64(
|
||||
terminalPanel: terminalPanel,
|
||||
includeScrollback: includeScrollback,
|
||||
lineLimit: lineLimit
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func readScreenText(_ args: String) -> String {
|
||||
let options: ReadScreenOptions
|
||||
switch parseReadScreenArgs(args) {
|
||||
case .success(let parsed):
|
||||
options = parsed
|
||||
case .failure(let error):
|
||||
return error.message
|
||||
}
|
||||
|
||||
let response = readTerminalTextBase64(
|
||||
surfaceArg: options.surfaceArg,
|
||||
includeScrollback: options.includeScrollback,
|
||||
lineLimit: options.lineLimit
|
||||
)
|
||||
guard response.hasPrefix("OK ") else { return response }
|
||||
|
||||
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if payload.isEmpty {
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let data = Data(base64Encoded: payload) else {
|
||||
return "ERROR: Failed to decode terminal text"
|
||||
}
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
private func helpText() -> String {
|
||||
var text = """
|
||||
Hierarchy: Workspace (sidebar tab) > Pane (split region) > Surface (nested tab) > Panel (terminal/browser)
|
||||
|
|
@ -6237,6 +6909,7 @@ class TerminalController {
|
|||
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
|
||||
send_surface <id|idx> <text> - Send text to a specific terminal
|
||||
send_key_surface <id|idx> <key> - Send special key to a specific terminal
|
||||
read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text)
|
||||
|
||||
Notification commands:
|
||||
notify <title>|<subtitle>|<body> - Notify focused panel
|
||||
|
|
@ -6298,7 +6971,6 @@ class TerminalController {
|
|||
activate_app - Bring app + main window to front (test-only)
|
||||
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
|
||||
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
|
||||
read_screen [id|idx] - Read visible terminal text (plain text, legacy test-only)
|
||||
render_stats [id|idx] - Read terminal render stats (draw counters, test-only)
|
||||
layout_debug - Dump bonsplit layout + selected panel bounds (test-only)
|
||||
bonsplit_underflow_count - Count bonsplit arranged-subview underflow events (test-only)
|
||||
|
|
@ -6766,82 +7438,7 @@ class TerminalController {
|
|||
}
|
||||
|
||||
private func readTerminalText(_ args: String) -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
var result = "ERROR: No tab selected"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tabId = tabManager.selectedTabId,
|
||||
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let panelId: UUID?
|
||||
if panelArg.isEmpty {
|
||||
panelId = tab.focusedPanelId
|
||||
} else {
|
||||
panelId = resolveSurfaceId(from: panelArg, tab: tab)
|
||||
}
|
||||
|
||||
guard let panelId,
|
||||
let terminalPanel = tab.terminalPanel(for: panelId),
|
||||
let surface = terminalPanel.surface.surface else {
|
||||
result = "ERROR: Terminal surface not found"
|
||||
return
|
||||
}
|
||||
|
||||
var selection = ghostty_selection_s(
|
||||
top_left: ghostty_point_s(
|
||||
tag: GHOSTTY_POINT_VIEWPORT,
|
||||
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
|
||||
x: 0,
|
||||
y: 0
|
||||
),
|
||||
bottom_right: ghostty_point_s(
|
||||
tag: GHOSTTY_POINT_VIEWPORT,
|
||||
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
||||
x: 0,
|
||||
y: 0
|
||||
),
|
||||
rectangle: true
|
||||
)
|
||||
var text = ghostty_text_s()
|
||||
|
||||
guard ghostty_surface_read_text(surface, selection, &text) else {
|
||||
result = "ERROR: Failed to read terminal text"
|
||||
return
|
||||
}
|
||||
defer {
|
||||
ghostty_surface_free_text(surface, &text)
|
||||
}
|
||||
|
||||
let b64: String
|
||||
if let ptr = text.text, text.text_len > 0 {
|
||||
b64 = Data(bytes: ptr, count: Int(text.text_len)).base64EncodedString()
|
||||
} else {
|
||||
b64 = ""
|
||||
}
|
||||
|
||||
result = "OK \(b64)"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func readScreen(_ args: String) -> String {
|
||||
let response = readTerminalText(args)
|
||||
guard response.hasPrefix("OK ") else { return response }
|
||||
|
||||
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if payload.isEmpty {
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let data = Data(base64Encoded: payload),
|
||||
let text = String(data: data, encoding: .utf8) else {
|
||||
return "ERROR: Failed to decode terminal text"
|
||||
}
|
||||
return text
|
||||
readTerminalTextBase64(surfaceArg: args)
|
||||
}
|
||||
|
||||
private struct RenderStatsResponse: Codable {
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
if WorkspaceAutoReorderSettings.isEnabled() {
|
||||
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
||||
}
|
||||
|
||||
let notification = TerminalNotification(
|
||||
id: UUID(),
|
||||
tabId: tabId,
|
||||
|
|
|
|||
|
|
@ -105,12 +105,22 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
// MARK: - Initialization
|
||||
|
||||
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
|
||||
BonsplitConfiguration.SplitButtonTooltips(
|
||||
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"),
|
||||
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"),
|
||||
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"),
|
||||
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down")
|
||||
)
|
||||
}
|
||||
|
||||
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
|
||||
bonsplitAppearance(from: config.backgroundColor)
|
||||
}
|
||||
|
||||
private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance {
|
||||
BonsplitConfiguration.Appearance(
|
||||
splitButtonTooltips: Self.currentSplitButtonTooltips(),
|
||||
enableAnimations: false,
|
||||
chromeColors: .init(backgroundHex: backgroundColor.hexString())
|
||||
)
|
||||
|
|
@ -208,6 +218,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func refreshSplitButtonTooltips() {
|
||||
var configuration = bonsplitController.configuration
|
||||
configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips()
|
||||
bonsplitController.configuration = configuration
|
||||
}
|
||||
|
||||
// MARK: - Surface ID to Panel ID Mapping
|
||||
|
||||
/// Mapping from bonsplit TabID (surface ID) to panel UUID
|
||||
|
|
@ -571,11 +587,16 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
inPane paneId: PaneID,
|
||||
url: URL? = nil,
|
||||
focus: Bool? = nil,
|
||||
insertAtEnd: Bool = false
|
||||
insertAtEnd: Bool = false,
|
||||
bypassInsecureHTTPHostOnce: String? = nil
|
||||
) -> BrowserPanel? {
|
||||
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
||||
|
||||
let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
|
||||
let browserPanel = BrowserPanel(
|
||||
workspaceId: id,
|
||||
initialURL: url,
|
||||
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
|
||||
)
|
||||
panels[browserPanel.id] = browserPanel
|
||||
|
||||
guard let newTabId = bonsplitController.createTab(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ struct cmuxApp: App {
|
|||
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
|
||||
private var toggleBrowserDeveloperToolsShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
|
||||
private var showBrowserJavaScriptConsoleShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
|
|
@ -426,6 +432,20 @@ struct cmuxApp: App {
|
|||
}
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
|
||||
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
if !manager.toggleDeveloperToolsFocusedBrowser() {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
if !manager.showJavaScriptConsoleFocusedBrowser() {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
Button("Zoom In") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
|
||||
}
|
||||
|
|
@ -463,6 +483,14 @@ struct cmuxApp: App {
|
|||
performSplitFromMenu(direction: .down)
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) {
|
||||
performBrowserSplitFromMenu(direction: .right)
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) {
|
||||
performBrowserSplitFromMenu(direction: .down)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
|
||||
|
|
@ -545,6 +573,34 @@ struct cmuxApp: App {
|
|||
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
|
||||
}
|
||||
|
||||
private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: toggleBrowserDeveloperToolsShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: showBrowserJavaScriptConsoleShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var splitBrowserRightMenuShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: splitBrowserRightShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var splitBrowserDownMenuShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: splitBrowserDownShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var notificationMenuSnapshot: NotificationMenuSnapshot {
|
||||
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
|
||||
}
|
||||
|
|
@ -577,6 +633,13 @@ struct cmuxApp: App {
|
|||
tabManager.createSplit(direction: direction)
|
||||
}
|
||||
|
||||
private func performBrowserSplitFromMenu(direction: SplitDirection) {
|
||||
if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true {
|
||||
return
|
||||
}
|
||||
_ = tabManager.createBrowserSplit(direction: direction)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
|
||||
if let key = keyEquivalent(for: shortcut) {
|
||||
|
|
@ -1132,6 +1195,7 @@ private enum DebugWindowConfigSnapshot {
|
|||
"""
|
||||
|
||||
let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults)
|
||||
let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
|
||||
|
||||
return """
|
||||
# Sidebar Debug
|
||||
|
|
@ -1142,6 +1206,9 @@ private enum DebugWindowConfigSnapshot {
|
|||
|
||||
# Menu Bar Extra Debug
|
||||
\(menuBarPayload)
|
||||
|
||||
# Browser DevTools Button
|
||||
\(browserDevToolsPayload)
|
||||
"""
|
||||
}
|
||||
|
||||
|
|
@ -1208,6 +1275,16 @@ private struct DebugWindowControlsView: View {
|
|||
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0
|
||||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
|
||||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
|
||||
|
||||
private var selectedDevToolsIconOption: BrowserDevToolsIconOption {
|
||||
BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
|
||||
}
|
||||
|
||||
private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption {
|
||||
BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
|
|
@ -1291,12 +1368,58 @@ private struct DebugWindowControlsView: View {
|
|||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
GroupBox("Browser DevTools Button") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Icon")
|
||||
Picker("Icon", selection: $browserDevToolsIconNameRaw) {
|
||||
ForEach(BrowserDevToolsIconOption.allCases) { option in
|
||||
Text(option.title).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Color")
|
||||
Picker("Color", selection: $browserDevToolsIconColorRaw) {
|
||||
ForEach(BrowserDevToolsIconColorOption.allCases) { option in
|
||||
Text(option.title).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Preview")
|
||||
Spacer()
|
||||
Image(systemName: selectedDevToolsIconOption.rawValue)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(selectedDevToolsColorOption.color)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Reset Button") {
|
||||
resetBrowserDevToolsButton()
|
||||
}
|
||||
Button("Copy Button Config") {
|
||||
copyBrowserDevToolsButtonConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
GroupBox("Copy") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button("Copy All Debug Config") {
|
||||
DebugWindowConfigSnapshot.copyCombinedToPasteboard()
|
||||
}
|
||||
Text("Copies sidebar, background, and menu bar debug settings as one payload.")
|
||||
Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
@ -1357,6 +1480,18 @@ private struct DebugWindowControlsView: View {
|
|||
pasteboard.clearContents()
|
||||
pasteboard.setString(payload, forType: .string)
|
||||
}
|
||||
|
||||
private func resetBrowserDevToolsButton() {
|
||||
browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
|
||||
browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
|
||||
}
|
||||
|
||||
private func copyBrowserDevToolsButtonConfig() {
|
||||
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard)
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(payload, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
private final class AboutWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
|
@ -2275,14 +2410,17 @@ struct SettingsView: View {
|
|||
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
||||
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
@State private var shortcutResetToken = UUID()
|
||||
@State private var topBlurOpacity: Double = 0
|
||||
@State private var topBlurBaselineOffset: CGFloat?
|
||||
@State private var settingsTitleLeadingInset: CGFloat = 92
|
||||
@State private var showClearBrowserHistoryConfirmation = false
|
||||
@State private var browserHistoryEntryCount: Int = 0
|
||||
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
|
||||
private var selectedWorkspacePlacement: NewWorkspacePlacement {
|
||||
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
|
||||
|
|
@ -2303,6 +2441,10 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
|
||||
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
|
||||
}
|
||||
|
||||
private func blurOpacity(forContentOffset offset: CGFloat) -> Double {
|
||||
guard let baseline = topBlurBaselineOffset else { return 0 }
|
||||
let reveal = (baseline - offset) / 24
|
||||
|
|
@ -2343,6 +2485,17 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Reorder on Notification",
|
||||
subtitle: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions."
|
||||
) {
|
||||
Toggle("", isOn: $workspaceAutoReorder)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Dock Badge",
|
||||
subtitle: "Show unread count on app icon (Dock and Cmd+Tab)."
|
||||
|
|
@ -2451,6 +2604,69 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("HTTP Host Allowlist")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextEditor(text: $browserInsecureHTTPAllowlistDraft)
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.frame(minHeight: 86)
|
||||
.padding(6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color(nsColor: .textBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
|
||||
)
|
||||
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistField")
|
||||
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button("Save") {
|
||||
saveBrowserInsecureHTTPAllowlist()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
|
||||
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button("Save") {
|
||||
saveBrowserInsecureHTTPAllowlist()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
|
||||
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) {
|
||||
Button("Clear History…") {
|
||||
showClearBrowserHistoryConfirmation = true
|
||||
|
|
@ -2574,6 +2790,13 @@ struct SettingsView: View {
|
|||
.onAppear {
|
||||
BrowserHistoryStore.shared.loadIfNeeded()
|
||||
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
|
||||
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
|
||||
}
|
||||
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
|
||||
// Keep draft in sync with external changes unless the user has local unsaved edits.
|
||||
if browserInsecureHTTPAllowlistDraft == oldValue {
|
||||
browserInsecureHTTPAllowlistDraft = newValue
|
||||
}
|
||||
}
|
||||
.onReceive(BrowserHistoryStore.shared.$entries) { entries in
|
||||
browserHistoryEntryCount = entries.count
|
||||
|
|
@ -2599,11 +2822,18 @@ struct SettingsView: View {
|
|||
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
||||
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
KeyboardShortcutSettings.resetAll()
|
||||
shortcutResetToken = UUID()
|
||||
}
|
||||
|
||||
private func saveBrowserInsecureHTTPAllowlist() {
|
||||
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import XCTest
|
||||
import AppKit
|
||||
import WebKit
|
||||
import ObjectiveC.runtime
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
|
|
@ -8,6 +9,49 @@ import WebKit
|
|||
@testable import cmux
|
||||
#endif
|
||||
|
||||
private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
|
||||
private var cmuxUnitTestInspectorOverrideInstalled = false
|
||||
|
||||
private extension CmuxWebView {
|
||||
@objc func cmuxUnitTestInspector() -> NSObject? {
|
||||
objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject
|
||||
}
|
||||
}
|
||||
|
||||
private extension WKWebView {
|
||||
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
|
||||
objc_setAssociatedObject(
|
||||
self,
|
||||
&cmuxUnitTestInspectorAssociationKey,
|
||||
inspector,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func installCmuxUnitTestInspectorOverride() {
|
||||
guard !cmuxUnitTestInspectorOverrideInstalled else { return }
|
||||
|
||||
guard let replacementMethod = class_getInstanceMethod(
|
||||
CmuxWebView.self,
|
||||
#selector(CmuxWebView.cmuxUnitTestInspector)
|
||||
) else {
|
||||
fatalError("Unable to locate test inspector replacement method")
|
||||
}
|
||||
|
||||
let added = class_addMethod(
|
||||
CmuxWebView.self,
|
||||
NSSelectorFromString("_inspector"),
|
||||
method_getImplementation(replacementMethod),
|
||||
method_getTypeEncoding(replacementMethod)
|
||||
)
|
||||
guard added else {
|
||||
fatalError("Unable to install CmuxWebView _inspector test override")
|
||||
}
|
||||
|
||||
cmuxUnitTestInspectorOverrideInstalled = true
|
||||
}
|
||||
|
||||
final class CmuxWebViewKeyEquivalentTests: XCTestCase {
|
||||
private final class ActionSpy: NSObject {
|
||||
private(set) var invoked: Bool = false
|
||||
|
|
@ -88,6 +132,258 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
|
||||
private func makeIsolatedDefaults() -> UserDefaults {
|
||||
let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
fatalError("Failed to create defaults suite")
|
||||
}
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
addTeardownBlock {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func testIconCatalogIncludesExpandedChoices() {
|
||||
XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10)
|
||||
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal))
|
||||
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe))
|
||||
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare))
|
||||
}
|
||||
|
||||
func testIconOptionFallsBackToDefaultForUnknownRawValue() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
|
||||
|
||||
XCTAssertEqual(
|
||||
BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults),
|
||||
BrowserDevToolsButtonDebugSettings.defaultIcon
|
||||
)
|
||||
}
|
||||
|
||||
func testColorOptionFallsBackToDefaultForUnknownRawValue() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
|
||||
|
||||
XCTAssertEqual(
|
||||
BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults),
|
||||
BrowserDevToolsButtonDebugSettings.defaultColor
|
||||
)
|
||||
}
|
||||
|
||||
func testCopyPayloadUsesPersistedValues() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
|
||||
defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
|
||||
|
||||
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
|
||||
XCTAssertTrue(payload.contains("browserDevToolsIconName=scope"))
|
||||
XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive"))
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase {
|
||||
func testSafariDefaultShortcutForToggleDeveloperTools() {
|
||||
let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
|
||||
XCTAssertEqual(shortcut.key, "i")
|
||||
XCTAssertTrue(shortcut.command)
|
||||
XCTAssertTrue(shortcut.option)
|
||||
XCTAssertFalse(shortcut.shift)
|
||||
XCTAssertFalse(shortcut.control)
|
||||
}
|
||||
|
||||
func testSafariDefaultShortcutForShowJavaScriptConsole() {
|
||||
let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
|
||||
XCTAssertEqual(shortcut.key, "c")
|
||||
XCTAssertTrue(shortcut.command)
|
||||
XCTAssertTrue(shortcut.option)
|
||||
XCTAssertFalse(shortcut.shift)
|
||||
XCTAssertFalse(shortcut.control)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
|
||||
func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
|
||||
let panel = BrowserPanel(workspaceId: UUID())
|
||||
let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool
|
||||
XCTAssertEqual(developerExtras, true)
|
||||
|
||||
if #available(macOS 13.3, *) {
|
||||
XCTAssertTrue(panel.webView.isInspectable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
||||
private final class FakeInspector: NSObject {
|
||||
private(set) var showCount = 0
|
||||
private(set) var closeCount = 0
|
||||
private var visible = false
|
||||
|
||||
@objc func isVisible() -> Bool {
|
||||
visible
|
||||
}
|
||||
|
||||
@objc func show() {
|
||||
showCount += 1
|
||||
visible = true
|
||||
}
|
||||
|
||||
@objc func close() {
|
||||
closeCount += 1
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
|
||||
override class func setUp() {
|
||||
super.setUp()
|
||||
installCmuxUnitTestInspectorOverride()
|
||||
}
|
||||
|
||||
private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) {
|
||||
let panel = BrowserPanel(workspaceId: UUID())
|
||||
let inspector = FakeInspector()
|
||||
panel.webView.cmuxSetUnitTestInspector(inspector)
|
||||
return (panel, inspector)
|
||||
}
|
||||
|
||||
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
|
||||
let (panel, inspector) = makePanelWithInspector()
|
||||
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
|
||||
// Simulate WebKit closing inspector during detach/reattach churn.
|
||||
inspector.close()
|
||||
XCTAssertFalse(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.closeCount, 1)
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.showCount, 2)
|
||||
}
|
||||
|
||||
func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() {
|
||||
let (panel, inspector) = makePanelWithInspector()
|
||||
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
|
||||
// Simulate user closing inspector before detach.
|
||||
inspector.close()
|
||||
panel.syncDeveloperToolsPreferenceFromInspector()
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
XCTAssertFalse(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
}
|
||||
|
||||
func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
|
||||
let (panel, inspector) = makePanelWithInspector()
|
||||
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
|
||||
// Simulate a transient close caused by view detach, not user intent.
|
||||
inspector.close()
|
||||
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.showCount, 2)
|
||||
}
|
||||
|
||||
func testForcedRefreshAfterAttachKeepsVisibleInspectorState() {
|
||||
let (panel, inspector) = makePanelWithInspector()
|
||||
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
XCTAssertEqual(inspector.closeCount, 0)
|
||||
|
||||
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
||||
XCTAssertEqual(inspector.closeCount, 0)
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
|
||||
// The force-refresh request should be one-shot.
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
XCTAssertEqual(inspector.closeCount, 0)
|
||||
XCTAssertEqual(inspector.showCount, 1)
|
||||
}
|
||||
|
||||
func testRefreshRequestTracksPendingStateUntilRestoreRuns() {
|
||||
let (panel, _) = makePanelWithInspector()
|
||||
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
|
||||
|
||||
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
|
||||
XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach())
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
|
||||
}
|
||||
|
||||
func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
|
||||
let (panel, _) = makePanelWithInspector()
|
||||
|
||||
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
||||
XCTAssertTrue(panel.hideDeveloperTools())
|
||||
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
||||
}
|
||||
|
||||
func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() {
|
||||
let (panel, _) = makePanelWithInspector()
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
|
||||
let representable = WebViewRepresentable(
|
||||
panel: panel,
|
||||
shouldAttachWebView: true,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0
|
||||
)
|
||||
let coordinator = representable.makeCoordinator()
|
||||
coordinator.webView = panel.webView
|
||||
let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100))
|
||||
host.addSubview(panel.webView)
|
||||
|
||||
WebViewRepresentable.dismantleNSView(host, coordinator: coordinator)
|
||||
|
||||
XCTAssertTrue(panel.webView.superview === host)
|
||||
}
|
||||
|
||||
func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() {
|
||||
let (panel, _) = makePanelWithInspector()
|
||||
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
||||
|
||||
let representable = WebViewRepresentable(
|
||||
panel: panel,
|
||||
shouldAttachWebView: true,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0
|
||||
)
|
||||
let coordinator = representable.makeCoordinator()
|
||||
coordinator.webView = panel.webView
|
||||
let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100))
|
||||
host.addSubview(panel.webView)
|
||||
|
||||
WebViewRepresentable.dismantleNSView(host, coordinator: coordinator)
|
||||
|
||||
XCTAssertNil(panel.webView.superview)
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceShortcutMapperTests: XCTestCase {
|
||||
func testCommandNineMapsToLastWorkspaceIndex() {
|
||||
XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0)
|
||||
|
|
@ -339,6 +635,43 @@ final class WorkspacePlacementSettingsTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class WorkspaceAutoReorderSettingsTests: XCTestCase {
|
||||
func testDefaultIsEnabled() {
|
||||
let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testDisabledWhenSetToFalse() {
|
||||
let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(false, forKey: WorkspaceAutoReorderSettings.key)
|
||||
XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testEnabledWhenSetToTrue() {
|
||||
let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
|
||||
XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
final class AppearanceSettingsTests: XCTestCase {
|
||||
func testResolvedModeDefaultsToSystemWhenUnset() {
|
||||
let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)"
|
||||
|
|
@ -354,62 +687,56 @@ final class AppearanceSettingsTests: XCTestCase {
|
|||
XCTAssertEqual(resolved, .system)
|
||||
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
|
||||
}
|
||||
|
||||
func testResolvedModeMigratesLegacyAndInvalidValuesToSystem() {
|
||||
let suiteName = "AppearanceSettingsTests.Migrate.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(AppearanceMode.auto.rawValue, forKey: AppearanceSettings.appearanceModeKey)
|
||||
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system)
|
||||
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
|
||||
|
||||
defaults.set("invalid-value", forKey: AppearanceSettings.appearanceModeKey)
|
||||
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system)
|
||||
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
|
||||
}
|
||||
|
||||
func testResolvedModePreservesExplicitLightAndDark() {
|
||||
let suiteName = "AppearanceSettingsTests.Preserve.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(AppearanceMode.light.rawValue, forKey: AppearanceSettings.appearanceModeKey)
|
||||
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .light)
|
||||
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.light.rawValue)
|
||||
|
||||
defaults.set(AppearanceMode.dark.rawValue, forKey: AppearanceSettings.appearanceModeKey)
|
||||
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .dark)
|
||||
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.dark.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
final class UpdateFeedResolverTests: XCTestCase {
|
||||
final class UpdateChannelSettingsTests: XCTestCase {
|
||||
func testDefaultNightlyPreferenceIsDisabled() {
|
||||
XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds)
|
||||
}
|
||||
|
||||
func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() {
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
|
||||
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
|
||||
let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults)
|
||||
XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL)
|
||||
XCTAssertFalse(resolved.isNightly)
|
||||
XCTAssertTrue(resolved.usedFallback)
|
||||
}
|
||||
|
||||
func testResolvedFeedUsesInfoFeedForStableChannel() {
|
||||
let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let infoFeed = "https://example.com/custom/appcast.xml"
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults)
|
||||
XCTAssertEqual(resolved.url, infoFeed)
|
||||
XCTAssertFalse(resolved.isNightly)
|
||||
XCTAssertFalse(resolved.usedFallback)
|
||||
}
|
||||
|
||||
func testResolvedFeedDetectsNightlyChannelFromInfoFeed() {
|
||||
let infoFeed = "https://example.com/nightly/appcast.xml"
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
|
||||
XCTAssertEqual(resolved.url, infoFeed)
|
||||
func testResolvedFeedUsesNightlyWhenPreferenceEnabled() {
|
||||
let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey)
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(
|
||||
infoFeedURL: "https://example.com/custom/appcast.xml",
|
||||
defaults: defaults
|
||||
)
|
||||
XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL)
|
||||
XCTAssertTrue(resolved.isNightly)
|
||||
XCTAssertFalse(resolved.usedFallback)
|
||||
}
|
||||
|
|
@ -753,54 +1080,6 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class SidebarOutsideDropResetPolicyTests: XCTestCase {
|
||||
func testOutsideDropResetsOnlyWhenDragIsActiveAndPayloadMatches() {
|
||||
let tabId = UUID()
|
||||
|
||||
XCTAssertTrue(
|
||||
SidebarOutsideDropResetPolicy.shouldResetDrag(
|
||||
draggedTabId: tabId,
|
||||
hasSidebarDragPayload: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarOutsideDropResetPolicy.shouldResetDrag(
|
||||
draggedTabId: nil,
|
||||
hasSidebarDragPayload: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarOutsideDropResetPolicy.shouldResetDrag(
|
||||
draggedTabId: tabId,
|
||||
hasSidebarDragPayload: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarDragFailsafePolicyTests: XCTestCase {
|
||||
func testRequestsClearOnlyWhenDragIsActiveAndMouseIsUp() {
|
||||
XCTAssertTrue(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: true,
|
||||
isLeftMouseButtonDown: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: true,
|
||||
isLeftMouseButtonDown: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: false,
|
||||
isLeftMouseButtonDown: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarDragAutoScrollPlannerTests: XCTestCase {
|
||||
func testAutoScrollPlanTriggersNearTopAndBottomOnly() {
|
||||
let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12)
|
||||
|
|
@ -2033,22 +2312,6 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
state = hostedView.debugInactiveOverlayState()
|
||||
XCTAssertTrue(state.isHidden)
|
||||
}
|
||||
|
||||
func testUnreadNotificationRingVisibilityTracksRequestedState() {
|
||||
let hostedView = GhosttySurfaceScrollView(
|
||||
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))
|
||||
)
|
||||
|
||||
hostedView.setNotificationRing(visible: true)
|
||||
var state = hostedView.debugNotificationRingState()
|
||||
XCTAssertFalse(state.isHidden)
|
||||
XCTAssertEqual(state.opacity, 1, accuracy: 0.001)
|
||||
|
||||
hostedView.setNotificationRing(visible: false)
|
||||
state = hostedView.debugNotificationRingState()
|
||||
XCTAssertTrue(state.isHidden)
|
||||
XCTAssertEqual(state.opacity, 0, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -2244,6 +2507,222 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
||||
private func realizeWindowLayout(_ window: NSWindow) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
func testPortalHostInstallsAboveContentViewForVisibility() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
_ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
|
||||
|
||||
guard let contentView = window.contentView,
|
||||
let container = contentView.superview else {
|
||||
XCTFail("Expected content container")
|
||||
return
|
||||
}
|
||||
|
||||
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
|
||||
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
|
||||
XCTFail("Expected host/content views in same container")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertGreaterThan(
|
||||
hostIndex,
|
||||
contentIndex,
|
||||
"Browser portal host must remain above content view so portal-hosted web views stay visible"
|
||||
)
|
||||
}
|
||||
|
||||
func testAnchorRebindKeepsWebViewInStablePortalSuperview() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
|
||||
let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120))
|
||||
contentView.addSubview(anchor1)
|
||||
contentView.addSubview(anchor2)
|
||||
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
portal.bind(webView: webView, to: anchor1, visibleInUI: true)
|
||||
let firstSuperview = webView.superview
|
||||
|
||||
XCTAssertNotNil(firstSuperview)
|
||||
XCTAssertTrue(firstSuperview is WindowBrowserSlotView)
|
||||
|
||||
portal.bind(webView: webView, to: anchor2, visibleInUI: true)
|
||||
XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view")
|
||||
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
portal.synchronizeWebViewForAnchor(anchor2)
|
||||
guard let slot = webView.superview as? WindowBrowserSlotView,
|
||||
let host = slot.superview as? WindowBrowserHostView else {
|
||||
XCTFail("Expected browser slot + host views")
|
||||
return
|
||||
}
|
||||
let expectedFrame = host.convert(anchor2.bounds, from: anchor2)
|
||||
XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5)
|
||||
XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5)
|
||||
XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5)
|
||||
XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate a transient oversized anchor rect during split churn.
|
||||
let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150))
|
||||
contentView.addSubview(anchor)
|
||||
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
|
||||
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
||||
XCTFail("Expected web view slot")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible")
|
||||
XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5)
|
||||
XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5)
|
||||
XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5)
|
||||
XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testPortalSyncNormalizesOutOfBoundsWebFrame() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160))
|
||||
contentView.addSubview(anchor)
|
||||
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
|
||||
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
||||
XCTFail("Expected browser slot")
|
||||
return
|
||||
}
|
||||
|
||||
// Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds.
|
||||
webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height)
|
||||
XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY)
|
||||
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5)
|
||||
XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5)
|
||||
XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5)
|
||||
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
|
||||
contentView.addSubview(anchor)
|
||||
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
|
||||
guard let slot = webView.superview as? WindowBrowserSlotView,
|
||||
let host = slot.superview as? WindowBrowserHostView else {
|
||||
XCTFail("Expected portal slot + host views")
|
||||
return
|
||||
}
|
||||
XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync")
|
||||
XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync")
|
||||
}
|
||||
|
||||
func testRegistryDetachRemovesPortalHostedWebView() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
|
||||
contentView.addSubview(anchor)
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
|
||||
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
|
||||
XCTAssertNotNil(webView.superview)
|
||||
|
||||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
XCTAssertNil(webView.superview)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserLinkOpenSettingsTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
import AppKit
|
||||
@testable import cmux_DEV
|
||||
|
||||
/// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths.
|
||||
/// This prevents accidentally hiding the update UI in Release builds.
|
||||
|
|
@ -64,3 +66,133 @@ final class UpdatePillReleaseVisibilityTests: XCTestCase {
|
|||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me).
|
||||
final class AppTransportSecurityTests: XCTestCase {
|
||||
func testInfoPlistAllowsArbitraryLoadsInWebContent() throws {
|
||||
let projectRoot = findProjectRoot()
|
||||
let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist")
|
||||
let data = try Data(contentsOf: infoPlistURL)
|
||||
var format = PropertyListSerialization.PropertyListFormat.xml
|
||||
let plist = try XCTUnwrap(
|
||||
PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any]
|
||||
)
|
||||
let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any])
|
||||
XCTAssertEqual(
|
||||
ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool,
|
||||
true,
|
||||
"Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames."
|
||||
)
|
||||
}
|
||||
|
||||
private func findProjectRoot() -> URL {
|
||||
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
|
||||
if FileManager.default.fileExists(atPath: marker.path) {
|
||||
return dir
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserInsecureHTTPSettingsTests: XCTestCase {
|
||||
func testDefaultAllowlistPatternsArePresent() {
|
||||
XCTAssertEqual(
|
||||
BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: nil),
|
||||
["127.0.0.1", "localhost", "*.localtest.me"]
|
||||
)
|
||||
}
|
||||
|
||||
func testWildcardAndExactHostMatching() {
|
||||
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("localhost", rawAllowlist: nil))
|
||||
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("api.localtest.me", rawAllowlist: nil))
|
||||
XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("neverssl.com", rawAllowlist: nil))
|
||||
}
|
||||
|
||||
func testCustomAllowlistNormalizesAndDeduplicatesEntries() {
|
||||
let raw = """
|
||||
localhost
|
||||
*.example.com
|
||||
127.0.0.1
|
||||
https://dev.internal:8080/path
|
||||
*.example.com
|
||||
"""
|
||||
|
||||
XCTAssertEqual(
|
||||
BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: raw),
|
||||
["localhost", "*.example.com", "127.0.0.1", "dev.internal"]
|
||||
)
|
||||
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("foo.example.com", rawAllowlist: raw))
|
||||
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("dev.internal", rawAllowlist: raw))
|
||||
XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw))
|
||||
}
|
||||
|
||||
func testBlockDecisionUsesAllowlistAndSchemeRules() throws {
|
||||
let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000"))
|
||||
XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil))
|
||||
|
||||
let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com"))
|
||||
XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil))
|
||||
|
||||
let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com"))
|
||||
XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil))
|
||||
}
|
||||
|
||||
func testOneTimeBypassIsConsumedAfterFirstNavigation() throws {
|
||||
let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com"))
|
||||
var bypassHostOnce: String? = "neverssl.com"
|
||||
|
||||
XCTAssertTrue(browserShouldConsumeOneTimeInsecureHTTPBypass(
|
||||
insecureURL,
|
||||
bypassHostOnce: &bypassHostOnce
|
||||
))
|
||||
XCTAssertNil(bypassHostOnce)
|
||||
|
||||
// Subsequent visits should prompt again unless host was saved.
|
||||
XCTAssertFalse(browserShouldConsumeOneTimeInsecureHTTPBypass(
|
||||
insecureURL,
|
||||
bypassHostOnce: &bypassHostOnce
|
||||
))
|
||||
XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil))
|
||||
}
|
||||
|
||||
func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws {
|
||||
let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let url = try XCTUnwrap(URL(string: "http://persist-me.test"))
|
||||
XCTAssertTrue(browserShouldBlockInsecureHTTPURL(url, defaults: defaults))
|
||||
|
||||
BrowserInsecureHTTPSettings.addAllowedHost("persist-me.test", defaults: defaults)
|
||||
let persisted = defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
|
||||
XCTAssertNotNil(persisted)
|
||||
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("persist-me.test", defaults: defaults))
|
||||
XCTAssertFalse(browserShouldBlockInsecureHTTPURL(url, defaults: defaults))
|
||||
}
|
||||
|
||||
func testAllowlistSelectionPersistsForProceedAndOpenExternal() {
|
||||
XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection(
|
||||
response: .alertFirstButtonReturn,
|
||||
suppressionEnabled: true
|
||||
))
|
||||
XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection(
|
||||
response: .alertSecondButtonReturn,
|
||||
suppressionEnabled: true
|
||||
))
|
||||
XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection(
|
||||
response: .alertThirdButtonReturn,
|
||||
suppressionEnabled: true
|
||||
))
|
||||
XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection(
|
||||
response: .alertSecondButtonReturn,
|
||||
suppressionEnabled: false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,21 @@ CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}"
|
|||
CACHE_DIR="$CACHE_ROOT/$GHOSTTY_SHA"
|
||||
CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework"
|
||||
LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework"
|
||||
LOCAL_SHA_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_sha"
|
||||
LOCK_DIR="$CACHE_ROOT/$GHOSTTY_SHA.lock"
|
||||
|
||||
mkdir -p "$CACHE_ROOT"
|
||||
|
||||
echo "==> Ghostty submodule commit: $GHOSTTY_SHA"
|
||||
|
||||
LOCK_TIMEOUT=300
|
||||
LOCK_START=$SECONDS
|
||||
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
|
||||
if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
|
||||
echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
|
||||
rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
|
||||
continue
|
||||
fi
|
||||
echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_SHA..."
|
||||
sleep 1
|
||||
done
|
||||
|
|
@ -36,25 +44,34 @@ trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT
|
|||
if [ -d "$CACHE_XCFRAMEWORK" ]; then
|
||||
echo "==> Reusing cached GhosttyKit.xcframework"
|
||||
else
|
||||
if [ -d "$LOCAL_XCFRAMEWORK" ]; then
|
||||
echo "==> Seeding cache from existing local GhosttyKit.xcframework"
|
||||
# Only reuse local xcframework if its SHA stamp matches the current ghostty commit.
|
||||
# Without this check, a stale build from a previous commit could be cached under
|
||||
# the wrong SHA, producing ABI mismatches.
|
||||
LOCAL_SHA=""
|
||||
if [ -f "$LOCAL_SHA_STAMP" ]; then
|
||||
LOCAL_SHA="$(cat "$LOCAL_SHA_STAMP")"
|
||||
fi
|
||||
|
||||
if [ -d "$LOCAL_XCFRAMEWORK" ] && [ "$LOCAL_SHA" = "$GHOSTTY_SHA" ]; then
|
||||
echo "==> Seeding cache from existing local GhosttyKit.xcframework (SHA matches)"
|
||||
else
|
||||
echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..."
|
||||
(
|
||||
cd ghostty
|
||||
zig build -Demit-xcframework=true -Doptimize=ReleaseFast
|
||||
)
|
||||
# Stamp the build output with the SHA it was built from
|
||||
echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP"
|
||||
fi
|
||||
|
||||
SRC_XCFRAMEWORK="$LOCAL_XCFRAMEWORK"
|
||||
if [ ! -d "$SRC_XCFRAMEWORK" ]; then
|
||||
echo "Error: GhosttyKit.xcframework not found at $SRC_XCFRAMEWORK"
|
||||
if [ ! -d "$LOCAL_XCFRAMEWORK" ]; then
|
||||
echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
cp -R "$SRC_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework"
|
||||
cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework"
|
||||
rm -rf "$CACHE_XCFRAMEWORK"
|
||||
mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK"
|
||||
rmdir "$TMP_DIR"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ Keep this workflow focused on existing debug windows and menu entries. Do not ad
|
|||
## Workflow
|
||||
|
||||
1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`.
|
||||
- Menu path in app: `Debug` → `Debug Windows` → window entry.
|
||||
- The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`).
|
||||
- Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu.
|
||||
2. Keep these actions available in `Menu("Debug Windows")`:
|
||||
- `Sidebar Debug…`
|
||||
- `Background Debug…`
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ usage() {
|
|||
cat <<'USAGE'
|
||||
Usage: debug_windows_snapshot.sh [--domain <defaults-domain>] [--copy]
|
||||
|
||||
Collect Sidebar Debug, Background Debug, and Menu Bar Extra debug values from macOS defaults
|
||||
and print a combined payload. Use --copy to also copy the payload to clipboard.
|
||||
Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values
|
||||
from macOS defaults and print a combined payload. Use --copy to also copy the payload.
|
||||
|
||||
Examples:
|
||||
debug_windows_snapshot.sh
|
||||
|
|
@ -118,13 +118,16 @@ menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingle
|
|||
menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)"
|
||||
legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')"
|
||||
if [[ -n "$legacySingleDigitX" ]]; then
|
||||
menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)"
|
||||
menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)"
|
||||
else
|
||||
menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)"
|
||||
fi
|
||||
menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)"
|
||||
menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)"
|
||||
|
||||
browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')"
|
||||
browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)"
|
||||
|
||||
payload="$(cat <<PAYLOAD
|
||||
# Defaults domain
|
||||
$domain
|
||||
|
|
@ -166,6 +169,10 @@ menubarDebugMultiDigitYOffset=$menubarDebugMultiDigitYOffset
|
|||
menubarDebugSingleDigitXAdjust=$menubarDebugSingleDigitXAdjust
|
||||
menubarDebugMultiDigitXAdjust=$menubarDebugMultiDigitXAdjust
|
||||
menubarDebugTextRectWidthAdjust=$menubarDebugTextRectWidthAdjust
|
||||
|
||||
# Browser DevTools Button
|
||||
browserDevToolsIconName=$browserDevToolsIconName
|
||||
browserDevToolsIconColor=$browserDevToolsIconColor
|
||||
PAYLOAD
|
||||
)"
|
||||
|
||||
|
|
|
|||
110
tests_v2/cmux.py
110
tests_v2/cmux.py
|
|
@ -398,12 +398,43 @@ class cmux:
|
|||
wsid = self._resolve_workspace_id(workspace)
|
||||
self._call("workspace.select", {"workspace_id": wsid})
|
||||
|
||||
def rename_workspace(self, title: str, workspace: Union[str, int, None] = None) -> None:
|
||||
renamed = str(title).strip()
|
||||
if not renamed:
|
||||
raise cmuxError("rename_workspace requires a non-empty title")
|
||||
wsid = self._resolve_workspace_id(workspace)
|
||||
params: Dict[str, Any] = {"title": renamed}
|
||||
if wsid:
|
||||
params["workspace_id"] = wsid
|
||||
self._call("workspace.rename", params)
|
||||
|
||||
def current_workspace(self) -> str:
|
||||
wsid = self._resolve_workspace_id(None)
|
||||
if not wsid:
|
||||
raise cmuxError("No current workspace")
|
||||
return wsid
|
||||
|
||||
def next_workspace(self) -> str:
|
||||
res = self._call("workspace.next") or {}
|
||||
wsid = res.get("workspace_id")
|
||||
if not wsid:
|
||||
raise cmuxError(f"workspace.next returned no workspace_id: {res}")
|
||||
return str(wsid)
|
||||
|
||||
def previous_workspace(self) -> str:
|
||||
res = self._call("workspace.previous") or {}
|
||||
wsid = res.get("workspace_id")
|
||||
if not wsid:
|
||||
raise cmuxError(f"workspace.previous returned no workspace_id: {res}")
|
||||
return str(wsid)
|
||||
|
||||
def last_workspace(self) -> str:
|
||||
res = self._call("workspace.last") or {}
|
||||
wsid = res.get("workspace_id")
|
||||
if not wsid:
|
||||
raise cmuxError(f"workspace.last returned no workspace_id: {res}")
|
||||
return str(wsid)
|
||||
|
||||
def move_workspace_to_window(self, workspace: Union[str, int], window_id: str, focus: bool = True) -> None:
|
||||
wsid = self._resolve_workspace_id(workspace)
|
||||
self._call(
|
||||
|
|
@ -639,6 +670,18 @@ class cmux:
|
|||
res = self._call("surface.health", params) or {}
|
||||
return list(res.get("surfaces") or [])
|
||||
|
||||
def clear_history(self, surface: Union[str, int, None] = None, workspace: Union[str, int, None] = None) -> None:
|
||||
params: Dict[str, Any] = {}
|
||||
if workspace is not None:
|
||||
wsid = self._resolve_workspace_id(workspace)
|
||||
params["workspace_id"] = wsid
|
||||
if surface is not None:
|
||||
sid = self._resolve_surface_id(surface, workspace_id=params.get("workspace_id"))
|
||||
if not sid:
|
||||
raise cmuxError(f"Invalid surface: {surface!r}")
|
||||
params["surface_id"] = sid
|
||||
self._call("surface.clear_history", params)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Pane commands
|
||||
# ---------------------------------------------------------------------
|
||||
|
|
@ -677,6 +720,61 @@ class cmux:
|
|||
))
|
||||
return out
|
||||
|
||||
def swap_pane(self, pane: Union[str, int], target_pane: Union[str, int], focus: bool = True) -> None:
|
||||
source = self._resolve_pane_id(pane)
|
||||
target = self._resolve_pane_id(target_pane)
|
||||
if not source or not target:
|
||||
raise cmuxError(f"Invalid panes: pane={pane!r}, target_pane={target_pane!r}")
|
||||
self._call("pane.swap", {"pane_id": source, "target_pane_id": target, "focus": bool(focus)})
|
||||
|
||||
def break_pane(self, pane: Union[str, int, None] = None, surface: Union[str, int, None] = None, focus: bool = True) -> str:
|
||||
params: Dict[str, Any] = {"focus": bool(focus)}
|
||||
if pane is not None:
|
||||
pid = self._resolve_pane_id(pane)
|
||||
if not pid:
|
||||
raise cmuxError(f"Invalid pane: {pane!r}")
|
||||
params["pane_id"] = pid
|
||||
if surface is not None:
|
||||
sid = self._resolve_surface_id(surface)
|
||||
if not sid:
|
||||
raise cmuxError(f"Invalid surface: {surface!r}")
|
||||
params["surface_id"] = sid
|
||||
res = self._call("pane.break", params) or {}
|
||||
wsid = res.get("workspace_id")
|
||||
if not wsid:
|
||||
raise cmuxError(f"pane.break returned no workspace_id: {res}")
|
||||
return str(wsid)
|
||||
|
||||
def join_pane(
|
||||
self,
|
||||
target_pane: Union[str, int],
|
||||
pane: Union[str, int, None] = None,
|
||||
surface: Union[str, int, None] = None,
|
||||
focus: bool = True,
|
||||
) -> None:
|
||||
target = self._resolve_pane_id(target_pane)
|
||||
if not target:
|
||||
raise cmuxError(f"Invalid target_pane: {target_pane!r}")
|
||||
params: Dict[str, Any] = {"target_pane_id": target, "focus": bool(focus)}
|
||||
if pane is not None:
|
||||
source = self._resolve_pane_id(pane)
|
||||
if not source:
|
||||
raise cmuxError(f"Invalid pane: {pane!r}")
|
||||
params["pane_id"] = source
|
||||
if surface is not None:
|
||||
sid = self._resolve_surface_id(surface)
|
||||
if not sid:
|
||||
raise cmuxError(f"Invalid surface: {surface!r}")
|
||||
params["surface_id"] = sid
|
||||
self._call("pane.join", params)
|
||||
|
||||
def last_pane(self) -> str:
|
||||
res = self._call("pane.last") or {}
|
||||
pid = res.get("pane_id")
|
||||
if not pid:
|
||||
raise cmuxError(f"pane.last returned no pane_id: {res}")
|
||||
return str(pid)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Input
|
||||
# ---------------------------------------------------------------------
|
||||
|
|
@ -830,6 +928,18 @@ class cmux:
|
|||
if panel is not None:
|
||||
sid = self._resolve_surface_id(panel)
|
||||
params["surface_id"] = sid
|
||||
try:
|
||||
res = self._call("surface.read_text", params) or {}
|
||||
if "text" in res:
|
||||
return str(res.get("text") or "")
|
||||
b64 = str(res.get("base64") or "")
|
||||
raw = base64.b64decode(b64) if b64 else b""
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
except cmuxError as exc:
|
||||
# Back-compat for older builds that only expose the debug method.
|
||||
if "method_not_found" not in str(exc):
|
||||
raise
|
||||
|
||||
res = self._call("debug.terminal.read_text", params) or {}
|
||||
b64 = str(res.get("base64") or "")
|
||||
raw = base64.b64decode(b64) if b64 else b""
|
||||
|
|
|
|||
136
tests_v2/test_read_screen_capture_pane_parity.py
Normal file
136
tests_v2/test_read_screen_capture_pane_parity.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: capture-pane parity via production read-screen APIs."""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, List
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str]) -> str:
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
caps = c.capabilities() or {}
|
||||
methods = set(caps.get("methods") or [])
|
||||
_must("surface.read_text" in methods, f"Missing surface.read_text in capabilities: {sorted(methods)[:20]}")
|
||||
|
||||
created_target = c._call("workspace.create") or {}
|
||||
ws_target = str(created_target.get("workspace_id") or "")
|
||||
_must(bool(ws_target), f"workspace.create returned no workspace_id: {created_target}")
|
||||
c._call("workspace.select", {"workspace_id": ws_target})
|
||||
|
||||
surfaces_payload = c._call("surface.list", {"workspace_id": ws_target}) or {}
|
||||
surfaces = surfaces_payload.get("surfaces") or []
|
||||
_must(bool(surfaces), f"Expected at least one surface in workspace: {surfaces_payload}")
|
||||
surface_target = str(surfaces[0].get("id") or "")
|
||||
_must(bool(surface_target), f"surface.list returned surface without id: {surfaces_payload}")
|
||||
|
||||
created_other = c._call("workspace.create") or {}
|
||||
ws_other = str(created_other.get("workspace_id") or "")
|
||||
_must(bool(ws_other), f"workspace.create returned no workspace_id: {created_other}")
|
||||
c._call("workspace.select", {"workspace_id": ws_other})
|
||||
|
||||
selected = c._call("workspace.current") or {}
|
||||
_must(str(selected.get("workspace_id") or "") == ws_other, f"Expected selected workspace {ws_other}, got: {selected}")
|
||||
|
||||
token = f"CMUX_READ_SCREEN_{int(time.time() * 1000)}"
|
||||
c._call("surface.send_text", {
|
||||
"workspace_id": ws_target,
|
||||
"surface_id": surface_target,
|
||||
"text": f"echo {token}\n",
|
||||
})
|
||||
|
||||
def has_token() -> bool:
|
||||
payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {}
|
||||
return token in str(payload.get("text") or "")
|
||||
|
||||
_wait_for(has_token, timeout_s=5.0)
|
||||
|
||||
read_payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {}
|
||||
text = str(read_payload.get("text") or "")
|
||||
_must(token in text, f"surface.read_text missing token {token!r}: {read_payload}")
|
||||
|
||||
ws_only_payload = c._call("surface.read_text", {"workspace_id": ws_target}) or {}
|
||||
_must(token in str(ws_only_payload.get("text") or ""), f"surface.read_text workspace-only call missing token {token!r}: {ws_only_payload}")
|
||||
|
||||
cli_text = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target])
|
||||
_must(token in cli_text, f"cmux read-screen output missing token {token!r}: {cli_text!r}")
|
||||
|
||||
cli_ws_only = _run_cli(cli, ["read-screen", "--workspace", ws_target])
|
||||
_must(token in cli_ws_only, f"cmux read-screen --workspace output missing token {token!r}: {cli_ws_only!r}")
|
||||
|
||||
cli_text_scrollback = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target, "--scrollback", "--lines", "80"])
|
||||
_must(token in cli_text_scrollback, f"cmux read-screen --scrollback output missing token {token!r}: {cli_text_scrollback!r}")
|
||||
|
||||
cli_json = _run_cli(cli, ["--json", "read-screen", "--workspace", ws_target, "--surface", surface_target])
|
||||
payload = json.loads(cli_json or "{}")
|
||||
_must(token in str(payload.get("text") or ""), f"cmux --json read-screen missing token {token!r}: {payload}")
|
||||
|
||||
invalid = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "read-screen", "--workspace", ws_target, "--surface", surface_target, "--lines", "0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
|
||||
_must(invalid.returncode != 0, "Expected read-screen --lines 0 to fail")
|
||||
_must("--lines must be greater than 0" in invalid_output, f"Unexpected error for --lines 0: {invalid_output!r}")
|
||||
|
||||
print("PASS: production read-screen APIs expose capture-pane behavior")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
118
tests_v2/test_rename_window_workspace_parity.py
Normal file
118
tests_v2/test_rename_window_workspace_parity.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: tmux rename-window parity via workspace.rename + CLI aliases."""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str]) -> str:
|
||||
env = dict(os.environ)
|
||||
# Keep this test deterministic when running from inside another cmux shell.
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _workspace_title(c: cmux, workspace_id: str) -> str:
|
||||
payload = c._call("workspace.list") or {}
|
||||
for row in payload.get("workspaces") or []:
|
||||
if str(row.get("id") or "") == workspace_id:
|
||||
return str(row.get("title") or "")
|
||||
raise cmuxError(f"workspace.list missing workspace {workspace_id}: {payload}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
stamp = int(time.time() * 1000)
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
caps = c.capabilities() or {}
|
||||
methods = set(caps.get("methods") or [])
|
||||
_must("workspace.rename" in methods, f"Missing workspace.rename in capabilities: {sorted(methods)[:30]}")
|
||||
|
||||
created = c._call("workspace.create") or {}
|
||||
ws_id = str(created.get("workspace_id") or "")
|
||||
_must(bool(ws_id), f"workspace.create returned no workspace_id: {created}")
|
||||
c._call("workspace.select", {"workspace_id": ws_id})
|
||||
|
||||
api_title = f"tmux-api-{stamp}"
|
||||
c.rename_workspace(api_title, workspace=ws_id)
|
||||
_must(_workspace_title(c, ws_id) == api_title, "workspace.rename API did not update workspace title")
|
||||
|
||||
cli_title = f"tmux cli {stamp}"
|
||||
_run_cli(cli, ["rename-workspace", "--workspace", ws_id, cli_title])
|
||||
_must(_workspace_title(c, ws_id) == cli_title, "cmux rename-workspace did not update workspace title")
|
||||
|
||||
alias_title = f"tmux alias {stamp}"
|
||||
_run_cli(cli, ["rename-window", "--workspace", ws_id, alias_title])
|
||||
_must(_workspace_title(c, ws_id) == alias_title, "cmux rename-window did not update workspace title")
|
||||
|
||||
current_title = f"tmux current {stamp}"
|
||||
_run_cli(cli, ["rename-window", current_title])
|
||||
_must(
|
||||
_workspace_title(c, ws_id) == current_title,
|
||||
"cmux rename-window without --workspace should target current workspace",
|
||||
)
|
||||
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
invalid = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "rename-window", "--workspace", ws_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
|
||||
_must(invalid.returncode != 0, "Expected rename-window without title to fail")
|
||||
_must(
|
||||
"rename-window requires a title" in invalid_output,
|
||||
f"Unexpected error for rename-window without title: {invalid_output!r}",
|
||||
)
|
||||
|
||||
print("PASS: tmux rename-window parity works via workspace.rename and CLI aliases")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
232
tests_v2/test_tmux_compat_matrix.py
Normal file
232
tests_v2/test_tmux_compat_matrix.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: tmux compatibility command matrix (implemented + explicit not-supported)."""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Tuple
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str], *, expect_ok: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if expect_ok and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _pane_selected_surface(c: cmux, pane_id: str) -> str:
|
||||
rows = c.list_pane_surfaces(pane_id)
|
||||
for _idx, sid, _title, selected in rows:
|
||||
if selected:
|
||||
return sid
|
||||
if rows:
|
||||
return rows[0][1]
|
||||
raise cmuxError(f"pane {pane_id} has no surfaces")
|
||||
|
||||
|
||||
def _pane_surface_ids(c: cmux, pane_id: str) -> List[str]:
|
||||
rows = c.list_pane_surfaces(pane_id)
|
||||
return [sid for _idx, sid, _title, _selected in rows]
|
||||
|
||||
|
||||
def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
|
||||
payload = c._call("surface.read_text", {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}) or {}
|
||||
return token in str(payload.get("text") or "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
stamp = int(time.time() * 1000)
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
caps = c.capabilities() or {}
|
||||
methods = set(caps.get("methods") or [])
|
||||
for method in [
|
||||
"workspace.next",
|
||||
"workspace.previous",
|
||||
"workspace.last",
|
||||
"pane.swap",
|
||||
"pane.break",
|
||||
"pane.join",
|
||||
"pane.last",
|
||||
"surface.clear_history",
|
||||
]:
|
||||
_must(method in methods, f"Missing capability {method!r}")
|
||||
|
||||
ws = c.new_workspace()
|
||||
c.select_workspace(ws)
|
||||
_ = c.new_split("right")
|
||||
time.sleep(0.2)
|
||||
|
||||
panes = [pid for _pidx, pid, _count, _focused in c.list_panes()]
|
||||
_must(len(panes) >= 2, f"Expected >=2 panes, got {panes}")
|
||||
p1, p2 = panes[0], panes[1]
|
||||
|
||||
s1 = _pane_selected_surface(c, p1)
|
||||
s2 = _pane_selected_surface(c, p2)
|
||||
|
||||
capture_token = f"TMUX_CAPTURE_{stamp}"
|
||||
c.send_surface(s1, f"echo {capture_token}\n")
|
||||
_wait_for(lambda: _surface_has(c, ws, s1, capture_token))
|
||||
|
||||
cap = _run_cli(cli, ["capture-pane", "--workspace", ws, "--surface", s1, "--scrollback"])
|
||||
_must(capture_token in cap.stdout, f"capture-pane missing token: {cap.stdout!r}")
|
||||
|
||||
pipe_file = Path(tempfile.gettempdir()) / f"cmux_pipe_pane_{stamp}.log"
|
||||
_run_cli(cli, ["pipe-pane", "--workspace", ws, "--surface", s1, "--command", f"cat > {pipe_file}"])
|
||||
piped = pipe_file.read_text() if pipe_file.exists() else ""
|
||||
_must(capture_token in piped, f"pipe-pane output missing token: {piped!r}")
|
||||
|
||||
wait_name = f"tmux_wait_{stamp}"
|
||||
waiter = _run_cli(cli, ["wait-for", wait_name, "--timeout", "5"], expect_ok=False)
|
||||
_must(waiter.returncode != 0, "wait-for without signal should time out when run synchronously in test")
|
||||
signaler = subprocess.Popen(
|
||||
[cli, "--socket", SOCKET_PATH, "wait-for", wait_name, "--timeout", "5"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID"}},
|
||||
)
|
||||
time.sleep(0.2)
|
||||
_run_cli(cli, ["wait-for", "-S", wait_name])
|
||||
out, err = signaler.communicate(timeout=5)
|
||||
_must(signaler.returncode == 0, f"wait-for signal/wait failed: out={out!r} err={err!r}")
|
||||
|
||||
title = f"tmux-title-{stamp}"
|
||||
_run_cli(cli, ["rename-window", "--workspace", ws, title])
|
||||
find = _run_cli(cli, ["find-window", title])
|
||||
_must(title in find.stdout, f"find-window title search failed: {find.stdout!r}")
|
||||
|
||||
ws2 = c.new_workspace()
|
||||
ws3 = c.new_workspace()
|
||||
c.select_workspace(ws)
|
||||
c.select_workspace(ws2)
|
||||
_run_cli(cli, ["last-window"])
|
||||
_must(c.current_workspace() == ws, f"last-window should navigate history back to ws={ws}")
|
||||
_run_cli(cli, ["next-window"])
|
||||
_must(c.current_workspace() == ws2, f"next-window should move to ws2={ws2}")
|
||||
_run_cli(cli, ["previous-window"])
|
||||
_must(c.current_workspace() == ws, f"previous-window should move back to ws={ws}")
|
||||
c.select_workspace(ws)
|
||||
|
||||
pre_p1 = _pane_selected_surface(c, p1)
|
||||
pre_p2 = _pane_selected_surface(c, p2)
|
||||
_run_cli(cli, ["swap-pane", "--workspace", ws, "--pane", p1, "--target-pane", p2])
|
||||
post_p1_ids = set(_pane_surface_ids(c, p1))
|
||||
post_p2_ids = set(_pane_surface_ids(c, p2))
|
||||
_must(pre_p2 in post_p1_ids, f"swap-pane should move target surface into source pane (p1={post_p1_ids}, pre_p2={pre_p2})")
|
||||
_must(pre_p1 in post_p2_ids, f"swap-pane should move source surface into target pane (p2={post_p2_ids}, pre_p1={pre_p1})")
|
||||
|
||||
s_break = _pane_selected_surface(c, p1)
|
||||
br = _run_cli(cli, ["--json", "--id-format", "both", "break-pane", "--workspace", ws, "--surface", s_break])
|
||||
br_payload = json.loads(br.stdout or "{}")
|
||||
ws_break = str(br_payload.get("workspace_id") or "")
|
||||
_must(bool(ws_break), f"break-pane returned invalid payload: {br_payload}")
|
||||
_must(ws_break in [wid for _idx, wid, _title, _sel in c.list_workspaces()], "break-pane workspace missing from list")
|
||||
_run_cli(cli, ["join-pane", "--workspace", ws, "--surface", s_break, "--target-pane", p2])
|
||||
_must(s_break in _pane_surface_ids(c, p2), f"join-pane should move broken surface into target pane {p2}")
|
||||
|
||||
current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()]
|
||||
if len(current_panes) < 2:
|
||||
_ = c.new_split("right")
|
||||
time.sleep(0.2)
|
||||
current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()]
|
||||
_must(len(current_panes) >= 2, f"Expected >=2 panes after break/join, got {current_panes}")
|
||||
lp_source, lp_target = current_panes[0], current_panes[1]
|
||||
|
||||
c.focus_pane(lp_source)
|
||||
c.focus_pane(lp_target)
|
||||
_run_cli(cli, ["last-pane", "--workspace", ws])
|
||||
ident = c.identify()
|
||||
focused = ident.get("focused") or {}
|
||||
_must(
|
||||
str(focused.get("pane_id") or "") == lp_source,
|
||||
f"last-pane should focus previous pane {lp_source}, focused={focused}",
|
||||
)
|
||||
|
||||
_run_cli(cli, ["clear-history", "--workspace", ws, "--surface", s1])
|
||||
|
||||
_run_cli(cli, ["set-hook", "workspace-created", "echo created"])
|
||||
hooks = _run_cli(cli, ["set-hook", "--list"])
|
||||
_must("workspace-created" in hooks.stdout, f"set-hook --list missing stored hook: {hooks.stdout!r}")
|
||||
_run_cli(cli, ["set-hook", "--unset", "workspace-created"])
|
||||
hooks2 = _run_cli(cli, ["set-hook", "--list"])
|
||||
_must("workspace-created" not in hooks2.stdout, f"set-hook --unset failed: {hooks2.stdout!r}")
|
||||
|
||||
for cmd in (["popup"], ["bind-key", "C-b", "split-window"], ["unbind-key", "C-b"], ["copy-mode"]):
|
||||
proc = _run_cli(cli, cmd, expect_ok=False)
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".lower()
|
||||
_must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}")
|
||||
|
||||
resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False)
|
||||
_must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added")
|
||||
|
||||
buffer_token = f"TMUX_BUFFER_{stamp}"
|
||||
_run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"])
|
||||
buffers = _run_cli(cli, ["list-buffers"])
|
||||
_must("tmuxbuf" in buffers.stdout, f"list-buffers missing tmuxbuf: {buffers.stdout!r}")
|
||||
_run_cli(cli, ["paste-buffer", "--name", "tmuxbuf", "--workspace", ws, "--surface", s1])
|
||||
_wait_for(lambda: _surface_has(c, ws, s1, buffer_token))
|
||||
|
||||
respawn_token = f"TMUX_RESPAWN_{stamp}"
|
||||
_run_cli(cli, ["respawn-pane", "--workspace", ws, "--surface", s1, "--command", f"echo {respawn_token}"])
|
||||
_wait_for(lambda: _surface_has(c, ws, s1, respawn_token))
|
||||
|
||||
msg = f"tmux-message-{stamp}"
|
||||
shown = _run_cli(cli, ["display-message", "-p", msg])
|
||||
_must(msg in shown.stdout, f"display-message -p should print message: {shown.stdout!r}")
|
||||
|
||||
print("PASS: tmux compatibility matrix commands are wired and tested")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87
|
||||
Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745
|
||||
|
|
@ -80,6 +80,16 @@ const CATEGORIES: ShortcutCategory[] = [
|
|||
combos: [["⌥", "⌘", "←/→/↑/↓"]],
|
||||
description: "Focus pane directionally",
|
||||
},
|
||||
{
|
||||
id: "sp-browser-right",
|
||||
combos: [["⌥", "⌘", "D"]],
|
||||
description: "Split browser right",
|
||||
},
|
||||
{
|
||||
id: "sp-browser-down",
|
||||
combos: [["⌥", "⌘", "⇧", "D"]],
|
||||
description: "Split browser down",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -88,8 +98,8 @@ const CATEGORIES: ShortcutCategory[] = [
|
|||
shortcuts: [
|
||||
{
|
||||
id: "br-open",
|
||||
combos: [["⌘", "⇧", "B"]],
|
||||
description: "Open browser in split",
|
||||
combos: [["⌘", "⇧", "L"]],
|
||||
description: "Open browser surface",
|
||||
},
|
||||
{ id: "br-addr", combos: [["⌘", "L"]], description: "Focus address bar" },
|
||||
{ id: "br-forward", combos: [["⌘", "]"]], description: "Forward" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue