diff --git a/CLI/CMUXCLI+CommandSuggestions.swift b/CLI/CMUXCLI+CommandSuggestions.swift index 3624928c9027..ad3752c89aca 100644 --- a/CLI/CMUXCLI+CommandSuggestions.swift +++ b/CLI/CMUXCLI+CommandSuggestions.swift @@ -166,6 +166,7 @@ extension CMUXCLI { "previous-window", "read-screen", "read-selection", + "record", "refresh-surfaces", "reload-config", "remote-daemon-status", diff --git a/CLI/CMUXCLI+Record.swift b/CLI/CMUXCLI+Record.swift new file mode 100644 index 000000000000..c72620ab7bb6 --- /dev/null +++ b/CLI/CMUXCLI+Record.swift @@ -0,0 +1,231 @@ +import Foundation + +extension CMUXCLI { + static var recordHelp: String { + String(localized: "cli.help.record", defaultValue: """ + Usage: cmux record [start] [flags] + cmux record stop|status [--id ] + cmux record note [--id ] + cmux record list + + Record a cmux window to an mp4 or gif, for a pull request or a bug + report. Only cmux's own windows can be recorded, and no Screen + Recording permission is involved. + + Flags for start: + --format mp4|gif Output format (default: mp4) + --gif Shorthand for --format gif + --fps <1-30> Frames per second (default: 12 for mp4, 8 for gif) + --max-seconds Stop by itself after n seconds, 0.5-120 (default: 15) + --scale <0.1-1> Scale the output (default: 1 for mp4, 0.5 for gif) + --max-width <64-4096> Cap the output width, keeping the aspect ratio + --region Record part of the window, in window points + --out Write here instead of a temporary directory + --label Name the file + --no-captions Do not draw `record note` captions into the clip + --window Window to record (default: the frontmost one) + + Output: ` `, or the full response with --json. + + A recording stops itself at --max-seconds, so a clip is never left + running by an agent that goes away. One recording at a time. + + Example: + cmux record start --gif --max-seconds 8 --label sidebar-drag + cmux record note "dragging the workspace" + cmux record stop + """) + } + + static var recordUsageLine: String { + String( + localized: "cli.usage.record", + defaultValue: "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" + ) + } + + func runRecordCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + windowOverride: String? + ) throws { + // `cmux record --gif` is `cmux record start --gif`: the subcommand is + // only a subcommand when it is not a flag. + let hasSubcommand = !(commandArgs.first?.hasPrefix("-") ?? true) + let subcommand = hasSubcommand ? commandArgs[0].lowercased() : "start" + let arguments = hasSubcommand ? Array(commandArgs.dropFirst()) : commandArgs + + switch subcommand { + case "start": + try runRecordStart( + arguments: arguments, + client: client, + jsonOutput: jsonOutput, + windowOverride: windowOverride + ) + case "stop", "status": + var (recordingID, trailing) = parseRecordOption(arguments, name: "--id") + if recordingID == nil, let positional = trailing.first, !positional.hasPrefix("-") { + recordingID = positional + trailing = Array(trailing.dropFirst()) + } + try requireNoRecordArguments(trailing, subcommand: subcommand) + var params: [String: Any] = [:] + if let recordingID { params["id"] = recordingID } + printRecordStatus( + try client.sendV2( + method: subcommand == "stop" ? "window.record.stop" : "window.record.status", + params: params, + responseTimeout: 60 + ), + jsonOutput: jsonOutput + ) + case "note": + let (id, parsed) = parseRecordOption(arguments, name: "--id") + // Only a leading `--` ends the options; one later in the text is + // part of the caption. + let trailing = parsed.first == "--" ? Array(parsed.dropFirst()) : parsed + let text = trailing.joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + throw CLIError(message: String( + localized: "cli.record.error.noteText", + defaultValue: "record note: text is required" + )) + } + var params: [String: Any] = ["text": text] + if let id { params["id"] = id } + printRecordStatus( + try client.sendV2(method: "window.record.note", params: params), + jsonOutput: jsonOutput + ) + case "list": + try requireNoRecordArguments(arguments, subcommand: subcommand) + let response = try client.sendV2(method: "window.record.list") + if jsonOutput { + print(jsonString(response)) + return + } + let recordings = (response["recordings"] as? [[String: Any]]) ?? [] + for recording in recordings { + print(Self.recordStatusLine(recording)) + } + default: + throw CLIError(message: String( + format: String( + localized: "cli.record.error.unknownSubcommand", + defaultValue: "record: unknown subcommand '%@' (start, stop, status, note, list)" + ), + subcommand + )) + } + } + + private func runRecordStart( + arguments: [String], + client: SocketClient, + jsonOutput: Bool, + windowOverride: String? + ) throws { + let (format, afterFormat) = parseRecordOption(arguments, name: "--format") + let (fps, afterFPS) = parseRecordOption(afterFormat, name: "--fps") + let (maxSeconds, afterSeconds) = parseRecordOption(afterFPS, name: "--max-seconds") + let (scale, afterScale) = parseRecordOption(afterSeconds, name: "--scale") + let (maxWidth, afterWidth) = parseRecordOption(afterScale, name: "--max-width") + let (region, afterRegion) = parseRecordOption(afterWidth, name: "--region") + let (out, afterOut) = parseRecordOption(afterRegion, name: "--out") + let (label, afterLabel) = parseRecordOption(afterOut, name: "--label") + let (window, afterWindow) = parseRecordOption(afterLabel, name: "--window") + + var trailing = afterWindow + let wantsGIF = trailing.contains("--gif") + let drawsCaptions = !trailing.contains("--no-captions") + trailing.removeAll { $0 == "--gif" || $0 == "--no-captions" } + try requireNoRecordArguments(trailing, subcommand: "start") + + if wantsGIF, let format, format.lowercased() != "gif" { + throw CLIError(message: String( + localized: "cli.record.error.formatConflict", + defaultValue: "record start: --gif conflicts with --format" + )) + } + + // Values stay as the caller typed them: the app owns the limits, so the + // CLI cannot disagree with it about what a valid frame rate is. + var params: [String: Any] = [:] + if let format { params["format"] = format } else if wantsGIF { params["format"] = "gif" } + if let fps { params["fps"] = fps } + if let maxSeconds { params["max_seconds"] = maxSeconds } + if let scale { params["scale"] = scale } + if let maxWidth { params["max_width"] = maxWidth } + if let region { params["region"] = region } + if let label { params["label"] = label } + if !drawsCaptions { params["captions"] = false } + // The app has no idea what directory the CLI was run from. + if let out { params["out"] = Self.absoluteRecordingPath(out) } + if let windowID = try normalizeWindowHandle(window ?? windowOverride, client: client) { + params["window"] = windowID + } + + printRecordStatus( + try client.sendV2( + method: "window.record.start", + params: params, + responseTimeout: 45 + ), + jsonOutput: jsonOutput + ) + } + + /// A flag where a value belongs is a typo rather than a value: plain + /// `parseOption` would let `--label --gif` name the clip "--gif" and quietly + /// drop the format. Hand both back so the caller's error names them. + private func parseRecordOption(_ args: [String], name: String) -> (String?, [String]) { + let (value, remaining) = parseOption(args, name: name) + guard let value, value.hasPrefix("--") else { return (value, remaining) } + return (nil, [name, value] + remaining) + } + + private func requireNoRecordArguments(_ trailing: [String], subcommand: String) throws { + guard trailing.isEmpty else { + throw CLIError(message: String( + format: String( + localized: "cli.record.error.unexpectedArguments", + defaultValue: "record %@: unexpected arguments: %@" + ), + subcommand, + trailing.joined(separator: " ") + )) + } + } + + private func printRecordStatus(_ response: [String: Any], jsonOutput: Bool) { + if jsonOutput { + print(jsonString(response)) + return + } + print(Self.recordStatusLine(response)) + } + + static func recordStatusLine(_ response: [String: Any]) -> String { + let id = (response["id"] as? String) ?? "-" + let state = (response["state"] as? String) ?? "-" + let frames = (response["frames"] as? Int).map(String.init) ?? "-" + let path = (response["path"] as? String) ?? "-" + var line = "\(id) \(state) \(frames) \(path)" + if let error = response["error"] as? String, !error.isEmpty { + line += " (\(error))" + } + return line + } + + static func absoluteRecordingPath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + guard !expanded.hasPrefix("/") else { return expanded } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(expanded) + .standardizedFileURL + .path + } +} diff --git a/CLI/CMUXCLI+TaskHelp.swift b/CLI/CMUXCLI+TaskHelp.swift index 38cb21a7e52f..235d8ec0d85d 100644 --- a/CLI/CMUXCLI+TaskHelp.swift +++ b/CLI/CMUXCLI+TaskHelp.swift @@ -275,6 +275,7 @@ extension CMUXCLI { current-workspace [--window ] \(Self.readSelectionUsageLine) \(Self.readScreenUsageLine) + \(Self.recordUsageLine) sidebar-state [--workspace ] [--window ] markdown [open] [--focus ] (open markdown file in formatted viewer panel with live reload) diff [patch-file|-] [--source ] [--cwd ] [--base ] [--focus ] [--no-focus] [--title ] [--layout ] [--font-size ] (open patch input or git source in a browser split) diff --git a/CLI/CMUXCLI+WindowDispatch.swift b/CLI/CMUXCLI+WindowDispatch.swift index d4640b9322a6..d993c7cf6961 100644 --- a/CLI/CMUXCLI+WindowDispatch.swift +++ b/CLI/CMUXCLI+WindowDispatch.swift @@ -24,6 +24,11 @@ extension CMUXCLI { if normalizedCommand == "read-screen" || normalizedCommand == "read-selection" || normalizedCommand == "current" { return false } + // Recording films whatever is on screen; activating a window first + // would put the recording's own side effect in the clip. + if normalizedCommand == "record" { + return false + } if normalizedCommand == "rpc", commandArgs.first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "surface.read_selection" { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9eef77a68730..7dfc9b526d4c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7586,6 +7586,14 @@ struct CMUXCLI { includeContextInPlainOutput: true ) + case "record": + try runRecordCommand( + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + windowOverride: windowId + ) + case "read-screen": let selectionOnly = commandArgs.contains("--selection") if selectionOnly { @@ -20436,6 +20444,8 @@ struct CMUXCLI { return Self.readSelectionHelp case "read-screen": return Self.readScreenHelp + case "record": + return Self.recordHelp case "paste": return Self.pasteHelp case "send": diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 31525e889046..e0cde6468f38 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -206,6 +206,14 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { // compositor before falling back to AppKit. Keep that wait on the // socket worker so WebKit-backed panels can render on the main actor. "debug.window.screenshot", + // Window recording samples ScreenCaptureKit on a schedule for as long + // as the clip lasts. The sampling loop must never own the main actor: + // the window it is filming has to keep drawing. + "window.record.start", + "window.record.stop", + "window.record.status", + "window.record.note", + "window.record.list", // debug.sidebar.simulate_drag intentionally runs on the socket worker // so its Thread.sleep between drag-state ticks doesn't block the main // actor (which still owns the SidebarDragState mutations via diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index d126aadaffe5..d7766b3630b5 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -124,6 +124,20 @@ struct ControlCommandExecutionPolicyTests { ) } + @Test func windowRecordingRunsOnTheWorkerAndIsNotMainThreadCallable() { + // A recording samples the window for as long as the clip lasts, so its + // verbs must never be callable inline on the main thread: the window + // being filmed has to keep drawing while the sampler runs. + for method in [ + "window.record.start", "window.record.stop", "window.record.status", + "window.record.note", "window.record.list", + ] { + let policy = ControlCommandExecutionPolicy(forMethod: method) + #expect(policy == .socketWorker(mainThreadCallable: false), "\(method)") + #expect(policy.runsOnSocketWorker, "\(method)") + } + } + @Test func v2ResolutionReadsRunOnTheWorkerAndAreMainThreadCallable() { // Tranche D (issue #5757): the implicit handle-normalization reads. // One controlResolveOnMain hop (refresh + witness + ref minting), diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingCaptionTrack.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingCaptionTrack.swift new file mode 100644 index 000000000000..cd56eabaa9a4 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingCaptionTrack.swift @@ -0,0 +1,78 @@ +internal import Foundation + +/// The captions burned into a recording's frames. +/// +/// An agent that records a tour wants the clip to say what it did, not just +/// show a cursor moving: `cmux record note "open Settings"` appends one here, +/// and the recorder draws whichever note is current when it writes a frame. +/// Automation commands push their own notes through the same sink, which is +/// what `--overlay-actions` turns on. +public struct WindowRecordingCaptionTrack: Sendable, Equatable { + public struct Note: Sendable, Equatable { + public let offsetSeconds: Double + public let text: String + + public init(offsetSeconds: Double, text: String) { + self.offsetSeconds = offsetSeconds + self.text = text + } + } + + /// How long one note stays on screen after the moment it was pushed. + public static let defaultVisibleSeconds: Double = 2.5 + /// Longest caption drawn; a whole prompt would cover the window. + public static let maximumCharacters = 120 + + public let visibleSeconds: Double + private var notes: [Note] = [] + + public init(visibleSeconds: Double = WindowRecordingCaptionTrack.defaultVisibleSeconds) { + self.visibleSeconds = max(0.1, visibleSeconds) + } + + public var isEmpty: Bool { notes.isEmpty } + public var notesInOrder: [Note] { notes } + public var count: Int { notes.count } + + /// Records one caption. Returns false when the text carries nothing to draw. + /// + /// Notes arrive from the recorder's own clock, so they normally append; an + /// out-of-order offset is still inserted in order rather than dropped, + /// which keeps `caption(atOffsetSeconds:)` a plain search. + @discardableResult + public mutating func append(text: String, atOffsetSeconds offset: Double) -> Bool { + let collapsed = text + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + guard !collapsed.isEmpty else { return false } + let clipped = collapsed.count > Self.maximumCharacters + ? String(collapsed.prefix(Self.maximumCharacters - 1)) + "\u{2026}" + : collapsed + let note = Note( + offsetSeconds: offset.isFinite ? max(0, offset) : 0, + text: clipped + ) + if let last = notes.last, last.offsetSeconds <= note.offsetSeconds { + notes.append(note) + } else { + let index = notes.firstIndex { $0.offsetSeconds > note.offsetSeconds } ?? notes.endIndex + notes.insert(note, at: index) + } + return true + } + + /// The caption to draw on the frame captured at `offset`, if any. + public func caption(atOffsetSeconds offset: Double) -> String? { + guard offset.isFinite else { return nil } + var current: Note? + for note in notes { + if note.offsetSeconds > offset { break } + current = note + } + guard let current, offset - current.offsetSeconds < visibleSeconds else { + return nil + } + return current.text + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingFrameGeometry.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingFrameGeometry.swift new file mode 100644 index 000000000000..23d3df2a413d --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingFrameGeometry.swift @@ -0,0 +1,134 @@ +internal import Foundation + +/// The crop and output size every frame of one recording uses. +/// +/// A video has one frame size for its whole length, so the geometry is decided +/// once from the first captured frame and reused: a window resized mid-clip is +/// letterboxed into the size the clip started with rather than ending it. +public struct WindowRecordingFrameGeometry: Equatable, Sendable { + /// Crop rectangle in captured pixels, origin at the image's top-left. + public let cropX: Int + public let cropY: Int + public let cropWidth: Int + public let cropHeight: Int + /// Encoded frame size in pixels. + public let outputWidth: Int + public let outputHeight: Int + + public enum Failure: Error, Equatable, Sendable { + case emptyWindow + case regionOutsideWindow + + public var message: String { + switch self { + case .emptyWindow: + "the window has no capturable content" + case .regionOutsideWindow: + "region lies outside the window" + } + } + } + + public var cropsNothing: Bool { + cropX == 0 && cropY == 0 + } + + public var scalesNothing: Bool { + outputWidth == cropWidth && outputHeight == cropHeight + } + + /// A pixel coordinate as an `Int`, clamped instead of trapped. + /// + /// `Int(_: Double)` traps on a value past `Int`'s range, and a region is + /// only bounded by the request decoder: a geometry planned from a request + /// built in code must answer "outside the window" rather than kill the app. + /// A region that is not a number crops nothing and fails the same way. + private static func pixelIndex(_ value: Double, rounding rule: FloatingPointRoundingRule) -> Int { + let rounded = value.rounded(rule) + guard rounded.isFinite else { return 0 } + guard rounded > Double(Int.min) else { return Int.min } + guard rounded < Double(Int.max) else { return Int.max } + return Int(rounded) + } + + /// Plans the geometry from the first captured frame. + /// + /// - Parameters: + /// - windowPixelWidth: width of the captured window image, in pixels. + /// - windowPixelHeight: height of the captured window image, in pixels. + /// - pointPixelScale: pixels per point of the captured image, so a region + /// given in window points lands on the right pixels on a Retina display. + /// - request: the validated recording request. + public static func plan( + windowPixelWidth: Int, + windowPixelHeight: Int, + pointPixelScale: Double, + request: WindowRecordingRequest + ) throws -> WindowRecordingFrameGeometry { + guard windowPixelWidth > 0, windowPixelHeight > 0 else { + throw Failure.emptyWindow + } + let pixelScale = pointPixelScale.isFinite && pointPixelScale > 0 ? pointPixelScale : 1 + + var cropX = 0 + var cropY = 0 + var cropWidth = windowPixelWidth + var cropHeight = windowPixelHeight + + if case let .region(region) = request.target { + let left = Self.pixelIndex(region.x * pixelScale, rounding: .down) + let top = Self.pixelIndex(region.y * pixelScale, rounding: .down) + let right = Self.pixelIndex((region.x + region.width) * pixelScale, rounding: .up) + let bottom = Self.pixelIndex((region.y + region.height) * pixelScale, rounding: .up) + cropX = max(0, min(left, windowPixelWidth)) + cropY = max(0, min(top, windowPixelHeight)) + cropWidth = min(right, windowPixelWidth) - cropX + cropHeight = min(bottom, windowPixelHeight) - cropY + guard cropWidth > 0, cropHeight > 0 else { + throw Failure.regionOutsideWindow + } + } + + var outputWidth = Double(cropWidth) * request.scale + var outputHeight = Double(cropHeight) * request.scale + if let maximumWidth = request.maximumWidth, outputWidth > Double(maximumWidth) { + let shrink = Double(maximumWidth) / outputWidth + outputWidth *= shrink + outputHeight *= shrink + } + + let quantum = request.format == .mp4 ? 2 : 1 + return WindowRecordingFrameGeometry( + cropX: cropX, + cropY: cropY, + cropWidth: cropWidth, + cropHeight: cropHeight, + outputWidth: quantize(outputWidth, to: quantum), + outputHeight: quantize(outputHeight, to: quantum) + ) + } + + /// Keeps this geometry's encoded frame size while taking another plan's crop. + /// + /// A window resized mid-recording has to be cropped against the image that + /// was captured, but the encoded frame size may not change, so the new crop + /// is drawn into the size the clip started with. + public func adoptingCrop( + of other: WindowRecordingFrameGeometry + ) -> WindowRecordingFrameGeometry { + WindowRecordingFrameGeometry( + cropX: other.cropX, + cropY: other.cropY, + cropWidth: other.cropWidth, + cropHeight: other.cropHeight, + outputWidth: outputWidth, + outputHeight: outputHeight + ) + } + + /// H.264 wants even dimensions; a gif only wants a positive one. + private static func quantize(_ value: Double, to quantum: Int) -> Int { + let rounded = max(quantum, Int(value.rounded())) + return rounded - (rounded % quantum) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingLabel.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingLabel.swift new file mode 100644 index 000000000000..fd32d1adb6b7 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingLabel.swift @@ -0,0 +1,43 @@ +internal import Foundation + +/// Converts an optional recording label into one safe filename component. +/// +/// Same rules as the screenshot label: the label reaches the filesystem, and a +/// caller that names a recording after a branch or a test can put anything in +/// it, including a path separator. +public struct WindowRecordingLabel: Sendable, Equatable { + private static let maximumUTF8ByteCount = 80 + + public let value: String + + public init(_ input: String) { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + value = "" + return + } + + let allowed = CharacterSet.alphanumerics.union( + CharacterSet(charactersIn: "-_.") + ) + let scalars = trimmed.unicodeScalars.map { + allowed.contains($0) ? Character($0) : "-" + } + let cleaned = String(scalars) + .trimmingCharacters(in: CharacterSet(charactersIn: "-_.")) + guard !cleaned.isEmpty else { + value = "recording" + return + } + + var byteCount = 0 + value = String(cleaned.prefix { character in + let characterByteCount = String(character).utf8.count + guard byteCount + characterByteCount <= Self.maximumUTF8ByteCount else { + return false + } + byteCount += characterByteCount + return true + }) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRegion.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRegion.swift new file mode 100644 index 000000000000..503ebc861195 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRegion.swift @@ -0,0 +1,40 @@ +internal import Foundation + +/// A rectangle inside a cmux window, in window points, with its origin at the +/// window's top-left corner. +/// +/// Top-left origin matches the captured image and the coordinates +/// `debug.window.screenshot` frames are read in, so an agent that found a +/// feature in one frame can hand the same numbers back to the recorder. +public struct WindowRecordingRegion: Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + /// Parses `x,y,width,height`, the form `cmux record --region` accepts. + public init?(commaSeparated text: String) { + let parts = text.split(separator: ",", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + var values: [Double] = [] + values.reserveCapacity(4) + for part in parts { + guard let value = Double(part.trimmingCharacters(in: .whitespaces)) else { + return nil + } + values.append(value) + } + self.init(x: values[0], y: values[1], width: values[2], height: values[3]) + } + + public var isFinite: Bool { + x.isFinite && y.isFinite && width.isFinite && height.isFinite + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest+Output.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest+Output.swift new file mode 100644 index 000000000000..9493f4f80c1f --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest+Output.swift @@ -0,0 +1,26 @@ +public import Foundation + +/// Where a recording lands and what it is called. +/// +/// Recordings follow the screenshot convention (a sortable timestamp plus a +/// short unique suffix, under one directory in the temporary directory) so the +/// two kinds of capture can be listed together and cleaned up together. +extension WindowRecordingRequest { + public static let outputDirectoryName = "cmux-recordings" + + /// `2026-09-28T07-14-03Z_1a2b3c4d`: sorts by time, unique per recording. + public static func recordingIdentifier(date: Date, uuid: UUID = UUID()) -> String { + let timestamp = ISO8601DateFormatter().string(from: date) + .replacingOccurrences(of: ":", with: "-") + .replacingOccurrences(of: "+", with: "_") + return "\(timestamp)_\(uuid.uuidString.prefix(8).lowercased())" + } + + /// The clip's filename when the caller did not pass `out`: the label, if + /// any, then the recording identifier, then the format's extension. + public func outputFilename(identifier: String) -> String { + let sanitized = WindowRecordingLabel(label).value + let stem = sanitized.isEmpty ? identifier : "\(sanitized)_\(identifier)" + return "\(stem).\(format.rawValue)" + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest.swift new file mode 100644 index 000000000000..a7a18e33a958 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest.swift @@ -0,0 +1,316 @@ +internal import Foundation + +/// One validated `window.record.start` request. +public struct WindowRecordingRequest: Equatable, Sendable { + public enum Format: String, Sendable, CaseIterable { + case mp4 + case gif + + /// Frame rate used when the caller does not ask for one. + /// + /// A gif carries one palette and one frame per delay slot, so it stays + /// slower and smaller than the mp4 default. + public var defaultFramesPerSecond: Int { + switch self { + case .mp4: 12 + case .gif: 8 + } + } + + /// Scale used when the caller does not ask for one. + public var defaultScale: Double { + switch self { + case .mp4: 1 + case .gif: 0.5 + } + } + + /// Width cap used when the caller does not ask for one. + /// + /// A full-resolution gif of a cmux window runs to tens of megabytes, + /// which no pull request body wants, so gifs get a default cap and mp4 + /// keeps the window's own size. + public var defaultMaximumWidth: Int? { + switch self { + case .mp4: nil + case .gif: 960 + } + } + } + + /// What the recorder frames. + public enum Target: Equatable, Sendable { + /// A whole cmux window: the one named by `window`, else the same window + /// `debug.window.screenshot` picks. + case window + /// A rectangle inside that window. + case region(WindowRecordingRegion) + } + + public enum Failure: Error, Equatable, Sendable { + case unknownFormat(String) + case notANumber(field: String) + case outOfRange(field: String, message: String) + case malformedRegion(String) + case regionTooSmall + case regionTooLarge + case outputPathNotAbsolute(String) + case outputExtensionMismatch(path: String, format: Format) + + /// The message the socket returns with `invalid_params`. + public var message: String { + switch self { + case let .unknownFormat(value): + let known = Format.allCases.map(\.rawValue).joined(separator: ", ") + return "unknown format '\(value)'; expected one of \(known)" + case let .notANumber(field): + return "\(field) must be a number" + case let .outOfRange(field, message): + return "\(field) \(message)" + case let .malformedRegion(value): + return "region '\(value)' must be x,y,width,height in window points" + case .regionTooSmall: + let minimum = Int(WindowRecordingRequest.minimumRegionExtent) + return "region width and height must each be at least \(minimum) points" + case .regionTooLarge: + let maximum = Int(WindowRecordingRequest.maximumRegionExtent) + return "region position and size must each stay within \(maximum) points" + case let .outputPathNotAbsolute(path): + return "out '\(path)' must be an absolute path" + case let .outputExtensionMismatch(path, format): + return "out '\(path)' must end in .\(format.rawValue) for format \(format.rawValue)" + } + } + } + + public let target: Target + public let windowHandle: String? + public let format: Format + public let framesPerSecond: Int + public let maximumSeconds: Double + public let scale: Double + public let maximumWidth: Int? + public let label: String + public let outputPath: String? + public let drawsCaptions: Bool + + public init( + target: Target = .window, + windowHandle: String? = nil, + format: Format = .mp4, + framesPerSecond: Int? = nil, + maximumSeconds: Double = 15, + scale: Double? = nil, + maximumWidth: Int?? = nil, + label: String = "", + outputPath: String? = nil, + drawsCaptions: Bool = true + ) { + self.target = target + self.windowHandle = windowHandle + self.format = format + self.framesPerSecond = framesPerSecond ?? format.defaultFramesPerSecond + self.maximumSeconds = maximumSeconds + self.scale = scale ?? format.defaultScale + self.maximumWidth = maximumWidth ?? format.defaultMaximumWidth + self.label = label + self.outputPath = outputPath + self.drawsCaptions = drawsCaptions + } + + /// The number of frames this request may write before it stops itself. + public var frameBudget: Int { + max(1, Int((maximumSeconds * Double(framesPerSecond)).rounded(.up))) + } + + /// Seconds between two frames. + public var frameInterval: Double { + 1 / Double(framesPerSecond) + } + + /// Validates one decoded `window.record.start` parameter dictionary. + /// + /// Absent keys take the format's defaults, so `{}` is a complete request: + /// a 15 second mp4 of the selected window. + public static func make(params: [String: Any]) throws -> WindowRecordingRequest { + let format = try decodeFormat(params["format"]) + let framesPerSecond = try decodeInt( + params["fps"], + field: "fps", + range: Self.allowedFramesPerSecond + ) + let maximumSeconds = try decodeDouble( + params["max_seconds"], + field: "max_seconds", + range: Self.allowedSeconds + ) + let scale = try decodeDouble( + params["scale"], + field: "scale", + range: Self.allowedScale + ) + let maximumWidth = try decodeInt( + params["max_width"], + field: "max_width", + range: Self.allowedMaximumWidth + ) + let target = try decodeTarget(params["region"]) + let outputPath = try decodeOutputPath(params["out"], format: format) + + return WindowRecordingRequest( + target: target, + windowHandle: (params["window"] as? String)?.trimmedNonEmpty, + format: format, + framesPerSecond: framesPerSecond, + maximumSeconds: maximumSeconds ?? 15, + scale: scale, + maximumWidth: maximumWidth.map { Optional($0) }, + label: WindowRecordingLabel(params["label"] as? String ?? "").value, + outputPath: outputPath, + drawsCaptions: decodeBool(params["captions"]) ?? true + ) + } + + private static func decodeFormat(_ value: Any?) throws -> Format { + guard let raw = (value as? String)?.trimmedNonEmpty else { return .mp4 } + guard let format = Format(rawValue: raw.lowercased()) else { + throw Failure.unknownFormat(raw) + } + return format + } + + private static func decodeTarget(_ value: Any?) throws -> Target { + guard let value else { return .window } + let region: WindowRecordingRegion + if let text = value as? String { + guard let parsed = WindowRecordingRegion(commaSeparated: text) else { + throw Failure.malformedRegion(text) + } + region = parsed + } else if let numbers = value as? [Any] { + let doubles = numbers.compactMap { numericValue($0) } + guard doubles.count == 4 else { + throw Failure.malformedRegion(String(describing: value)) + } + region = WindowRecordingRegion( + x: doubles[0], + y: doubles[1], + width: doubles[2], + height: doubles[3] + ) + } else { + throw Failure.malformedRegion(String(describing: value)) + } + guard region.isFinite else { + throw Failure.malformedRegion(String(describing: value)) + } + guard region.width >= Self.minimumRegionExtent, + region.height >= Self.minimumRegionExtent else { + throw Failure.regionTooSmall + } + // An upper bound as well as a lower one: the geometry turns points into + // pixel integers, and "--region 0,0,1e19,1e19" is something a caller can + // type. No window is anywhere near this wide. + guard abs(region.x) <= Self.maximumRegionExtent, + abs(region.y) <= Self.maximumRegionExtent, + region.width <= Self.maximumRegionExtent, + region.height <= Self.maximumRegionExtent else { + throw Failure.regionTooLarge + } + return .region(region) + } + + private static func decodeOutputPath(_ value: Any?, format: Format) throws -> String? { + guard let path = (value as? String)?.trimmedNonEmpty else { return nil } + guard path.hasPrefix("/") else { + throw Failure.outputPathNotAbsolute(path) + } + guard path.lowercased().hasSuffix(".\(format.rawValue)") else { + throw Failure.outputExtensionMismatch(path: path, format: format) + } + return path + } + + private static func decodeInt( + _ value: Any?, + field: String, + range: ClosedRange + ) throws -> Int? { + guard let value else { return nil } + guard let number = numericValue(value), number.isFinite else { + throw Failure.notANumber(field: field) + } + // `Int(exactly:)` rather than `Int(_:)`: converting a value past Int's + // range traps, and "--fps 1e30" is something a caller can type. + guard let rounded = Int(exactly: number.rounded()), range.contains(rounded) else { + throw Failure.outOfRange( + field: field, + message: "must be between \(range.lowerBound) and \(range.upperBound)" + ) + } + return rounded + } + + private static func decodeDouble( + _ value: Any?, + field: String, + range: ClosedRange + ) throws -> Double? { + guard let value else { return nil } + guard let number = numericValue(value), number.isFinite else { + throw Failure.notANumber(field: field) + } + guard range.contains(number) else { + throw Failure.outOfRange( + field: field, + message: "must be between \(Self.trim(range.lowerBound)) and \(Self.trim(range.upperBound))" + ) + } + return number + } + + private static func decodeBool(_ value: Any?) -> Bool? { + if let flag = value as? Bool { return flag } + guard let text = (value as? String)?.trimmedNonEmpty?.lowercased() else { return nil } + switch text { + case "true", "yes", "1": return true + case "false", "no", "0": return false + default: return nil + } + } + + private static func numericValue(_ value: Any) -> Double? { + if let number = value as? Double { return number } + if let number = value as? Int { return Double(number) } + if let number = value as? NSNumber { return number.doubleValue } + if let text = value as? String { return Double(text.trimmingCharacters(in: .whitespaces)) } + return nil + } + + private static func trim(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(value) + } +} + +private extension String { + /// Nil for a blank value, so an empty socket string means "not supplied". + var trimmedNonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +/// The bounds every `window.record.start` request is held to. +/// +/// One recording writes frames for as long as it runs, so the caller cannot be +/// trusted with an unbounded frame rate, duration, or pixel count: a stuck +/// agent would fill the disk. These are the limits the socket enforces; the +/// recorder stops itself at `maximumSeconds` even when nobody calls stop. +extension WindowRecordingRequest { + public static let allowedFramesPerSecond = 1...30 + public static let allowedSeconds = 0.5...120.0 + public static let allowedScale = 0.1...1.0 + public static let allowedMaximumWidth = 64...4096 + public static let minimumRegionExtent: Double = 8 + public static let maximumRegionExtent: Double = 100_000 +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingCaptionTrackTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingCaptionTrackTests.swift new file mode 100644 index 000000000000..9809408bddcc --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingCaptionTrackTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite struct WindowRecordingCaptionTrackTests { + @Test func noNotesDrawNoCaption() { + let track = WindowRecordingCaptionTrack() + + #expect(track.isEmpty) + #expect(track.caption(atOffsetSeconds: 0) == nil) + } + + @Test func aNoteIsDrawnFromItsOffsetUntilItExpires() { + var track = WindowRecordingCaptionTrack(visibleSeconds: 2) + track.append(text: "open Settings", atOffsetSeconds: 1) + + #expect(track.caption(atOffsetSeconds: 0.9) == nil) + #expect(track.caption(atOffsetSeconds: 1) == "open Settings") + #expect(track.caption(atOffsetSeconds: 2.9) == "open Settings") + #expect(track.caption(atOffsetSeconds: 3) == nil) + } + + @Test func aLaterNoteReplacesAnEarlierOneImmediately() { + var track = WindowRecordingCaptionTrack(visibleSeconds: 10) + track.append(text: "first", atOffsetSeconds: 0) + track.append(text: "second", atOffsetSeconds: 1) + + #expect(track.caption(atOffsetSeconds: 0.5) == "first") + #expect(track.caption(atOffsetSeconds: 1) == "second") + #expect(track.count == 2) + } + + @Test func outOfOrderNotesAreStillOrdered() { + var track = WindowRecordingCaptionTrack(visibleSeconds: 10) + track.append(text: "late", atOffsetSeconds: 5) + track.append(text: "early", atOffsetSeconds: 1) + + #expect(track.notesInOrder.map(\.text) == ["early", "late"]) + #expect(track.caption(atOffsetSeconds: 2) == "early") + } + + @Test func whitespaceIsCollapsedAndBlankNotesAreDropped() { + var track = WindowRecordingCaptionTrack() + + let drewCaption = track.append(text: " click\n Settings ", atOffsetSeconds: 0) + let drewBlank = track.append(text: " \n ", atOffsetSeconds: 1) + + #expect(drewCaption) + #expect(!drewBlank) + #expect(track.caption(atOffsetSeconds: 0) == "click Settings") + #expect(track.count == 1) + } + + @Test func longCaptionsAreClippedRatherThanCoveringTheWindow() { + var track = WindowRecordingCaptionTrack() + track.append(text: String(repeating: "a", count: 400), atOffsetSeconds: 0) + let caption = track.caption(atOffsetSeconds: 0) + + #expect(caption?.count == WindowRecordingCaptionTrack.maximumCharacters) + #expect(caption?.hasSuffix("\u{2026}") == true) + } + + @Test func negativeAndBrokenOffsetsLandAtTheStart() { + var track = WindowRecordingCaptionTrack(visibleSeconds: 5) + track.append(text: "start", atOffsetSeconds: -3) + track.append(text: "nan", atOffsetSeconds: .nan) + + #expect(track.notesInOrder.allSatisfy { $0.offsetSeconds == 0 }) + #expect(track.caption(atOffsetSeconds: .nan) == nil) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingFrameGeometryTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingFrameGeometryTests.swift new file mode 100644 index 000000000000..e0e5da9b44d6 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingFrameGeometryTests.swift @@ -0,0 +1,170 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite struct WindowRecordingFrameGeometryTests { + private func plan( + windowPixelWidth: Int = 1600, + windowPixelHeight: Int = 1000, + pointPixelScale: Double = 2, + params: [String: Any] = [:] + ) throws -> WindowRecordingFrameGeometry { + try WindowRecordingFrameGeometry.plan( + windowPixelWidth: windowPixelWidth, + windowPixelHeight: windowPixelHeight, + pointPixelScale: pointPixelScale, + request: try WindowRecordingRequest.make(params: params) + ) + } + + @Test func mp4OfAWholeWindowKeepsItsPixels() throws { + let geometry = try plan() + + #expect(geometry.cropX == 0) + #expect(geometry.cropY == 0) + #expect(geometry.cropWidth == 1600) + #expect(geometry.cropHeight == 1000) + #expect(geometry.outputWidth == 1600) + #expect(geometry.outputHeight == 1000) + #expect(geometry.scalesNothing) + } + + @Test func mp4DimensionsStayEvenForH264() throws { + let geometry = try plan( + windowPixelWidth: 1001, + windowPixelHeight: 667, + params: ["scale": 0.5] + ) + + #expect(geometry.outputWidth % 2 == 0) + #expect(geometry.outputHeight % 2 == 0) + #expect(geometry.outputWidth == 500) + #expect(geometry.outputHeight == 334) + } + + @Test func gifIsHalvedAndWidthCappedByDefault() throws { + let geometry = try plan(params: ["format": "gif"]) + + #expect(geometry.outputWidth == 800) + #expect(geometry.outputHeight == 500) + } + + @Test func gifWidthCapKeepsTheAspectRatio() throws { + let geometry = try plan( + windowPixelWidth: 3200, + windowPixelHeight: 2000, + params: ["format": "gif"] + ) + + #expect(geometry.outputWidth == 960) + #expect(geometry.outputHeight == 600) + } + + @Test func regionPointsBecomePixelsOnARetinaWindow() throws { + let geometry = try plan(params: ["region": "100,50,400,300"]) + + #expect(geometry.cropX == 200) + #expect(geometry.cropY == 100) + #expect(geometry.cropWidth == 800) + #expect(geometry.cropHeight == 600) + #expect(geometry.outputWidth == 800) + #expect(geometry.outputHeight == 600) + #expect(!geometry.cropsNothing) + } + + @Test func regionOnAOnePointPerPixelWindowIsNotScaled() throws { + let geometry = try plan(pointPixelScale: 1, params: ["region": "10,10,100,100"]) + + #expect(geometry.cropX == 10) + #expect(geometry.cropWidth == 100) + } + + @Test func aRegionHangingOffTheEdgeIsClampedToTheWindow() throws { + let geometry = try plan(params: ["region": "700,400,400,400"]) + + #expect(geometry.cropX == 1400) + #expect(geometry.cropY == 800) + #expect(geometry.cropWidth == 200) + #expect(geometry.cropHeight == 200) + } + + @Test func aRegionFullyOutsideTheWindowFails() throws { + let request = try WindowRecordingRequest.make(params: ["region": "2000,0,100,100"]) + + #expect(throws: WindowRecordingFrameGeometry.Failure.regionOutsideWindow) { + try WindowRecordingFrameGeometry.plan( + windowPixelWidth: 1600, + windowPixelHeight: 1000, + pointPixelScale: 1, + request: request + ) + } + } + + /// The decoder bounds a region, but a request built in code is not bound by + /// it, and `Int(_: Double)` traps past `Int`'s range. Planning has to answer + /// rather than take the app down, whatever it is handed. + @Test func anAbsurdRegionFailsInsteadOfTrapping() throws { + let huge = WindowRecordingRequest( + target: .region(WindowRecordingRegion(x: 0, y: 0, width: 1e19, height: 1e19)) + ) + let planned = try WindowRecordingFrameGeometry.plan( + windowPixelWidth: 1600, + windowPixelHeight: 1000, + pointPixelScale: 2, + request: huge + ) + // Clamped to the window, which is the same answer a merely oversized + // region gets. + #expect(planned.cropWidth == 1600) + #expect(planned.cropHeight == 1000) + + let offscreen = WindowRecordingRequest( + target: .region(WindowRecordingRegion(x: -1e19, y: 0, width: 100, height: 100)) + ) + #expect(throws: WindowRecordingFrameGeometry.Failure.regionOutsideWindow) { + try WindowRecordingFrameGeometry.plan( + windowPixelWidth: 1600, + windowPixelHeight: 1000, + pointPixelScale: 2, + request: offscreen + ) + } + } + + @Test func anEmptyWindowFails() throws { + #expect(throws: WindowRecordingFrameGeometry.Failure.emptyWindow) { + try plan(windowPixelWidth: 0) + } + } + + @Test func aBrokenPixelScaleFallsBackToOnePixelPerPoint() throws { + let geometry = try plan(pointPixelScale: 0, params: ["region": "10,10,100,100"]) + + #expect(geometry.cropX == 10) + #expect(geometry.cropWidth == 100) + } + + @Test func outputNeverCollapsesToZero() throws { + let geometry = try plan( + windowPixelWidth: 10, + windowPixelHeight: 10, + params: ["scale": 0.1] + ) + + #expect(geometry.outputWidth == 2) + #expect(geometry.outputHeight == 2) + } + + @Test func aResizedWindowKeepsTheEncodedFrameSize() throws { + let opening = try plan(windowPixelWidth: 1000, windowPixelHeight: 800) + let resized = try plan(windowPixelWidth: 1200, windowPixelHeight: 700) + + let continued = opening.adoptingCrop(of: resized) + + #expect(continued.cropWidth == 1200) + #expect(continued.cropHeight == 700) + #expect(continued.outputWidth == opening.outputWidth) + #expect(continued.outputHeight == opening.outputHeight) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingOutputNamingTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingOutputNamingTests.swift new file mode 100644 index 000000000000..16c6f9d5d955 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingOutputNamingTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite struct WindowRecordingOutputNamingTests { + private let uuid = UUID(uuidString: "1A2B3C4D-0000-0000-0000-000000000000")! + + @Test func identifiersSortByTimeAndCarryAUniqueSuffix() { + let identifier = WindowRecordingRequest.recordingIdentifier( + date: Date(timeIntervalSince1970: 1_790_000_000), + uuid: uuid + ) + + #expect(identifier.hasSuffix("_1a2b3c4d")) + #expect(!identifier.contains(":")) + } + + @Test func aLabelBecomesTheFilenamePrefix() throws { + let filename = try WindowRecordingRequest.make(params: [ + "label": "sidebar tour", + "format": "gif", + ]).outputFilename(identifier: "2026-09-28T07-14-03Z_1a2b3c4d") + + #expect(filename == "sidebar-tour_2026-09-28T07-14-03Z_1a2b3c4d.gif") + } + + @Test func noLabelLeavesJustTheIdentifier() throws { + let filename = try WindowRecordingRequest.make(params: [ + "label": " ", + "format": "mp4", + ]).outputFilename(identifier: "id") + + #expect(filename == "id.mp4") + } + + @Test func aPathSeparatorInALabelCannotEscapeTheDirectory() throws { + let filename = try WindowRecordingRequest.make(params: [ + "label": "../../etc/passwd", + "format": "mp4", + ]).outputFilename(identifier: "id") + + #expect(!filename.contains("/")) + #expect(filename == "etc-passwd_id.mp4") + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingRequestTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingRequestTests.swift new file mode 100644 index 000000000000..875dc16a3463 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingRequestTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite struct WindowRecordingRequestTests { + @Test func emptyParametersAreACompleteMP4Request() throws { + let request = try WindowRecordingRequest.make(params: [:]) + + #expect(request.format == .mp4) + #expect(request.target == .window) + #expect(request.windowHandle == nil) + #expect(request.framesPerSecond == 12) + #expect(request.maximumSeconds == 15) + #expect(request.scale == 1) + #expect(request.maximumWidth == nil) + #expect(request.drawsCaptions) + #expect(request.frameBudget == 180) + } + + @Test func gifDefaultsStaySmallerThanMP4Defaults() throws { + let request = try WindowRecordingRequest.make(params: ["format": "GIF"]) + + #expect(request.format == .gif) + #expect(request.framesPerSecond == 8) + #expect(request.scale == 0.5) + #expect(request.maximumWidth == 960) + } + + @Test func explicitValuesOverrideFormatDefaults() throws { + let request = try WindowRecordingRequest.make(params: [ + "format": "gif", + "fps": 15, + "scale": 0.75, + "max_width": 640, + "max_seconds": 4.5, + "captions": false, + "window": " window:2 ", + "label": "settings tour!", + ]) + + #expect(request.framesPerSecond == 15) + #expect(request.scale == 0.75) + #expect(request.maximumWidth == 640) + #expect(request.maximumSeconds == 4.5) + #expect(!request.drawsCaptions) + #expect(request.windowHandle == "window:2") + #expect(request.label == "settings-tour") + #expect(request.frameBudget == 68) + } + + @Test func numbersArriveAsStringsFromV1Callers() throws { + let request = try WindowRecordingRequest.make(params: [ + "fps": "6", + "max_seconds": "2", + "captions": "no", + ]) + + #expect(request.framesPerSecond == 6) + #expect(request.maximumSeconds == 2) + #expect(!request.drawsCaptions) + #expect(request.frameBudget == 12) + } + + @Test func regionAcceptsTextAndArrayForms() throws { + let fromText = try WindowRecordingRequest.make(params: ["region": "10, 20,300,200"]) + let fromArray = try WindowRecordingRequest.make(params: ["region": [10, 20, 300, 200]]) + let expected = WindowRecordingRegion(x: 10, y: 20, width: 300, height: 200) + + #expect(fromText.target == .region(expected)) + #expect(fromArray.target == .region(expected)) + } + + @Test(arguments: [ + "10,20,300", + "10,20,300,200,5", + "", + "a,b,c,d", + ]) + func malformedRegionsAreRejected(region: String) { + #expect(throws: WindowRecordingRequest.Failure.malformedRegion(region)) { + try WindowRecordingRequest.make(params: ["region": region]) + } + } + + @Test func tinyRegionsAreRejected() { + #expect(throws: WindowRecordingRequest.Failure.regionTooSmall) { + try WindowRecordingRequest.make(params: ["region": "0,0,4,200"]) + } + } + + @Test func nonFiniteRegionsAreRejected() { + #expect(throws: (any Error).self) { + try WindowRecordingRequest.make(params: ["region": [0, 0, Double.infinity, 200]]) + } + } + + /// A finite region can still be absurd, and the geometry turns points into + /// pixel integers: a value past `Int`'s range used to take the app down with + /// it rather than come back as a bad parameter. + @Test func hugeRegionsAreRejected() { + #expect(throws: WindowRecordingRequest.Failure.regionTooLarge) { + try WindowRecordingRequest.make(params: ["region": [0, 0, 1e19, 1e19]]) + } + #expect(throws: WindowRecordingRequest.Failure.regionTooLarge) { + try WindowRecordingRequest.make(params: ["region": [-1e19, 0, 100, 100]]) + } + #expect( + WindowRecordingRequest.Failure.regionTooLarge.message + == "region position and size must each stay within 100000 points" + ) + } + + @Test func unknownFormatsNameTheKnownOnes() throws { + let failure = WindowRecordingRequest.Failure.unknownFormat("webm") + + #expect(throws: failure) { + try WindowRecordingRequest.make(params: ["format": "webm"]) + } + #expect(failure.message == "unknown format 'webm'; expected one of mp4, gif") + } + + @Test(arguments: [ + ("fps", 0), + ("fps", 61), + ("max_width", 8), + ]) + func outOfRangeIntegersAreRejected(field: String, value: Int) { + #expect(throws: (any Error).self) { + try WindowRecordingRequest.make(params: [field: value]) + } + } + + @Test func durationIsCappedSoAStuckAgentCannotFillTheDisk() { + #expect(throws: (any Error).self) { + try WindowRecordingRequest.make(params: ["max_seconds": 3600]) + } + #expect(WindowRecordingRequest.allowedSeconds.upperBound == 120) + } + + @Test func scaleNeverUpscales() { + #expect(WindowRecordingRequest.allowedScale.upperBound == 1) + #expect(throws: (any Error).self) { + try WindowRecordingRequest.make(params: ["scale": 2]) + } + } + + @Test func nonNumericValuesSayWhichField() { + #expect(throws: WindowRecordingRequest.Failure.notANumber(field: "fps")) { + try WindowRecordingRequest.make(params: ["fps": "soon"]) + } + } + + /// `Int(Double.nan)` and `Int(1e30)` trap, which takes the whole app down, + /// and `cmux record --fps nan` hands exactly those values to this decoder. + @Test(arguments: ["nan", "inf", "-inf", "1e30", "-1e30"]) + func numbersThatCannotBeAnIntegerAreRejectedRatherThanTrapping(text: String) { + #expect(throws: (any Error).self) { + try WindowRecordingRequest.make(params: ["fps": text]) + } + #expect(throws: (any Error).self) { + try WindowRecordingRequest.make(params: ["max_width": text]) + } + #expect(throws: (any Error).self) { + try WindowRecordingRequest.make(params: ["max_seconds": text]) + } + } + + @Test func outputPathMustBeAbsoluteAndMatchTheFormat() { + #expect(throws: WindowRecordingRequest.Failure.outputPathNotAbsolute("clip.mp4")) { + try WindowRecordingRequest.make(params: ["out": "clip.mp4"]) + } + #expect(throws: WindowRecordingRequest.Failure.outputExtensionMismatch( + path: "/tmp/clip.mov", + format: .mp4 + )) { + try WindowRecordingRequest.make(params: ["out": "/tmp/clip.mov"]) + } + } + + @Test func outputPathIsKeptWhenItMatches() throws { + let request = try WindowRecordingRequest.make(params: [ + "format": "gif", + "out": "/tmp/tour/clip.GIF", + ]) + + #expect(request.outputPath == "/tmp/tour/clip.GIF") + } + + @Test func frameIntervalFollowsTheFrameRate() throws { + let request = try WindowRecordingRequest.make(params: ["fps": 10]) + + #expect(request.frameInterval == 0.1) + } +} diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift index 707b63f4c86c..403c7b112302 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift @@ -86,6 +86,20 @@ struct RemoteRelayCoreRPCPolicyTests { #expect(decision(method, [:]) != .allowed) } + @Test("window recording is never reachable from a relay", arguments: [ + "window.record.start", "window.record.stop", "window.record.status", + "window.record.note", "window.record.list" + ]) + func windowRecordingIsDenied(method: String) { + // Recording films the local screen. A relay peer is authorized for the + // objects of one workspace, not for whatever the local user has on + // display, so these verbs stay off the allowlist entirely. + #expect(decision(method, [:]) == .denied(code: "remote_relay_method_denied", + message: "Relay method is not permitted")) + #expect(decision(method, ["workspace_id": owner.uuidString]) != .allowed) + #expect(RemoteRelayCommandPolicy().permittedMethods(from: [method]).isEmpty) + } + @Test("remote reconnect remains withheld until its surface execution is scoped") func reconnectIsDenied() { #expect(decision("workspace.remote.reconnect", [ diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e0cc5b0ab414..e0153163a5af 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -234915,256 +234915,6 @@ } } }, - "cloudTree.group.cloudMachines": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud Machines" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Maschinen" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Machines cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "أجهزة سحابية" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Máquinas en la nube" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "雲端機器" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "云端机器" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "클라우드 머신" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クラウドマシン" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Mašine u oblaku" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Cloud-maskiner" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Macchine cloud" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ម៉ាស៊ីនលើពពក" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Skymaskiner" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Maszyny w chmurze" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Máquinas na nuvem" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Облачные машины" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เครื่องคลาวด์" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bulut makineleri" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Хмарні машини" - } - } - } - }, - "cloudTree.group.devices": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "My Devices" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Meine Geräte" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Mes appareils" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "أجهزتي" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Mis dispositivos" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "我的裝置" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "我的设备" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "내 기기" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "マイデバイス" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Moji uređaji" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Mine enheder" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "I miei dispositivi" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ឧបករណ៍របស់ខ្ញុំ" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Mine enheter" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Moje urządzenia" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Meus dispositivos" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Мои устройства" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "อุปกรณ์ของฉัน" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Cihazlarım" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Мої пристрої" - } - } - } - }, "machines.operation.checkpoint": { "extractionState": "manual", "localizations": { @@ -237560,419 +237310,6 @@ } } }, - "machines.listUnavailable.stale": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Machine list unavailable — showing last known" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Maschinenliste nicht verfügbar – zeige letzten bekannten Stand" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Liste des machines indisponible — affichage des dernières informations connues" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "قائمة الأجهزة غير متاحة — يتم عرض آخر حالة معروفة" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Lista de máquinas no disponible — se muestra el último estado conocido" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法取得機器清單 — 顯示上次已知的狀態" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "机器列表不可用 —— 显示的是最后已知状态" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "머신 목록을 사용할 수 없음 — 마지막으로 확인된 상태 표시 중" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "マシン一覧を取得できません — 最後の状態を表示中" - } - } - } - }, - "machines.listUnavailable.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Your machines are unchanged. cmux couldn’t load the list from the Cloud service and retries on its own." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Deine Maschinen sind unverändert. cmux konnte die Liste nicht vom Cloud-Dienst laden und versucht es selbstständig erneut." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Vos machines ne sont pas modifiées. cmux n’a pas pu charger la liste depuis le service Cloud et va réessayer automatiquement." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لم تتغير أجهزتك. تعذر على cmux تحميل القائمة من الخدمة السحابية، وسيعيد المحاولة تلقائيًا." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Tus máquinas no han cambiado. cmux no pudo cargar la lista desde el servicio de la nube y volverá a intentarlo automáticamente." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "您的機器沒有任何變動。cmux 無法從雲端服務載入清單,會自動重試。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "你的机器没有任何变化。cmux 无法从云端服务加载列表,会自动重试。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "머신은 변경되지 않았습니다. cmux가 클라우드 서비스에서 목록을 불러오지 못했으며 자동으로 다시 시도합니다." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "マシンは変更されていません。cmux はクラウドサービスから一覧を読み込めませんでした。自動的に再試行します。" - } - } - } - }, - "machines.listUnavailable.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Can’t load the machine list" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Maschinenliste kann nicht geladen werden" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Impossible de charger la liste des machines" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذر تحميل قائمة الأجهزة" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se puede cargar la lista de máquinas" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法載入機器清單" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法加载机器列表" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "머신 목록을 불러올 수 없습니다" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "マシン一覧を読み込めません" - } - } - } - }, - "machines.offline.stale": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Offline — showing last known" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Offline – zeige letzten bekannten Stand" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Hors ligne — affichage des dernières informations connues" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "غير متصل — يتم عرض آخر حالة معروفة" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sin conexión — se muestra el último estado conocido" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "離線 — 顯示上次已知的狀態" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "离线 —— 显示的是最后已知状态" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "오프라인 — 마지막으로 확인된 상태 표시 중" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "オフライン — 最後の状態を表示中" - } - } - } - }, - "machines.offline.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud machines load when this Mac is back online." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Maschinen werden geladen, sobald dieser Mac wieder online ist." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Les machines Cloud se chargeront dès que ce Mac sera de nouveau en ligne." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "سيتم تحميل الأجهزة السحابية عندما يعود جهاز Mac هذا إلى الاتصال بالإنترنت." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Las máquinas en la nube se cargarán cuando este Mac vuelva a estar en línea." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "這台 Mac 恢復連線後,就會載入雲端機器。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "这台 Mac 恢复联网后,就会加载云端机器。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이 Mac이 다시 온라인 상태가 되면 클라우드 머신을 불러옵니다." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "この Mac がオンラインに戻ると Cloud マシンを読み込みます。" - } - } - } - }, - "machines.offline.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Waiting for network" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Warte auf Netzwerk" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "En attente du réseau" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "في انتظار الشبكة" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Esperando la red" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在等待網路" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在等待网络" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "네트워크 대기 중" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ネットワークを待機中" - } - } - } - }, - "machines.reconnecting.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reconnecting to Cloud…" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbindung zur Cloud wird wiederhergestellt…" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Reconnexion au Cloud…" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "جارٍ إعادة الاتصال بالسحابة…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reconectando con la nube…" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在重新連線至雲端…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在重新连接云端…" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "클라우드에 다시 연결하는 중…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クラウドに再接続中…" - } - } - } - }, "markdown.copyConfirm.html": { "extractionState": "manual", "localizations": { @@ -260698,381 +260035,6 @@ } } }, - "cli.auth.status.teamNotLoaded": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": " team: not loaded" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": " チーム: 未読み込み" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": " 团队: 未加载" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": " Team: nicht geladen" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": " équipe: non chargée" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": " الفريق: لم يتم تحميله" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": " equipo: no cargado" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": " 團隊: 未載入" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": " 팀: 로드되지 않음" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": " tim: nije učitan" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": " team: ikke indlæst" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": " team: non caricato" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": " ក្រុម៖ មិនទាន់បានផ្ទុក" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": " team: ikke lastet" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": " zespół: nie załadowano" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": " equipe: não carregada" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": " команда: не загружена" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": " ทีม: ยังไม่ได้โหลด" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": " ekip: yüklenmedi" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": " команда: не завантажена" - } - } - } - }, - "cli.auth.status.teamNotLoadedRetrying": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": " team: not loaded (retrying)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": " チーム: 未読み込み(再試行中)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": " 团队: 未加载(正在重试)" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": " Team: nicht geladen (wird erneut versucht)" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": " équipe: non chargée (nouvel essai)" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": " الفريق: لم يتم تحميله (جارٍ إعادة المحاولة)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": " equipo: no cargado (reintentando)" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": " 團隊: 未載入(重試中)" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": " 팀: 로드되지 않음 (재시도 중)" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": " tim: nije učitan (ponovni pokušaj)" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": " team: ikke indlæst (prøver igen)" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": " team: non caricato (nuovo tentativo)" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": " ក្រុម៖ មិនទាន់បានផ្ទុក (កំពុងព្យាយាមម្ដងទៀត)" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": " team: ikke lastet (prøver igjen)" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": " zespół: nie załadowano (ponawianie próby)" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": " equipe: não carregada (tentando novamente)" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": " команда: не загружена (повторная попытка)" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": " ทีม: ยังไม่ได้โหลด (กำลังลองอีกครั้ง)" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": " ekip: yüklenmedi (yeniden deneniyor)" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": " команда: не завантажена (повторна спроба)" - } - } - } - }, - "mobile.pairing.error.teamUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not load your cmux team. Check your internet connection, then try again." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux チームを読み込めませんでした。インターネット接続を確認してから、もう一度お試しください。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法加载你的 cmux 团队。请检查网络连接,然后重试。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Dein cmux-Team konnte nicht geladen werden. Prüfe deine Internetverbindung und versuche es erneut." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Impossible de charger votre équipe cmux. Vérifiez votre connexion Internet, puis réessayez." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذّر تحميل فريق cmux الخاص بك. تحقّق من اتصالك بالإنترنت، ثم حاول مرة أخرى." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo cargar tu equipo de cmux. Comprueba tu conexión a internet e inténtalo de nuevo." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法載入你的 cmux 團隊。請檢查網路連線,然後再試一次。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux 팀을 불러올 수 없습니다. 인터넷 연결을 확인한 후 다시 시도하세요." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Nije moguće učitati vaš cmux tim. Provjerite internetsku vezu i pokušajte ponovo." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Din cmux-team kunne ikke indlæses. Kontrollér din internetforbindelse, og prøv igen." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Impossibile caricare il tuo team cmux. Controlla la connessione Internet e riprova." - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "មិនអាចផ្ទុកក្រុម cmux របស់អ្នកបានទេ។ សូមពិនិត្យការតភ្ជាប់អ៊ីនធឺណិត ហើយព្យាយាមម្ដងទៀត។" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Kunne ikke laste inn cmux-teamet ditt. Kontroller internettilkoblingen, og prøv igjen." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nie udało się załadować Twojego zespołu cmux. Sprawdź połączenie z internetem i spróbuj ponownie." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Não foi possível carregar sua equipe cmux. Verifique sua conexão com a internet e tente novamente." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Не удалось загрузить вашу команду cmux. Проверьте подключение к интернету и попробуйте ещё раз." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ไม่สามารถโหลดทีม cmux ของคุณได้ ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตแล้วลองอีกครั้ง" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "cmux ekibiniz yüklenemedi. İnternet bağlantınızı kontrol edip tekrar deneyin." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Не вдалося завантажити вашу команду cmux. Перевірте підключення до інтернету й спробуйте ще раз." - } - } - } -}, "mobile.pairing.error.noTicket": { "extractionState": "manual", "localizations": { @@ -565029,6 +563991,669 @@ } } }, + "cloudTree.group.cloudMachines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud Machines" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Maschinen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Machines cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أجهزة سحابية" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Máquinas en la nube" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雲端機器" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "云端机器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클라우드 머신" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドマシン" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Mašine u oblaku" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Cloud-maskiner" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Macchine cloud" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម៉ាស៊ីនលើពពក" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skymaskiner" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Maszyny w chmurze" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Máquinas na nuvem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Облачные машины" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เครื่องคลาวด์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bulut makineleri" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хмарні машини" + } + } + } + }, + "cloudTree.group.devices": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My Devices" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Geräte" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mes appareils" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أجهزتي" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mis dispositivos" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "我的裝置" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "我的设备" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 기기" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マイデバイス" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Moji uređaji" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mine enheder" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I miei dispositivi" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ឧបករណ៍របស់ខ្ញុំ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mine enheter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Moje urządzenia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Meus dispositivos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мои устройства" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อุปกรณ์ของฉัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cihazlarım" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Мої пристрої" + } + } + } + }, + "machines.listUnavailable.stale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Machine list unavailable — showing last known" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maschinenliste nicht verfügbar – zeige letzten bekannten Stand" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liste des machines indisponible — affichage des dernières informations connues" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قائمة الأجهزة غير متاحة — يتم عرض آخر حالة معروفة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lista de máquinas no disponible — se muestra el último estado conocido" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法取得機器清單 — 顯示上次已知的狀態" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "机器列表不可用 —— 显示的是最后已知状态" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신 목록을 사용할 수 없음 — 마지막으로 확인된 상태 표시 중" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシン一覧を取得できません — 最後の状態を表示中" + } + } + } + }, + "machines.listUnavailable.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your machines are unchanged. cmux couldn’t load the list from the Cloud service and retries on its own." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine Maschinen sind unverändert. cmux konnte die Liste nicht vom Cloud-Dienst laden und versucht es selbstständig erneut." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vos machines ne sont pas modifiées. cmux n’a pas pu charger la liste depuis le service Cloud et va réessayer automatiquement." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم تتغير أجهزتك. تعذر على cmux تحميل القائمة من الخدمة السحابية، وسيعيد المحاولة تلقائيًا." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tus máquinas no han cambiado. cmux no pudo cargar la lista desde el servicio de la nube y volverá a intentarlo automáticamente." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您的機器沒有任何變動。cmux 無法從雲端服務載入清單,會自動重試。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的机器没有任何变化。cmux 无法从云端服务加载列表,会自动重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신은 변경되지 않았습니다. cmux가 클라우드 서비스에서 목록을 불러오지 못했으며 자동으로 다시 시도합니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシンは変更されていません。cmux はクラウドサービスから一覧を読み込めませんでした。自動的に再試行します。" + } + } + } + }, + "machines.listUnavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can’t load the machine list" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maschinenliste kann nicht geladen werden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de charger la liste des machines" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تحميل قائمة الأجهزة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede cargar la lista de máquinas" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法載入機器清單" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载机器列表" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신 목록을 불러올 수 없습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシン一覧を読み込めません" + } + } + } + }, + "machines.offline.stale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Offline — showing last known" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Offline – zeige letzten bekannten Stand" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hors ligne — affichage des dernières informations connues" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير متصل — يتم عرض آخر حالة معروفة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin conexión — se muestra el último estado conocido" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "離線 — 顯示上次已知的狀態" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "离线 —— 显示的是最后已知状态" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오프라인 — 마지막으로 확인된 상태 표시 중" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフライン — 最後の状態を表示中" + } + } + } + }, + "machines.offline.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud machines load when this Mac is back online." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Maschinen werden geladen, sobald dieser Mac wieder online ist." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les machines Cloud se chargeront dès que ce Mac sera de nouveau en ligne." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيتم تحميل الأجهزة السحابية عندما يعود جهاز Mac هذا إلى الاتصال بالإنترنت." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las máquinas en la nube se cargarán cuando este Mac vuelva a estar en línea." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這台 Mac 恢復連線後,就會載入雲端機器。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 Mac 恢复联网后,就会加载云端机器。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac이 다시 온라인 상태가 되면 클라우드 머신을 불러옵니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac がオンラインに戻ると Cloud マシンを読み込みます。" + } + } + } + }, + "machines.offline.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for network" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warte auf Netzwerk" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente du réseau" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "في انتظار الشبكة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando la red" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在等待網路" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待网络" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크 대기 중" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワークを待機中" + } + } + } + }, + "machines.reconnecting.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to Cloud…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung zur Cloud wird wiederhergestellt…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnexion au Cloud…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إعادة الاتصال بالسحابة…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconectando con la nube…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在重新連線至雲端…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重新连接云端…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클라우드에 다시 연결하는 중…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドに再接続中…" + } + } + } + }, "devices.link.error.notDiscoverable": { "extractionState": "manual", "localizations": { @@ -565904,244 +565529,6 @@ } } }, - "command.newPaneAutoLayout.title": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "لوحة جديدة (تخطيط تلقائي)" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Novi panel (automatski raspored)" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Nyt panel (automatisk layout)" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Neuer Bereich (automatisches Layout)" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "New Pane (Auto Layout)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nuevo panel (diseño automático)" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Nouveau panneau (disposition automatique)" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Nuovo pannello (layout automatico)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいペイン(自動レイアウト)" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "새 패널 (자동 레이아웃)" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Nytt panel (automatisk oppsett)" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nowy panel (układ automatyczny)" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Novo Painel (Layout Automático)" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Новая панель (автоматическая раскладка)" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "บานหน้าต่างใหม่ (จัดวางอัตโนมัติ)" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Yeni Bölme (Otomatik Düzen)" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Нова панель (автоматичне розташування)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "新建窗格(自动布局)" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "新增窗格(自動佈局)" - } - } - } - }, - "shortcut.newPaneAutoLayout.label": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "لوحة جديدة (تخطيط تلقائي)" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Novi panel (automatski raspored)" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Nyt panel (automatisk layout)" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Neuer Bereich (automatisches Layout)" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "New Pane (Auto Layout)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nuevo panel (diseño automático)" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Nouveau panneau (disposition automatique)" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Nuovo pannello (layout automatico)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいペイン(自動レイアウト)" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "새 패널 (자동 레이아웃)" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Nytt panel (automatisk oppsett)" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nowy panel (układ automatyczny)" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Novo Painel (Layout Automático)" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Новая панель (автоматическая раскладка)" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "บานหน้าต่างใหม่ (จัดวางอัตโนมัติ)" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Yeni Bölme (Otomatik Düzen)" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Нова панель (автоматичне розташування)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "新建窗格(自动布局)" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "新增窗格(自動佈局)" - } - } - } - }, "socket.terminal.notRunning.awaitingRestore": { "extractionState": "manual", "localizations": { @@ -566767,2838 +566154,3156 @@ } } }, - "cli.restoreSession.help": { + "cli.help.record": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Usage: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nReopen the previous saved cmux session.\n\nIf the app is already running, this restores the last saved session into the current app.\nIf the app is not running, this launches cmux and lets startup restore reopen the saved session.\n\nEach cmux install (stable, nightly, rc, staging, tagged debug builds) saves its own session.\n--from Reopen another install's saved session in this running cmux, for example\n after trying nightly and switching back to stable. The session opens as\n additional windows; the other install's saved file is only read.\n--from Reopen a session file written by --export.\n--export Write this cmux's saved session to a file. Pass --force to replace an\n existing file.\n\n--from and --export require cmux to be running." + "value": "Usage: cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\nRecord a cmux window to an mp4 or gif, for a pull request or a bug\nreport. Only cmux's own windows can be recorded, and no Screen\nRecording permission is involved.\n\nFlags for start:\n --format mp4|gif Output format (default: mp4)\n --gif Shorthand for --format gif\n --fps <1-30> Frames per second (default: 12 for mp4, 8 for gif)\n --max-seconds Stop by itself after n seconds, 0.5-120 (default: 15)\n --scale <0.1-1> Scale the output (default: 1 for mp4, 0.5 for gif)\n --max-width <64-4096> Cap the output width, keeping the aspect ratio\n --region Record part of the window, in window points\n --out Write here instead of a temporary directory\n --label Name the file\n --no-captions Do not draw `record note` captions into the clip\n --window Window to record (default: the frontmost one)\n\nOutput: ` `, or the full response with --json.\n\nA recording stops itself at --max-seconds, so a clip is never left\nrunning by an agent that goes away. One recording at a time.\n\nExample:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Verwendung: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nDie zuletzt gespeicherte cmux-Sitzung erneut öffnen.\n\nLäuft die App bereits, wird die zuletzt gespeicherte Sitzung in der aktuellen App wiederhergestellt.\nLäuft die App nicht, wird cmux gestartet und die gespeicherte Sitzung beim Start wiederhergestellt.\n\nJede cmux-Installation (stable, nightly, rc, staging, getaggte Debug-Builds) speichert ihre eigene Sitzung.\n--from Die gespeicherte Sitzung einer anderen Installation in diesem laufenden cmux öffnen,\n z. B. nachdem Sie nightly ausprobiert haben und zu stable zurückkehren. Die Sitzung\n öffnet sich in zusätzlichen Fenstern; die Datei der anderen Installation wird nur gelesen.\n--from Eine mit --export geschriebene Sitzungsdatei öffnen.\n--export Die gespeicherte Sitzung dieses cmux in eine Datei schreiben. Mit --force wird eine\n vorhandene Datei ersetzt.\n\n--from und --export setzen ein laufendes cmux voraus." + "value": "الاستخدام: cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\nتسجيل نافذة cmux بصيغة mp4 أو gif، من أجل طلب سحب أو تقرير خطأ.\nلا يمكن تسجيل سوى نوافذ cmux نفسها،\nولا يتطلب ذلك إذن تسجيل الشاشة.\n\nخيارات start:\n --format mp4|gif صيغة الإخراج (الافتراضي: mp4)\n --gif اختصار لـ --format gif\n --fps <1-30> الإطارات في الثانية (الافتراضي: 12 لـ mp4، 8 لـ gif)\n --max-seconds يتوقف من تلقاء نفسه بعد n ثانية، 0.5-120 (الافتراضي: 15)\n --scale <0.1-1> تحجيم الإخراج (الافتراضي: 1 لـ mp4، 0.5 لـ gif)\n --max-width <64-4096> تحديد أقصى عرض للإخراج مع الحفاظ على نسبة الأبعاد\n --region تسجيل جزء من النافذة، بوحدات نقاط النافذة\n --out الكتابة هنا بدلاً من دليل مؤقت\n --label تسمية الملف\n --no-captions عدم رسم تعليقات `record note` داخل المقطع\n --window النافذة المراد تسجيلها (الافتراضي: النافذة الأمامية)\n\nالإخراج: ` `، أو الاستجابة الكاملة مع --json.\n\nيتوقف التسجيل من تلقاء نفسه عند --max-seconds، حتى لا يترك وكيل غادر\nمقطعًا قيد التسجيل. تسجيل واحد في كل مرة.\n\nمثال:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "Utilisation : cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nRouvrir la dernière session cmux enregistrée.\n\nSi l'app est déjà ouverte, la dernière session enregistrée est restaurée dans l'app actuelle.\nSi l'app n'est pas ouverte, cmux est lancé et la restauration au démarrage rouvre la session enregistrée.\n\nChaque installation de cmux (stable, nightly, rc, staging, builds debug avec tag) enregistre sa propre session.\n--from Rouvrir la session enregistrée d'une autre installation dans ce cmux en cours, par exemple\n après avoir essayé nightly puis être revenu à stable. La session s'ouvre dans des fenêtres\n supplémentaires ; le fichier de l'autre installation est seulement lu.\n--from Rouvrir un fichier de session écrit par --export.\n--export Écrire la session enregistrée de ce cmux dans un fichier. Ajoutez --force pour remplacer\n un fichier existant.\n\n--from et --export nécessitent que cmux soit ouvert." + "value": "Verwendung: cmux record [start] [Flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\nEin cmux-Fenster als mp4 oder gif aufzeichnen, für einen Pull Request\noder einen Fehlerbericht. Nur cmux-eigene Fenster können aufgezeichnet\nwerden, und es ist keine Bildschirmaufnahme-Berechtigung beteiligt.\n\nFlags für start:\n --format mp4|gif Ausgabeformat (Standard: mp4)\n --gif Kurzform für --format gif\n --fps <1-30> Bilder pro Sekunde (Standard: 12 für mp4, 8 für gif)\n --max-seconds Nach n Sekunden selbst beenden, 0.5-120 (Standard: 15)\n --scale <0.1-1> Ausgabe skalieren (Standard: 1 für mp4, 0.5 für gif)\n --max-width <64-4096> Ausgabebreite begrenzen, Seitenverhältnis bleibt\n --region Teil des Fensters aufzeichnen, in Fensterpunkten\n --out Hierhin schreiben statt in ein temporäres Verzeichnis\n --label Datei benennen\n --no-captions Keine `record note`-Untertitel in den Clip zeichnen\n --window Fenster zum Aufzeichnen (Standard: das vorderste)\n\nAusgabe: ` `, oder mit --json die vollständige Antwort.\n\nEine Aufzeichnung beendet sich bei --max-seconds selbst, damit ein Agent, der\nverschwindet, keinen laufenden Clip hinterlässt. Eine Aufzeichnung zur Zeit.\n\nBeispiel:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, "es": { "stringUnit": { "state": "translated", - "value": "Uso: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nVuelve a abrir la última sesión guardada de cmux.\n\nSi la app ya está abierta, restaura la última sesión guardada en la app actual.\nSi la app no está abierta, inicia cmux y deja que la restauración de inicio vuelva a abrir la sesión guardada.\n\nCada instalación de cmux (stable, nightly, rc, staging, builds de depuración con etiqueta) guarda su propia sesión.\n--from Abre en este cmux la sesión guardada de otra instalación, por ejemplo después de\n probar nightly y volver a stable. La sesión se abre en ventanas adicionales; el archivo\n de la otra instalación solo se lee.\n--from Abre un archivo de sesión escrito con --export.\n--export Escribe en un archivo la sesión guardada de este cmux. Usa --force para reemplazar un\n archivo existente.\n\n--from y --export requieren que cmux esté abierto." + "value": "Uso: cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\nGraba una ventana de cmux en mp4 o gif, para un pull request o un informe\nde error. Solo se pueden grabar las ventanas propias de cmux, y no\ninterviene ningún permiso de grabación de pantalla.\n\nFlags para start:\n --format mp4|gif Formato de salida (predeterminado: mp4)\n --gif Atajo para --format gif\n --fps <1-30> Fotogramas por segundo (predeterminado: 12 para mp4, 8 para gif)\n --max-seconds Se detiene solo tras n segundos, 0.5-120 (predeterminado: 15)\n --scale <0.1-1> Escala la salida (predeterminado: 1 para mp4, 0.5 para gif)\n --max-width <64-4096> Limita el ancho de salida manteniendo la proporción\n --region Graba parte de la ventana, en puntos de ventana\n --out Escribe aquí en lugar de en un directorio temporal\n --label Da nombre al archivo\n --no-captions No dibujar los subtítulos de `record note` en el clip\n --window Ventana que se graba (predeterminado: la del frente)\n\nSalida: ` `, o la respuesta completa con --json.\n\nUna grabación se detiene sola en --max-seconds, para que un agente que\ndesaparece nunca deje un clip en marcha. Una grabación a la vez.\n\nEjemplo:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, - "ja": { + "fr": { "stringUnit": { "state": "translated", - "value": "使い方: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n前回保存した cmux セッションを再度開きます。\n\nアプリがすでに起動している場合は、最後に保存したセッションを現在のアプリに復元します。\nアプリが起動していない場合は、cmux を起動し、起動時の復元で保存済みセッションを開きます。\n\ncmux の各インストール(stable、nightly、rc、staging、タグ付き debug ビルド)はそれぞれ独自のセッションを保存します。\n--from 別のインストールで保存したセッションを、起動中のこの cmux で開きます。たとえば nightly を\n 試したあと stable に戻る場合に使います。セッションは追加のウインドウとして開き、別の\n インストールのファイルは読み取るだけです。\n--from --export で書き出したセッションファイルを開きます。\n--export この cmux の保存済みセッションをファイルに書き出します。既存のファイルを置き換えるには\n --force を指定します。\n\n--from と --export を使うには cmux が起動している必要があります。" + "value": "Utilisation : cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\nEnregistrer une fenêtre cmux en mp4 ou gif, pour une pull request ou un\nrapport de bug. Seules les fenêtres de cmux peuvent être enregistrées, et\naucune autorisation d'enregistrement de l'écran n'intervient.\n\nFlags pour start :\n --format mp4|gif Format de sortie (par défaut : mp4)\n --gif Raccourci pour --format gif\n --fps <1-30> Images par seconde (par défaut : 12 pour mp4, 8 pour gif)\n --max-seconds S'arrête seul après n secondes, 0.5-120 (par défaut : 15)\n --scale <0.1-1> Mettre la sortie à l'échelle (par défaut : 1 pour mp4, 0.5 pour gif)\n --max-width <64-4096> Limiter la largeur de sortie en gardant les proportions\n --region N'enregistrer qu'une partie de la fenêtre, en points fenêtre\n --out Écrire ici au lieu d'un répertoire temporaire\n --label Nommer le fichier\n --no-captions Ne pas dessiner les légendes `record note` dans le clip\n --window Fenêtre à enregistrer (par défaut : celle au premier plan)\n\nSortie : ` `, ou la réponse complète avec --json.\n\nUn enregistrement s'arrête de lui-même à --max-seconds, afin qu'un agent qui\ndisparaît ne laisse jamais un clip en cours. Un seul enregistrement à la fois.\n\nExemple :\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "الاستخدام: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nأعد فتح آخر جلسة cmux محفوظة.\n\nإذا كان التطبيق يعمل بالفعل، فستُستعاد آخر جلسة محفوظة في التطبيق الحالي.\nإذا لم يكن التطبيق يعمل، فسيُشغَّل cmux وتعيد استعادة بدء التشغيل فتح الجلسة المحفوظة.\n\nيحفظ كل تثبيت من cmux (stable وnightly وrc وstaging وإصدارات debug الموسومة) جلسته الخاصة.\n--from افتح الجلسة المحفوظة لتثبيت آخر في cmux الذي يعمل الآن، مثلًا\n بعد تجربة nightly والعودة إلى stable. تُفتح الجلسة في\n نوافذ إضافية، ويُقرأ ملف التثبيت الآخر فقط.\n--from افتح ملف جلسة كتبه الأمر --export.\n--export اكتب الجلسة المحفوظة لهذا cmux في ملف. استخدم --force لاستبدال\n ملف موجود.\n\nيتطلب --from و--export أن يكون cmux قيد التشغيل." + "value": "使用方法: cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\nプルリクエストやバグ報告のために、cmux のウィンドウを mp4 または gif\nで録画します。録画できるのは cmux 自身のウィンドウだけで、画面収録の\n権限は必要ありません。\n\nstart のフラグ:\n --format mp4|gif 出力形式(既定: mp4)\n --gif --format gif の短縮形\n --fps <1-30> 1 秒あたりのフレーム数(既定: mp4 は 12、gif は 8)\n --max-seconds n 秒後に自動停止、0.5-120(既定: 15)\n --scale <0.1-1> 出力を拡大縮小(既定: mp4 は 1、gif は 0.5)\n --max-width <64-4096> 縦横比を保ったまま出力の幅を制限\n --region ウィンドウの一部だけを録画(ウィンドウポイント単位)\n --out 一時ディレクトリではなくこのパスに書き出す\n --label ファイルに名前を付ける\n --no-captions `record note` のキャプションをクリップに描画しない\n --window 録画するウィンドウ(既定: 最前面のウィンドウ)\n\n出力: ` `、または --json で完全なレスポンス。\n\n録画は --max-seconds で自動停止するので、いなくなったエージェントが\n録画を実行したままにすることはありません。録画は同時に 1 つだけです。\n\n例:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "用法: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n重新開啟上次儲存的 cmux 工作階段。\n\n如果 App 已在執行,會將上次儲存的工作階段還原到目前的 App 中。\n如果 App 未執行,會啟動 cmux,並由啟動還原重新開啟已儲存的工作階段。\n\n每個 cmux 安裝(stable、nightly、rc、staging、帶標籤的 debug 建置)都會儲存自己的工作階段。\n--from 在執行中的 cmux 開啟另一個安裝儲存的工作階段,例如\n 試用 nightly 後切回 stable。工作階段會以\n 額外視窗開啟;另一個安裝的檔案只會被讀取。\n--from 開啟由 --export 寫出的工作階段檔案。\n--export 將此 cmux 儲存的工作階段寫入檔案。使用 --force 可取代\n 既有檔案。\n\n--from 和 --export 需要 cmux 正在執行。" + "value": "사용법: cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\n풀 리퀘스트나 버그 리포트에 쓰려고 cmux 창을 mp4 또는 gif로\n녹화합니다. cmux 자신의 창만 녹화할 수 있고, 화면 기록 권한은\n필요하지 않습니다.\n\nstart 플래그:\n --format mp4|gif 출력 형식 (기본값: mp4)\n --gif --format gif의 단축형\n --fps <1-30> 초당 프레임 수 (기본값: mp4는 12, gif는 8)\n --max-seconds n초 후 스스로 정지, 0.5-120 (기본값: 15)\n --scale <0.1-1> 출력 크기 조정 (기본값: mp4는 1, gif는 0.5)\n --max-width <64-4096> 가로 세로 비율을 유지하며 출력 너비 제한\n --region 창의 일부만 녹화, 창 포인트 단위\n --out 임시 디렉터리 대신 이 경로에 기록\n --label 파일 이름 지정\n --no-captions `record note` 캡션을 클립에 그리지 않음\n --window 녹화할 창 (기본값: 맨 앞 창)\n\n출력: ` `, 또는 --json으로 전체 응답.\n\n녹화는 --max-seconds에서 스스로 멈추므로 사라진 에이전트가 녹화를 계속\n남겨 두는 일은 없습니다. 한 번에 하나의 녹화만 가능합니다.\n\n예:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "用法: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n重新打开上次保存的 cmux 会话。\n\n如果应用已在运行,会将上次保存的会话恢复到当前应用中。\n如果应用未运行,会启动 cmux,并由启动恢复重新打开保存的会话。\n\n每个 cmux 安装(stable、nightly、rc、staging、带标签的 debug 构建)都会保存自己的会话。\n--from 在正在运行的 cmux 中打开另一个安装保存的会话,例如\n 试用 nightly 后切回 stable。会话会以\n 额外窗口打开;另一个安装的会话文件只会被读取。\n--from 打开由 --export 写出的会话文件。\n--export 将此 cmux 保存的会话写入文件。使用 --force 可替换\n 已有文件。\n\n--from 和 --export 需要 cmux 正在运行。" + "value": "使用方法: cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\n将 cmux 窗口录制为 mp4 或 gif,用于 pull request 或错误报告。\n只能录制 cmux 自己的窗口,\n而且不需要任何屏幕录制权限。\n\nstart 的标志:\n --format mp4|gif 输出格式(默认: mp4)\n --gif --format gif 的简写\n --fps <1-30> 每秒帧数(默认: mp4 为 12,gif 为 8)\n --max-seconds n 秒后自行停止,0.5-120(默认: 15)\n --scale <0.1-1> 缩放输出(默认: mp4 为 1,gif 为 0.5)\n --max-width <64-4096> 限制输出宽度并保持宽高比\n --region 只录制窗口的一部分,以窗口点为单位\n --out 写入此处而非临时目录\n --label 为文件命名\n --no-captions 不要把 `record note` 字幕画进视频\n --window 要录制的窗口(默认: 最前面的窗口)\n\n输出: ` `,或用 --json 获取完整响应。\n\n录制会在 --max-seconds 时自行停止,因此离开的代理不会留下仍在录制的\n视频。同一时间只能有一份录制。\n\n示例:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "사용법: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n마지막으로 저장된 cmux 세션을 다시 엽니다.\n\n앱이 이미 실행 중이면 마지막으로 저장된 세션을 현재 앱에 복원합니다.\n앱이 실행 중이 아니면 cmux를 실행하고 시작 복원이 저장된 세션을 다시 열도록 합니다.\n\n각 cmux 설치(stable, nightly, rc, staging, 태그가 지정된 debug 빌드)는 자체 세션을 저장합니다.\n--from 다른 설치에 저장된 세션을 실행 중인 이 cmux에서 엽니다. 예를 들어\n nightly를 사용해 본 뒤 stable로 돌아올 때 사용합니다. 세션은\n 추가 창으로 열리며, 다른 설치의 파일은 읽기만 합니다.\n--from --export로 작성한 세션 파일을 엽니다.\n--export 이 cmux에 저장된 세션을 파일로 씁니다. 기존 파일을 바꾸려면\n --force를 사용하세요.\n\n--from과 --export를 사용하려면 cmux가 실행 중이어야 합니다." + "value": "使用方法: cmux record [start] [flags]\n cmux record stop|status [--id ]\n cmux record note [--id ] \n cmux record list\n\n將 cmux 視窗錄製成 mp4 或 gif,用於 pull request 或錯誤報告。\n只能錄製 cmux 自己的視窗,\n而且不需要任何螢幕錄製權限。\n\nstart 的旗標:\n --format mp4|gif 輸出格式(預設: mp4)\n --gif --format gif 的簡寫\n --fps <1-30> 每秒影格數(預設: mp4 為 12,gif 為 8)\n --max-seconds n 秒後自行停止,0.5-120(預設: 15)\n --scale <0.1-1> 縮放輸出(預設: mp4 為 1,gif 為 0.5)\n --max-width <64-4096> 限制輸出寬度並維持長寬比\n --region 只錄製視窗的一部分,以視窗點為單位\n --out 寫入此處而非暫存目錄\n --label 為檔案命名\n --no-captions 不要將 `record note` 字幕畫進影片\n --window 要錄製的視窗(預設: 最前面的視窗)\n\n輸出: ` `,或以 --json 取得完整回應。\n\n錄製會在 --max-seconds 時自行停止,因此離開的代理程式不會留下仍在\n錄製的影片。同一時間只能有一份錄製。\n\n範例:\n cmux record start --gif --max-seconds 8 --label sidebar-drag\n cmux record note \"dragging the workspace\"\n cmux record stop" } } } }, - "session.export.error.destinationExists": { + "cli.record.error.formatConflict": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ already exists. Pass --force to replace it." + "value": "record start: --gif conflicts with --format" } }, "ar": { "stringUnit": { "state": "translated", - "value": "%@ موجود بالفعل. استخدم --force لاستبداله." + "value": "record start: لا يمكن دمج --gif مع --format" } }, "de": { "stringUnit": { "state": "translated", - "value": "%@ ist bereits vorhanden. Verwenden Sie --force, um die Datei zu ersetzen." + "value": "record start: --gif kann nicht mit --format kombiniert werden" } }, "es": { "stringUnit": { "state": "translated", - "value": "%@ ya existe. Usa --force para reemplazarlo." + "value": "record start: --gif no se puede combinar con --format" } }, "fr": { "stringUnit": { "state": "translated", - "value": "%@ existe déjà. Ajoutez --force pour le remplacer." + "value": "record start: --gif ne peut pas être combiné avec --format" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ はすでに存在します。置き換えるには --force を指定してください。" + "value": "record start: --gif は --format と併用できません" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@ 파일이 이미 있습니다. 바꾸려면 --force를 사용하세요." + "value": "record start: --gif은 --format과 함께 사용할 수 없습니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 已存在。使用 --force 以替换它。" + "value": "record start: --gif 不能与 --format 同用" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%@ 已存在。使用 --force 以取代它。" + "value": "record start: --gif 無法與 --format 同時使用" } } } }, - "session.export.error.destinationIsLive": { + "cli.record.error.noteText": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ is this cmux's own session file. Choose another path." + "value": "record note: text is required" } }, "ar": { "stringUnit": { "state": "translated", - "value": "%@ هو ملف الجلسة الخاص بهذا الـ cmux. اختر مسارًا آخر." + "value": "record note: النص مطلوب" } }, "de": { "stringUnit": { "state": "translated", - "value": "%@ ist die eigene Sitzungsdatei dieses cmux. Wählen Sie einen anderen Pfad." + "value": "record note: Text ist erforderlich" } }, "es": { "stringUnit": { "state": "translated", - "value": "%@ es el archivo de sesión de este cmux. Elige otra ruta." + "value": "record note: se requiere el texto" } }, "fr": { "stringUnit": { "state": "translated", - "value": "%@ est le fichier de session de ce cmux. Choisissez un autre chemin." + "value": "record note : le texte est requis" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ はこの cmux 自身のセッションファイルです。別のパスを指定してください。" + "value": "record note: テキストが必要です" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@ 파일은 이 cmux의 세션 파일입니다. 다른 경로를 선택하세요." + "value": "record note: 텍스트가 필요합니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 是此 cmux 自己的会话文件。请选择其他路径。" + "value": "record note: 必须提供文本" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%@ 是此 cmux 本身的工作階段檔案。請選擇其他路徑。" + "value": "record note: 必須提供文字" } } } }, - "session.export.error.noSnapshot": { + "cli.record.error.unexpectedArguments": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux has not saved a session yet. Try again in a few seconds." + "value": "record %@: unexpected arguments: %@" } }, "ar": { "stringUnit": { "state": "translated", - "value": "لم يحفظ cmux أي جلسة بعد. حاول مرة أخرى بعد بضع ثوانٍ." + "value": "record %@: وسائط غير متوقعة: %@" } }, "de": { "stringUnit": { "state": "translated", - "value": "cmux hat noch keine Sitzung gespeichert. Versuchen Sie es in ein paar Sekunden erneut." + "value": "record %@: unerwartete Argumente: %@" } }, "es": { "stringUnit": { "state": "translated", - "value": "cmux aún no ha guardado ninguna sesión. Inténtalo de nuevo en unos segundos." + "value": "record %@: argumentos inesperados: %@" } }, "fr": { "stringUnit": { "state": "translated", - "value": "cmux n'a pas encore enregistré de session. Réessayez dans quelques secondes." + "value": "record %@ : arguments inattendus : %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux はまだセッションを保存していません。数秒後にもう一度お試しください。" + "value": "record %@: 予期しない引数: %@" } }, "ko": { "stringUnit": { "state": "translated", - "value": "cmux가 아직 세션을 저장하지 않았습니다. 몇 초 후에 다시 시도하세요." + "value": "record %@: 예기치 않은 인수: %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "cmux 尚未保存会话。请几秒后再试。" + "value": "record %@: 多余的参数: %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "cmux 尚未儲存工作階段。請過幾秒再試一次。" + "value": "record %@: 未預期的引數: %@" } } } }, - "session.export.error.writeFailed": { + "cli.record.error.unknownSubcommand": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Could not write %@." + "value": "record: unknown subcommand '%@' (start, stop, status, note, list)" } }, "ar": { "stringUnit": { "state": "translated", - "value": "تعذّرت كتابة %@." + "value": "record: أمر فرعي غير معروف '%@' (start، stop، status، note، list)" } }, "de": { "stringUnit": { "state": "translated", - "value": "%@ konnte nicht geschrieben werden." + "value": "record: unbekannter Unterbefehl „%@“ (start, stop, status, note, list)" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se pudo escribir %@." + "value": "record: subcomando desconocido '%@' (start, stop, status, note, list)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Impossible d'écrire %@." + "value": "record : sous-commande inconnue '%@' (start, stop, status, note, list)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ を書き込めませんでした。" + "value": "record: 不明なサブコマンド '%@' です(start、stop、status、note、list)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@ 파일을 쓸 수 없습니다." + "value": "record: 알 수 없는 하위 명령 '%@' (start, stop, status, note, list)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法写入 %@。" + "value": "record: 未知的子命令 '%@'(start、stop、status、note、list)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "無法寫入 %@。" + "value": "record: 未知的子命令 '%@'(start、stop、status、note、list)" } } } }, - "session.import.error.fileNotFound": { + "cli.usage.record": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "No saved cmux session at %@." + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, - "ar": { + "de": { "stringUnit": { "state": "translated", - "value": "لا توجد جلسة cmux محفوظة في %@." + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, - "de": { + "en": { "stringUnit": { "state": "translated", - "value": "Unter %@ gibt es keine gespeicherte cmux-Sitzung." + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, "es": { "stringUnit": { "state": "translated", - "value": "No hay ninguna sesión de cmux guardada en %@." + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Aucune session cmux enregistrée à l'emplacement %@." + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ に保存された cmux セッションはありません。" + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@ 위치에 저장된 cmux 세션이 없습니다." + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 没有已保存的 cmux 会话。" + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%@ 沒有已儲存的 cmux 工作階段。" + "value": "record [start] [--format mp4|gif] [--gif] [--fps ] [--max-seconds ] [--scale ] [--max-width ] [--region ] [--out ] [--label ] [--no-captions] [--window ] | record stop|status [--id ] | record note [--id ] | record list" } } } }, - "session.import.error.newerSchema": { + "cli.auth.status.teamNotLoaded": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%1$@ was saved by a newer cmux (session format %2$@, this cmux reads %3$@). Update cmux to import it." + "value": " team: not loaded" } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "حُفظ %1$@ بواسطة إصدار أحدث من cmux (تنسيق الجلسة %2$@، ويقرأ هذا الـ cmux التنسيق %3$@). حدّث cmux لاستيراده." + "value": " チーム: 未読み込み" } }, - "de": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%1$@ wurde von einem neueren cmux gespeichert (Sitzungsformat %2$@, dieses cmux liest %3$@). Aktualisieren Sie cmux, um die Datei zu importieren." + "value": " 团队: 未加载" } }, - "es": { + "de": { "stringUnit": { "state": "translated", - "value": "%1$@ se guardó con un cmux más reciente (formato de sesión %2$@; este cmux lee el %3$@). Actualiza cmux para importarlo." + "value": " Team: nicht geladen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "%1$@ a été enregistré par une version plus récente de cmux (format de session %2$@, ce cmux lit le format %3$@). Mettez cmux à jour pour l'importer." + "value": " équipe: non chargée" } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "%1$@ は新しい cmux で保存されています(セッション形式 %2$@、この cmux が読める形式は %3$@)。読み込むには cmux をアップデートしてください。" + "value": " الفريق: لم يتم تحميله" } }, - "ko": { + "es": { "stringUnit": { "state": "translated", - "value": "%1$@ 파일은 더 새로운 cmux에서 저장되었습니다(세션 형식 %2$@, 이 cmux는 %3$@ 형식을 읽음). 가져오려면 cmux를 업데이트하세요." + "value": " equipo: no cargado" } }, - "zh-Hans": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%1$@ 由较新版本的 cmux 保存(会话格式 %2$@,此 cmux 读取 %3$@)。请更新 cmux 后再导入。" + "value": " 團隊: 未載入" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "%1$@ 是由較新版本的 cmux 儲存(工作階段格式 %2$@,此 cmux 讀取 %3$@)。請更新 cmux 後再匯入。" + "value": " 팀: 로드되지 않음" } - } - } - }, - "session.import.error.noWindows": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "bs": { "stringUnit": { "state": "translated", - "value": "%@ has no windows to restore." + "value": " tim: nije učitan" } }, - "ar": { + "da": { "stringUnit": { "state": "translated", - "value": "لا يحتوي %@ على نوافذ لاستعادتها." + "value": " team: ikke indlæst" } }, - "de": { + "it": { "stringUnit": { "state": "translated", - "value": "%@ enthält keine Fenster zum Wiederherstellen." + "value": " team: non caricato" } }, - "es": { + "km": { "stringUnit": { "state": "translated", - "value": "%@ no tiene ventanas que restaurar." + "value": " ក្រុម៖ មិនទាន់បានផ្ទុក" } }, - "fr": { + "nb": { "stringUnit": { "state": "translated", - "value": "%@ ne contient aucune fenêtre à restaurer." + "value": " team: ikke lastet" } }, - "ja": { + "pl": { "stringUnit": { "state": "translated", - "value": "%@ には復元できるウインドウがありません。" + "value": " zespół: nie załadowano" } }, - "ko": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "%@ 파일에 복원할 창이 없습니다." + "value": " equipe: não carregada" } }, - "zh-Hans": { + "ru": { "stringUnit": { "state": "translated", - "value": "%@ 没有可恢复的窗口。" + "value": " команда: не загружена" } }, - "zh-Hant": { + "th": { "stringUnit": { "state": "translated", - "value": "%@ 沒有可還原的視窗。" + "value": " ทีม: ยังไม่ได้โหลด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": " ekip: yüklenmedi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": " команда: не завантажена" } } } }, - "session.import.error.notASnapshot": { + "cli.auth.status.teamNotLoadedRetrying": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ is not a cmux session snapshot." + "value": " team: not loaded (retrying)" } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "%@ ليس لقطة جلسة cmux." + "value": " チーム: 未読み込み(再試行中)" } }, - "de": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ ist keine cmux-Sitzungsdatei." + "value": " 团队: 未加载(正在重试)" } }, - "es": { + "de": { "stringUnit": { "state": "translated", - "value": "%@ no es una instantánea de sesión de cmux." + "value": " Team: nicht geladen (wird erneut versucht)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "%@ n'est pas un instantané de session cmux." + "value": " équipe: non chargée (nouvel essai)" } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "%@ は cmux のセッションスナップショットではありません。" + "value": " الفريق: لم يتم تحميله (جارٍ إعادة المحاولة)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " equipo: no cargado (reintentando)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 團隊: 未載入(重試中)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@ 파일은 cmux 세션 스냅샷이 아닙니다." + "value": " 팀: 로드되지 않음 (재시도 중)" } }, - "zh-Hans": { + "bs": { "stringUnit": { "state": "translated", - "value": "%@ 不是 cmux 会话快照。" + "value": " tim: nije učitan (ponovni pokušaj)" } }, - "zh-Hant": { + "da": { "stringUnit": { "state": "translated", - "value": "%@ 不是 cmux 工作階段快照。" + "value": " team: ikke indlæst (prøver igen)" } - } - } - }, - "session.import.error.nothingRestored": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "it": { "stringUnit": { "state": "translated", - "value": "Nothing in %@ could be reopened." + "value": " team: non caricato (nuovo tentativo)" } }, - "ar": { + "km": { "stringUnit": { "state": "translated", - "value": "تعذّر إعادة فتح أي شيء من %@." + "value": " ក្រុម៖ មិនទាន់បានផ្ទុក (កំពុងព្យាយាមម្ដងទៀត)" } }, - "de": { + "nb": { "stringUnit": { "state": "translated", - "value": "Aus %@ konnte nichts wieder geöffnet werden." + "value": " team: ikke lastet (prøver igjen)" } }, - "es": { + "pl": { "stringUnit": { "state": "translated", - "value": "No se pudo volver a abrir nada de %@." + "value": " zespół: nie załadowano (ponawianie próby)" } }, - "fr": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "Aucun élément de %@ n'a pu être rouvert." + "value": " equipe: não carregada (tentando novamente)" } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "%@ から再度開けるものはありませんでした。" + "value": " команда: не загружена (повторная попытка)" } }, - "ko": { + "th": { "stringUnit": { "state": "translated", - "value": "%@ 파일에서 다시 열 수 있는 항목이 없습니다." + "value": " ทีม: ยังไม่ได้โหลด (กำลังลองอีกครั้ง)" } }, - "zh-Hans": { + "tr": { "stringUnit": { "state": "translated", - "value": "无法重新打开 %@ 中的任何内容。" + "value": " ekip: yüklenmedi (yeniden deneniyor)" } }, - "zh-Hant": { + "uk": { "stringUnit": { "state": "translated", - "value": "無法重新開啟 %@ 中的任何內容。" + "value": " команда: не завантажена (повторна спроба)" } } } }, - "session.import.error.olderSchema": { + "mobile.pairing.error.teamUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load your cmux team. Check your internet connection, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux チームを読み込めませんでした。インターネット接続を確認してから、もう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载你的 cmux 团队。请检查网络连接,然后重试。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein cmux-Team konnte nicht geladen werden. Prüfe deine Internetverbindung und versuche es erneut." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de charger votre équipe cmux. Vérifiez votre connexion Internet, puis réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر تحميل فريق cmux الخاص بك. تحقّق من اتصالك بالإنترنت، ثم حاول مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo cargar tu equipo de cmux. Comprueba tu conexión a internet e inténtalo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法載入你的 cmux 團隊。請檢查網路連線,然後再試一次。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 팀을 불러올 수 없습니다. 인터넷 연결을 확인한 후 다시 시도하세요." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće učitati vaš cmux tim. Provjerite internetsku vezu i pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Din cmux-team kunne ikke indlæses. Kontrollér din internetforbindelse, og prøv igen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile caricare il tuo team cmux. Controlla la connessione Internet e riprova." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចផ្ទុកក្រុម cmux របស់អ្នកបានទេ។ សូមពិនិត្យការតភ្ជាប់អ៊ីនធឺណិត ហើយព្យាយាមម្ដងទៀត។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke laste inn cmux-teamet ditt. Kontroller internettilkoblingen, og prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się załadować Twojego zespołu cmux. Sprawdź połączenie z internetem i spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível carregar sua equipe cmux. Verifique sua conexão com a internet e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось загрузить вашу команду cmux. Проверьте подключение к интернету и попробуйте ещё раз." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถโหลดทีม cmux ของคุณได้ ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตแล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux ekibiniz yüklenemedi. İnternet bağlantınızı kontrol edip tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося завантажити вашу команду cmux. Перевірте підключення до інтернету й спробуйте ще раз." + } + } + } +}, + "settings.app.reorderOnNotification.mode.agentActivity": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "%1$@ uses an older session format (%2$@) that this cmux no longer reads (%3$@)." + "value": "نشاط الوكيل" } }, - "ar": { + "bs": { "stringUnit": { "state": "translated", - "value": "يستخدم %1$@ تنسيق جلسة أقدم (%2$@) لم يعد هذا الـ cmux يقرؤه (%3$@)." + "value": "Aktivnost agenata" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agentaktivitet" } }, "de": { "stringUnit": { "state": "translated", - "value": "%1$@ verwendet ein älteres Sitzungsformat (%2$@), das dieses cmux nicht mehr liest (%3$@)." + "value": "Agentenaktivität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent Activity" } }, "es": { "stringUnit": { "state": "translated", - "value": "%1$@ usa un formato de sesión anterior (%2$@) que este cmux ya no lee (%3$@)." + "value": "Actividad de agentes" } }, "fr": { "stringUnit": { "state": "translated", - "value": "%1$@ utilise un ancien format de session (%2$@) que ce cmux ne lit plus (%3$@)." + "value": "Activité des agents" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attività degli agenti" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%1$@ はこの cmux が読み込まなくなった古いセッション形式(%2$@)を使用しています(%3$@)。" + "value": "エージェントのアクティビティ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%1$@ 파일은 이 cmux가 더 이상 읽지 않는 이전 세션 형식(%2$@)을 사용합니다(%3$@)." + "value": "에이전트 활동" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agentaktivitet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktywność agentów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Atividade dos agentes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активность агентов" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กิจกรรมของเอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan etkinliği" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Активність агентів" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%1$@ 使用较旧的会话格式(%2$@),此 cmux 已不再读取(%3$@)。" + "value": "代理活动" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%1$@ 使用較舊的工作階段格式(%2$@),此 cmux 已不再讀取(%3$@)。" + "value": "代理活動" } } } }, - "session.import.error.unknownChannel": { + "settings.app.reorderOnNotification.mode.notifications": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Unknown cmux channel \"%@\". Use stable, nightly, rc, staging, debug:, or a path to a session file." + "value": "الإشعارات" } }, - "ar": { + "bs": { "stringUnit": { "state": "translated", - "value": "قناة cmux غير معروفة \"%@\". استخدم stable أو nightly أو rc أو staging أو debug: أو مسار ملف جلسة." + "value": "Obavještenja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Notifikationer" } }, "de": { "stringUnit": { "state": "translated", - "value": "Unbekannter cmux-Kanal \"%@\". Verwenden Sie stable, nightly, rc, staging, debug: oder einen Pfad zu einer Sitzungsdatei." + "value": "Benachrichtigungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" } }, "es": { "stringUnit": { "state": "translated", - "value": "Canal de cmux desconocido \"%@\". Usa stable, nightly, rc, staging, debug: o la ruta de un archivo de sesión." + "value": "Notificaciones" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Canal cmux inconnu « %@ ». Utilisez stable, nightly, rc, staging, debug: ou le chemin d'un fichier de session." + "value": "Sur notification" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Notifiche" } }, "ja": { "stringUnit": { "state": "translated", - "value": "不明な cmux チャンネル「%@」です。stable、nightly、rc、staging、debug:、またはセッションファイルのパスを指定してください。" + "value": "通知" } }, "ko": { "stringUnit": { "state": "translated", - "value": "알 수 없는 cmux 채널 \"%@\"입니다. stable, nightly, rc, staging, debug: 또는 세션 파일 경로를 사용하세요." + "value": "알림" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Varsler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Notificações" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimler" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сповіщення" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "未知的 cmux 渠道“%@”。请使用 stable、nightly、rc、staging、debug: 或会话文件路径。" + "value": "通知" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "未知的 cmux 通道「%@」。請使用 stable、nightly、rc、staging、debug: 或工作階段檔案路徑。" + "value": "通知" } } } }, - "session.import.error.unreadable": { + "settings.app.reorderOnNotification.mode.off": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Could not read %@." + "value": "إيقاف" } }, - "ar": { + "bs": { "stringUnit": { "state": "translated", - "value": "تعذّرت قراءة %@." + "value": "Isključeno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fra" } }, "de": { "stringUnit": { "state": "translated", - "value": "%@ konnte nicht gelesen werden." + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se pudo leer %@." + "value": "Desactivado" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Impossible de lire %@." + "value": "Désactivé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattivato" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ を読み込めませんでした。" + "value": "オフ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@ 파일을 읽을 수 없습니다." + "value": "끔" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Av" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyłączone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapalı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вимкнено" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法读取 %@。" + "value": "关闭" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "無法讀取 %@。" + "value": "關閉" } } } }, - "session.import.error.liveSnapshot": { + "settings.app.reorderOnNotification.modeSubtitle": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "%@ is the session this cmux is saving right now. Run cmux restore-session without --from to reopen the previous launch." + "value": "ينقل مساحات العمل إلى الأعلى عندما تتلقى إشعارًا. مع نشاط الوكيل، ينقلها أيضًا عندما ترسل مطالبة أو ينهي وكيل دورة أو يحتاج إلى إدخال أو يفشل، لكن ليس أبدًا أثناء وجود المؤشر فوق الشريط الجانبي. الإيقاف يحافظ على ثبات مواضع الاختصارات." } }, - "de": { + "bs": { "stringUnit": { "state": "translated", - "value": "%@ ist die Sitzung, die dieses cmux gerade speichert. Führen Sie cmux restore-session ohne --from aus, um den vorherigen Start wieder zu öffnen." + "value": "Pomjera radne prostore na vrh kada prime obavještenje. Aktivnost agenata ih pomjera i kada pošaljete upit ili agent završi potez, treba unos ili ne uspije, ali nikad dok je pokazivač iznad bočne trake. Isključeno čuva stabilne pozicije prečica." } }, - "fr": { + "da": { "stringUnit": { "state": "translated", - "value": "%@ est la session que ce cmux enregistre actuellement. Exécutez cmux restore-session sans --from pour rouvrir le lancement précédent." + "value": "Flytter arbejdsområder til toppen, når de modtager en notifikation. Agentaktivitet flytter dem også, når du sender en prompt eller en agent afslutter en tur, har brug for input eller fejler, men aldrig mens markøren er over sidepanelet. Fra holder genvejspositionerne stabile." } }, - "ar": { + "de": { "stringUnit": { "state": "translated", - "value": "%@ هي الجلسة التي يحفظها cmux هذا الآن. شغّل cmux restore-session دون --from لإعادة فتح التشغيل السابق." + "value": "Verschiebt Workspaces nach oben, wenn sie eine Benachrichtigung erhalten. Mit Agentenaktivität auch dann, wenn Sie einen Prompt senden oder ein Agent einen Durchlauf beendet, eine Eingabe braucht oder fehlschlägt, aber nie, während der Zeiger über der Seitenleiste ist. Aus hält die Positionen der Tastenkürzel stabil." } }, - "es": { + "en": { "stringUnit": { "state": "translated", - "value": "%@ es la sesión que este cmux está guardando ahora. Ejecuta cmux restore-session sin --from para volver a abrir el inicio anterior." + "value": "Move workspaces to the top when they receive a notification. Agent Activity also moves them when you send a prompt or an agent finishes a turn, needs input, or fails, but never while you point at the sidebar. Off keeps shortcut positions stable." } }, - "zh-Hant": { + "es": { "stringUnit": { "state": "translated", - "value": "%@ 是此 cmux 目前正在儲存的工作階段。執行不含 --from 的 cmux restore-session 以重新開啟上次啟動的內容。" + "value": "Mueve los espacios de trabajo arriba cuando reciben una notificación. Con Actividad de agentes, también cuando envías un prompt o un agente termina un turno, necesita una respuesta o falla, pero nunca mientras el puntero está sobre la barra lateral. Desactivado mantiene estables las posiciones de los atajos." } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "%@ 是此 cmux 当前正在保存的会话。运行不带 --from 的 cmux restore-session 以重新打开上次启动的内容。" + "value": "Place les espaces de travail en haut lorsqu’ils reçoivent une notification. Avec Activité des agents, aussi lorsque vous envoyez un prompt ou qu’un agent termine un tour, attend une saisie ou échoue, mais jamais pendant que le pointeur survole la barre latérale. Désactivé garde les positions des raccourcis stables." } }, - "ko": { + "it": { "stringUnit": { "state": "translated", - "value": "%@ 파일은 이 cmux가 지금 저장 중인 세션입니다. 이전 실행을 다시 열려면 --from 없이 cmux restore-session을 실행하세요." + "value": "Sposta gli spazi di lavoro in cima quando ricevono una notifica. Attività degli agenti li sposta anche quando invii un prompt o un agente termina un turno, attende un input o fallisce, ma mai mentre il puntatore è sulla barra laterale. Disattivato mantiene stabili le posizioni delle scorciatoie." } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ はこの cmux が現在保存しているセッションです。前回の起動を再度開くには、--from を付けずに cmux restore-session を実行してください。" + "value": "通知を受け取ったワークスペースを先頭に移動します。「エージェントのアクティビティ」では、プロンプトを送信したときや、エージェントがターンを終えたとき、入力を待っているとき、失敗したときにも移動しますが、ポインタがサイドバー上にある間は移動しません。「オフ」ではショートカットの位置が変わりません。" } - } - } - }, - "cli.session.help": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "Usage: cmux session restore [--list] [--session ]...\n\nReopen Claude sessions that were running when cmux last quit unexpectedly.\ncmux finds them in the agent journal, skips any that are running or already\nopen, and resumes each in its own workspace through the launcher that started\nit.\n\nWithout --session, restore acts only after an unexpected quit.\n\nOptions:\n --list Show the sessions without restoring them.\n --session Restore only this session (repeatable)." + "value": "알림을 받으면 워크스페이스를 맨 위로 옮깁니다. 에이전트 활동을 선택하면 프롬프트를 보내거나 에이전트가 턴을 마치거나 입력이 필요하거나 실패할 때도 옮기지만, 포인터가 사이드바 위에 있을 때는 옮기지 않습니다. 끔은 단축키 위치를 고정합니다." } }, - "de": { + "nb": { "stringUnit": { "state": "translated", - "value": "Verwendung: cmux session restore [--list] [--session ]...\n\nÖffnet Claude-Sitzungen erneut, die liefen, als cmux zuletzt unerwartet beendet wurde.\ncmux findet sie im Agent-Journal, überspringt laufende oder bereits geöffnete\nund setzt jede in einem eigenen Workspace\nüber den Launcher fort, der sie gestartet hat.\n\nOhne --session stellt restore nur nach einem unerwarteten Beenden wieder her.\n\nOptionen:\n --list Sitzungen anzeigen, ohne sie wiederherzustellen.\n --session Nur diese Sitzung wiederherstellen (wiederholbar)." + "value": "Flytter arbeidsområder til toppen når de mottar et varsel. Agentaktivitet flytter dem også når du sender en ledetekst eller en agent fullfører en runde, trenger input eller feiler, men aldri mens pekeren er over sidefeltet. Av holder snarveisposisjonene stabile." } }, - "fr": { + "pl": { "stringUnit": { "state": "translated", - "value": "Utilisation : cmux session restore [--list] [--session ]...\n\nRouvre les sessions Claude en cours lorsque cmux s’est fermé de façon inattendue.\ncmux les trouve dans le journal des agents, ignore celles qui tournent ou sont déjà\nouvertes, et reprend chacune dans son propre espace de travail via le lanceur qui l’a\ndémarrée.\n\nSans --session, restore n’agit qu’après une fermeture inattendue.\n\nOptions :\n --list Afficher les sessions sans les restaurer.\n --session Restaurer uniquement cette session (répétable)." + "value": "Przenosi obszary robocze na górę, gdy otrzymają powiadomienie. Aktywność agentów przenosi je także, gdy wyślesz prompt lub agent zakończy turę, potrzebuje danych lub zakończy się błędem, ale nigdy, gdy wskaźnik jest nad paskiem bocznym. Wyłączone zachowuje stałe pozycje skrótów." } }, - "ar": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "الاستخدام: cmux session restore [--list] [--session ]...\n\nيعيد فتح جلسات Claude التي كانت قيد التشغيل عندما أُغلق cmux بشكل غير متوقع آخر مرة.\nيعثر cmux عليها في سجل الوكيل، ويتخطى ما يعمل منها أو المفتوح بالفعل،\nويستأنف كل جلسة في مساحة عمل خاصة بها\nعبر المشغّل الذي بدأها.\n\nبدون --session، لا تعمل الاستعادة إلا بعد إغلاق غير متوقع.\n\nالخيارات:\n --list عرض الجلسات دون استعادتها.\n --session استعادة هذه الجلسة فقط (قابل للتكرار)." + "value": "Move os espaços de trabalho para o topo quando recebem uma notificação. Atividade dos agentes também os move quando você envia um prompt ou um agente termina um turno, precisa de uma resposta ou falha, mas nunca enquanto o ponteiro está sobre a barra lateral. Desativado mantém as posições dos atalhos estáveis." } }, - "es": { + "ru": { "stringUnit": { "state": "translated", - "value": "Uso: cmux session restore [--list] [--session ]...\n\nVuelve a abrir las sesiones de Claude que estaban en ejecución cuando cmux se cerró inesperadamente.\ncmux las encuentra en el diario de agentes, omite las que están en ejecución o ya\nabiertas, y reanuda cada una en su propio espacio de trabajo mediante el lanzador que\nla inició.\n\nSin --session, restore solo actúa tras un cierre inesperado.\n\nOpciones:\n --list Mostrar las sesiones sin restaurarlas.\n --session Restaurar solo esta sesión (repetible)." + "value": "Перемещает рабочие области наверх, когда они получают уведомление. «Активность агентов» также перемещает их, когда вы отправляете запрос или агент завершает ход, ждёт ввода или завершается с ошибкой, но никогда, пока указатель находится над боковой панелью. «Выкл.» сохраняет позиции сочетаний клавиш." } }, - "zh-Hant": { + "th": { "stringUnit": { "state": "translated", - "value": "用法: cmux session restore [--list] [--session ]...\n\n重新開啟 cmux 上次意外結束時仍在執行的 Claude 工作階段。\ncmux 會從代理日誌中找出它們,略過仍在執行或已開啟的工作階段,\n並透過原本啟動它的啟動器\n在各自的工作區中繼續。\n\n未指定 --session 時,restore 只會在意外結束後執行。\n\n選項:\n --list 只列出工作階段,不還原。\n --session 只還原此工作階段(可重複)。" + "value": "ย้ายเวิร์กสเปซขึ้นด้านบนเมื่อได้รับการแจ้งเตือน กิจกรรมของเอเจนต์จะย้ายเมื่อคุณส่งพรอมต์หรือเอเจนต์จบรอบ ต้องการข้อมูล หรือล้มเหลวด้วย แต่จะไม่ย้ายขณะที่ตัวชี้อยู่เหนือแถบด้านข้าง ปิดจะคงตำแหน่งของทางลัดไว้" } }, - "zh-Hans": { + "tr": { "stringUnit": { "state": "translated", - "value": "用法: cmux session restore [--list] [--session ]...\n\n重新打开 cmux 上次意外退出时仍在运行的 Claude 会话。\ncmux 会从代理日志中找到它们,跳过仍在运行或已打开的会话,\n并通过最初启动它的启动器\n在各自的工作区中继续。\n\n未指定 --session 时,restore 只会在意外退出后执行。\n\n选项:\n --list 只列出会话,不恢复。\n --session 只恢复此会话(可重复)。" + "value": "Bildirim aldıklarında çalışma alanlarını en üste taşır. Ajan etkinliği, bir istem gönderdiğinizde, bir ajan turunu bitirdiğinde, girdi beklediğinde veya başarısız olduğunda da taşır, ancak işaretçi kenar çubuğunun üzerindeyken asla taşımaz. Kapalı, kısayol konumlarını sabit tutar." } }, - "ko": { + "uk": { "stringUnit": { "state": "translated", - "value": "사용법: cmux session restore [--list] [--session ]...\n\ncmux가 마지막으로 예기치 않게 종료될 때 실행 중이던 Claude 세션을 다시 엽니다.\ncmux는 에이전트 저널에서 세션을 찾고, 실행 중이거나 이미 열려 있는 세션은 건너뛰며,\n각 세션을 시작한 런처를 통해\n자체 워크스페이스에서 이어갑니다.\n\n--session이 없으면 restore는 예기치 않은 종료 후에만 동작합니다.\n\n옵션:\n --list 복원하지 않고 세션만 표시합니다.\n --session 이 세션만 복원합니다(반복 가능)." + "value": "Переміщує робочі області вгору, коли вони отримують сповіщення. «Активність агентів» також переміщує їх, коли ви надсилаєте запит або агент завершує хід, чекає на введення або завершується з помилкою, але ніколи, поки вказівник над бічною панеллю. «Вимкнено» зберігає позиції скорочень." } }, - "ja": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "使用法: cmux session restore [--list] [--session ]...\n\ncmux が前回予期せず終了したときに実行中だった Claude セッションを再び開きます。\ncmux はエージェントジャーナルからセッションを見つけ、実行中または既に開いている\nものはスキップし、それぞれを起動したランチャー経由で\n専用のワークスペースで再開します。\n\n--session を指定しない場合、restore は予期しない終了の後にのみ動作します。\n\nオプション:\n --list 復元せずにセッションを表示します。\n --session このセッションのみ復元します(複数指定可)。" + "value": "工作区收到通知时移到顶部。选择“代理活动”时,你发送提示词或代理完成一轮、需要输入或失败时也会移动,但指针停在侧边栏上时绝不移动。关闭可让快捷键位置保持不变。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區收到通知時移到頂部。選擇「代理活動」時,你送出提示詞或代理完成一輪、需要輸入或失敗時也會移動,但指標停在側邊欄上時絕不移動。關閉可讓快捷鍵位置保持不變。" } } } }, - "cli.session.error.expectedRestore": { + "command.newPaneAutoLayout.title": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "session: expected 'restore' (see cmux help session)" + "value": "لوحة جديدة (تخطيط تلقائي)" } }, - "de": { + "bs": { "stringUnit": { "state": "translated", - "value": "session: 'restore' erwartet (siehe cmux help session)" + "value": "Novi panel (automatski raspored)" } }, - "fr": { + "da": { "stringUnit": { "state": "translated", - "value": "session : « restore » attendu (voir cmux help session)" + "value": "Nyt panel (automatisk layout)" } }, - "ar": { + "de": { "stringUnit": { "state": "translated", - "value": "session: كان المتوقع 'restore' (راجع cmux help session)" + "value": "Neuer Bereich (automatisches Layout)" } }, - "es": { + "en": { "stringUnit": { "state": "translated", - "value": "session: se esperaba 'restore' (consulta cmux help session)" + "value": "New Pane (Auto Layout)" } }, - "zh-Hant": { + "es": { "stringUnit": { "state": "translated", - "value": "session:應為 'restore'(請參閱 cmux help session)" + "value": "Nuevo panel (diseño automático)" } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "session:应为 'restore'(请参阅 cmux help session)" + "value": "Nouveau panneau (disposition automatique)" } }, - "ko": { + "it": { "stringUnit": { "state": "translated", - "value": "session: 'restore'가 필요합니다(cmux help session 참조)" + "value": "Nuovo pannello (layout automatico)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "session: 'restore' が必要です(cmux help session を参照)" + "value": "新しいペイン(自動レイアウト)" } - } - } - }, - "cli.session.error.sessionRequiresId": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "session restore: --session requires an id" + "value": "새 패널 (자동 레이아웃)" } }, - "de": { + "nb": { "stringUnit": { "state": "translated", - "value": "session restore: --session erfordert eine ID" + "value": "Nytt panel (automatisk oppsett)" } }, - "fr": { + "pl": { "stringUnit": { "state": "translated", - "value": "session restore : --session nécessite un identifiant" + "value": "Nowy panel (układ automatyczny)" } }, - "ar": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "session restore: يتطلب --session معرّفًا" + "value": "Novo Painel (Layout Automático)" } }, - "es": { + "ru": { "stringUnit": { "state": "translated", - "value": "session restore: --session requiere un id" + "value": "Новая панель (автоматическая раскладка)" } }, - "zh-Hant": { + "th": { "stringUnit": { "state": "translated", - "value": "session restore:--session 需要一個 ID" + "value": "บานหน้าต่างใหม่ (จัดวางอัตโนมัติ)" } }, - "zh-Hans": { + "tr": { "stringUnit": { "state": "translated", - "value": "session restore:--session 需要一个 ID" + "value": "Yeni Bölme (Otomatik Düzen)" } }, - "ko": { + "uk": { "stringUnit": { "state": "translated", - "value": "session restore: --session에는 ID가 필요합니다" + "value": "Нова панель (автоматичне розташування)" } }, - "ja": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "session restore: --session には ID が必要です" + "value": "新建窗格(自动布局)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增窗格(自動佈局)" } } } }, - "cli.session.error.unknownArgument": { + "shortcut.newPaneAutoLayout.label": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "session restore: unknown argument '%@'" + "value": "لوحة جديدة (تخطيط تلقائي)" } }, - "de": { + "bs": { "stringUnit": { "state": "translated", - "value": "session restore: unbekanntes Argument '%@'" + "value": "Novi panel (automatski raspored)" } }, - "fr": { + "da": { "stringUnit": { "state": "translated", - "value": "session restore : argument inconnu « %@ »" + "value": "Nyt panel (automatisk layout)" } }, - "ar": { + "de": { "stringUnit": { "state": "translated", - "value": "session restore: وسيط غير معروف '%@'" + "value": "Neuer Bereich (automatisches Layout)" } }, - "es": { + "en": { "stringUnit": { "state": "translated", - "value": "session restore: argumento desconocido '%@'" + "value": "New Pane (Auto Layout)" } }, - "zh-Hant": { + "es": { "stringUnit": { "state": "translated", - "value": "session restore:未知的參數 '%@'" + "value": "Nuevo panel (diseño automático)" } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "session restore:未知参数 '%@'" + "value": "Nouveau panneau (disposition automatique)" } }, - "ko": { + "it": { "stringUnit": { "state": "translated", - "value": "session restore: 알 수 없는 인수 '%@'" + "value": "Nuovo pannello (layout automatico)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "session restore: 不明な引数 '%@'" + "value": "新しいペイン(自動レイアウト)" } - } - } - }, - "cli.session.list.empty": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "No agent sessions to restore." + "value": "새 패널 (자동 레이아웃)" } }, - "de": { + "nb": { "stringUnit": { "state": "translated", - "value": "Keine Agent-Sitzungen zum Wiederherstellen." + "value": "Nytt panel (automatisk oppsett)" } }, - "fr": { + "pl": { "stringUnit": { "state": "translated", - "value": "Aucune session d’agent à restaurer." + "value": "Nowy panel (układ automatyczny)" } }, - "ar": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "لا توجد جلسات وكيل لاستعادتها." + "value": "Novo Painel (Layout Automático)" } }, - "es": { + "ru": { "stringUnit": { "state": "translated", - "value": "No hay sesiones de agente para restaurar." + "value": "Новая панель (автоматическая раскладка)" } }, - "zh-Hant": { + "th": { "stringUnit": { "state": "translated", - "value": "沒有可還原的代理工作階段。" + "value": "บานหน้าต่างใหม่ (จัดวางอัตโนมัติ)" } }, - "zh-Hans": { + "tr": { "stringUnit": { "state": "translated", - "value": "没有可恢复的代理会话。" + "value": "Yeni Bölme (Otomatik Düzen)" } }, - "ko": { + "uk": { "stringUnit": { "state": "translated", - "value": "복원할 에이전트 세션이 없습니다." + "value": "Нова панель (автоматичне розташування)" } }, - "ja": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "復元するエージェントセッションはありません。" + "value": "新建窗格(自动布局)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增窗格(自動佈局)" } } } }, - "cli.session.restore.cleanQuit": { + "cli.restoreSession.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No agent sessions restored: cmux last quit normally. To restore one anyway, pass --session from --list." + "value": "Usage: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nReopen the previous saved cmux session.\n\nIf the app is already running, this restores the last saved session into the current app.\nIf the app is not running, this launches cmux and lets startup restore reopen the saved session.\n\nEach cmux install (stable, nightly, rc, staging, tagged debug builds) saves its own session.\n--from Reopen another install's saved session in this running cmux, for example\n after trying nightly and switching back to stable. The session opens as\n additional windows; the other install's saved file is only read.\n--from Reopen a session file written by --export.\n--export Write this cmux's saved session to a file. Pass --force to replace an\n existing file.\n\n--from and --export require cmux to be running." } }, "de": { "stringUnit": { "state": "translated", - "value": "Keine Agent-Sitzungen wiederhergestellt: cmux wurde zuletzt normal beendet. Um trotzdem eine wiederherzustellen, übergib --session aus --list." + "value": "Verwendung: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nDie zuletzt gespeicherte cmux-Sitzung erneut öffnen.\n\nLäuft die App bereits, wird die zuletzt gespeicherte Sitzung in der aktuellen App wiederhergestellt.\nLäuft die App nicht, wird cmux gestartet und die gespeicherte Sitzung beim Start wiederhergestellt.\n\nJede cmux-Installation (stable, nightly, rc, staging, getaggte Debug-Builds) speichert ihre eigene Sitzung.\n--from Die gespeicherte Sitzung einer anderen Installation in diesem laufenden cmux öffnen,\n z. B. nachdem Sie nightly ausprobiert haben und zu stable zurückkehren. Die Sitzung\n öffnet sich in zusätzlichen Fenstern; die Datei der anderen Installation wird nur gelesen.\n--from Eine mit --export geschriebene Sitzungsdatei öffnen.\n--export Die gespeicherte Sitzung dieses cmux in eine Datei schreiben. Mit --force wird eine\n vorhandene Datei ersetzt.\n\n--from und --export setzen ein laufendes cmux voraus." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Aucune session d’agent restaurée : cmux s’est fermé normalement la dernière fois. Pour en restaurer une quand même, passez --session depuis --list." + "value": "Utilisation : cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nRouvrir la dernière session cmux enregistrée.\n\nSi l'app est déjà ouverte, la dernière session enregistrée est restaurée dans l'app actuelle.\nSi l'app n'est pas ouverte, cmux est lancé et la restauration au démarrage rouvre la session enregistrée.\n\nChaque installation de cmux (stable, nightly, rc, staging, builds debug avec tag) enregistre sa propre session.\n--from Rouvrir la session enregistrée d'une autre installation dans ce cmux en cours, par exemple\n après avoir essayé nightly puis être revenu à stable. La session s'ouvre dans des fenêtres\n supplémentaires ; le fichier de l'autre installation est seulement lu.\n--from Rouvrir un fichier de session écrit par --export.\n--export Écrire la session enregistrée de ce cmux dans un fichier. Ajoutez --force pour remplacer\n un fichier existant.\n\n--from et --export nécessitent que cmux soit ouvert." } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "لم تتم استعادة أي جلسات وكيل: أُغلق cmux بشكل طبيعي آخر مرة. لاستعادة جلسة على أي حال، مرّر --session من --list." + "value": "Uso: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nVuelve a abrir la última sesión guardada de cmux.\n\nSi la app ya está abierta, restaura la última sesión guardada en la app actual.\nSi la app no está abierta, inicia cmux y deja que la restauración de inicio vuelva a abrir la sesión guardada.\n\nCada instalación de cmux (stable, nightly, rc, staging, builds de depuración con etiqueta) guarda su propia sesión.\n--from Abre en este cmux la sesión guardada de otra instalación, por ejemplo después de\n probar nightly y volver a stable. La sesión se abre en ventanas adicionales; el archivo\n de la otra instalación solo se lee.\n--from Abre un archivo de sesión escrito con --export.\n--export Escribe en un archivo la sesión guardada de este cmux. Usa --force para reemplazar un\n archivo existente.\n\n--from y --export requieren que cmux esté abierto." } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "No se restauró ninguna sesión de agente: cmux se cerró normalmente la última vez. Para restaurar una de todos modos, pasa --session de --list." + "value": "使い方: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n前回保存した cmux セッションを再度開きます。\n\nアプリがすでに起動している場合は、最後に保存したセッションを現在のアプリに復元します。\nアプリが起動していない場合は、cmux を起動し、起動時の復元で保存済みセッションを開きます。\n\ncmux の各インストール(stable、nightly、rc、staging、タグ付き debug ビルド)はそれぞれ独自のセッションを保存します。\n--from 別のインストールで保存したセッションを、起動中のこの cmux で開きます。たとえば nightly を\n 試したあと stable に戻る場合に使います。セッションは追加のウインドウとして開き、別の\n インストールのファイルは読み取るだけです。\n--from --export で書き出したセッションファイルを開きます。\n--export この cmux の保存済みセッションをファイルに書き出します。既存のファイルを置き換えるには\n --force を指定します。\n\n--from と --export を使うには cmux が起動している必要があります。" } }, - "zh-Hant": { + "ar": { "stringUnit": { "state": "translated", - "value": "未還原任何代理工作階段:cmux 上次是正常結束。若仍要還原,請傳入 --list 中的 --session 。" + "value": "الاستخدام: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\nأعد فتح آخر جلسة cmux محفوظة.\n\nإذا كان التطبيق يعمل بالفعل، فستُستعاد آخر جلسة محفوظة في التطبيق الحالي.\nإذا لم يكن التطبيق يعمل، فسيُشغَّل cmux وتعيد استعادة بدء التشغيل فتح الجلسة المحفوظة.\n\nيحفظ كل تثبيت من cmux (stable وnightly وrc وstaging وإصدارات debug الموسومة) جلسته الخاصة.\n--from افتح الجلسة المحفوظة لتثبيت آخر في cmux الذي يعمل الآن، مثلًا\n بعد تجربة nightly والعودة إلى stable. تُفتح الجلسة في\n نوافذ إضافية، ويُقرأ ملف التثبيت الآخر فقط.\n--from افتح ملف جلسة كتبه الأمر --export.\n--export اكتب الجلسة المحفوظة لهذا cmux في ملف. استخدم --force لاستبدال\n ملف موجود.\n\nيتطلب --from و--export أن يكون cmux قيد التشغيل." } }, - "zh-Hans": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "未恢复任何代理会话:cmux 上次是正常退出。若仍要恢复,请传入 --list 中的 --session 。" + "value": "用法: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n重新開啟上次儲存的 cmux 工作階段。\n\n如果 App 已在執行,會將上次儲存的工作階段還原到目前的 App 中。\n如果 App 未執行,會啟動 cmux,並由啟動還原重新開啟已儲存的工作階段。\n\n每個 cmux 安裝(stable、nightly、rc、staging、帶標籤的 debug 建置)都會儲存自己的工作階段。\n--from 在執行中的 cmux 開啟另一個安裝儲存的工作階段,例如\n 試用 nightly 後切回 stable。工作階段會以\n 額外視窗開啟;另一個安裝的檔案只會被讀取。\n--from 開啟由 --export 寫出的工作階段檔案。\n--export 將此 cmux 儲存的工作階段寫入檔案。使用 --force 可取代\n 既有檔案。\n\n--from 和 --export 需要 cmux 正在執行。" } }, - "ko": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "복원된 에이전트 세션이 없습니다. cmux가 마지막에 정상적으로 종료되었습니다. 그래도 복원하려면 --list에서 확인한 --session 를 전달하세요." + "value": "用法: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n重新打开上次保存的 cmux 会话。\n\n如果应用已在运行,会将上次保存的会话恢复到当前应用中。\n如果应用未运行,会启动 cmux,并由启动恢复重新打开保存的会话。\n\n每个 cmux 安装(stable、nightly、rc、staging、带标签的 debug 构建)都会保存自己的会话。\n--from 在正在运行的 cmux 中打开另一个安装保存的会话,例如\n 试用 nightly 后切回 stable。会话会以\n 额外窗口打开;另一个安装的会话文件只会被读取。\n--from 打开由 --export 写出的会话文件。\n--export 将此 cmux 保存的会话写入文件。使用 --force 可替换\n 已有文件。\n\n--from 和 --export 需要 cmux 正在运行。" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "エージェントセッションは復元されませんでした。cmux は前回正常に終了しています。それでも復元する場合は、--list の --session を指定してください。" + "value": "사용법: cmux restore-session\n cmux restore-session --from |path>\n cmux restore-session --export [--force]\n\n마지막으로 저장된 cmux 세션을 다시 엽니다.\n\n앱이 이미 실행 중이면 마지막으로 저장된 세션을 현재 앱에 복원합니다.\n앱이 실행 중이 아니면 cmux를 실행하고 시작 복원이 저장된 세션을 다시 열도록 합니다.\n\n각 cmux 설치(stable, nightly, rc, staging, 태그가 지정된 debug 빌드)는 자체 세션을 저장합니다.\n--from 다른 설치에 저장된 세션을 실행 중인 이 cmux에서 엽니다. 예를 들어\n nightly를 사용해 본 뒤 stable로 돌아올 때 사용합니다. 세션은\n 추가 창으로 열리며, 다른 설치의 파일은 읽기만 합니다.\n--from --export로 작성한 세션 파일을 엽니다.\n--export 이 cmux에 저장된 세션을 파일로 씁니다. 기존 파일을 바꾸려면\n --force를 사용하세요.\n\n--from과 --export를 사용하려면 cmux가 실행 중이어야 합니다." } } } }, - "cli.session.restore.empty": { + "session.export.error.destinationExists": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No agent sessions restored." + "value": "%@ already exists. Pass --force to replace it." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Keine Agent-Sitzungen wiederhergestellt." + "value": "%@ موجود بالفعل. استخدم --force لاستبداله." } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "Aucune session d’agent restaurée." + "value": "%@ ist bereits vorhanden. Verwenden Sie --force, um die Datei zu ersetzen." } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "لم تتم استعادة أي جلسات وكيل." + "value": "%@ ya existe. Usa --force para reemplazarlo." } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "No se restauró ninguna sesión de agente." + "value": "%@ existe déjà. Ajoutez --force pour le remplacer." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "未還原任何代理工作階段。" + "value": "%@ はすでに存在します。置き換えるには --force を指定してください。" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "未恢复任何代理会话。" + "value": "%@ 파일이 이미 있습니다. 바꾸려면 --force를 사용하세요." } }, - "ko": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "복원된 에이전트 세션이 없습니다." + "value": "%@ 已存在。使用 --force 以替换它。" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "エージェントセッションは復元されませんでした。" + "value": "%@ 已存在。使用 --force 以取代它。" } } } }, - "cli.session.restore.done": { + "session.export.error.destinationIsLive": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Agent sessions restored: %lld" + "value": "%@ is this cmux's own session file. Choose another path." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Wiederhergestellte Agent-Sitzungen: %lld" + "value": "%@ هو ملف الجلسة الخاص بهذا الـ cmux. اختر مسارًا آخر." } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "Sessions d’agent restaurées : %lld" + "value": "%@ ist die eigene Sitzungsdatei dieses cmux. Wählen Sie einen anderen Pfad." } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "جلسات الوكيل المستعادة: %lld" + "value": "%@ es el archivo de sesión de este cmux. Elige otra ruta." } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Sesiones de agente restauradas: %lld" + "value": "%@ est le fichier de session de ce cmux. Choisissez un autre chemin." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "已還原的代理工作階段:%lld" + "value": "%@ はこの cmux 自身のセッションファイルです。別のパスを指定してください。" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "已恢复的代理会话:%lld" + "value": "%@ 파일은 이 cmux의 세션 파일입니다. 다른 경로를 선택하세요." } }, - "ko": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "복원된 에이전트 세션: %lld" + "value": "%@ 是此 cmux 自己的会话文件。请选择其他路径。" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "復元したエージェントセッション: %lld" + "value": "%@ 是此 cmux 本身的工作階段檔案。請選擇其他路徑。" } } } }, - "agentRecovery.alert.title": { + "session.export.error.noSnapshot": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Restore agent sessions?" + "value": "cmux has not saved a session yet. Try again in a few seconds." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Agent-Sitzungen wiederherstellen?" + "value": "لم يحفظ cmux أي جلسة بعد. حاول مرة أخرى بعد بضع ثوانٍ." } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "Restaurer les sessions d’agent ?" + "value": "cmux hat noch keine Sitzung gespeichert. Versuchen Sie es in ein paar Sekunden erneut." } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "هل تريد استعادة جلسات الوكيل؟" + "value": "cmux aún no ha guardado ninguna sesión. Inténtalo de nuevo en unos segundos." } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "¿Restaurar las sesiones de agente?" + "value": "cmux n'a pas encore enregistré de session. Réessayez dans quelques secondes." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "要還原代理工作階段嗎?" + "value": "cmux はまだセッションを保存していません。数秒後にもう一度お試しください。" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "要恢复代理会话吗?" + "value": "cmux가 아직 세션을 저장하지 않았습니다. 몇 초 후에 다시 시도하세요." } }, - "ko": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "에이전트 세션을 복원할까요?" + "value": "cmux 尚未保存会话。请几秒后再试。" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "エージェントセッションを復元しますか?" + "value": "cmux 尚未儲存工作階段。請過幾秒再試一次。" } } } }, - "agentRecovery.alert.message": { + "session.export.error.writeFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux quit unexpectedly while agent sessions were running. Sessions to restore: %lld. Each one reopens in its own workspace and resumes where it left off." + "value": "Could not write %@." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "cmux wurde unerwartet beendet, während Agent-Sitzungen liefen. Wiederherzustellende Sitzungen: %lld. Jede wird in einem eigenen Workspace geöffnet und dort fortgesetzt, wo sie aufgehört hat." + "value": "تعذّرت كتابة %@." } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "cmux s’est fermé de façon inattendue pendant que des sessions d’agent étaient en cours. Sessions à restaurer : %lld. Chacune rouvre dans son propre espace de travail et reprend là où elle s’était arrêtée." + "value": "%@ konnte nicht geschrieben werden." } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "أُغلق cmux بشكل غير متوقع أثناء تشغيل جلسات الوكيل. الجلسات المطلوب استعادتها: %lld. تُفتح كل جلسة في مساحة عمل خاصة بها وتُستأنف من حيث توقفت." + "value": "No se pudo escribir %@." } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "cmux se cerró inesperadamente mientras había sesiones de agente en ejecución. Sesiones por restaurar: %lld. Cada una se abre en su propio espacio de trabajo y continúa donde se quedó." + "value": "Impossible d'écrire %@." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "cmux 在代理工作階段執行時意外結束。要還原的工作階段:%lld。每個工作階段都會在各自的工作區重新開啟,並從中斷處繼續。" + "value": "%@ を書き込めませんでした。" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "cmux 在代理会话运行时意外退出。要恢复的会话:%lld。每个会话都会在各自的工作区重新打开,并从中断处继续。" + "value": "%@ 파일을 쓸 수 없습니다." } }, - "ko": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "에이전트 세션이 실행 중일 때 cmux가 예기치 않게 종료되었습니다. 복원할 세션: %lld. 각 세션은 자체 워크스페이스에서 다시 열리고 중단된 곳에서 이어집니다." + "value": "无法写入 %@。" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "エージェントセッションの実行中に cmux が予期せず終了しました。復元するセッション: %lld。各セッションは専用のワークスペースで再び開き、中断したところから再開します。" + "value": "無法寫入 %@。" } } } }, - "agentRecovery.alert.restore": { + "session.import.error.fileNotFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Restore Agent Sessions" + "value": "No saved cmux session at %@." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Agent-Sitzungen wiederherstellen" + "value": "لا توجد جلسة cmux محفوظة في %@." } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "Restaurer les sessions d’agent" + "value": "Unter %@ gibt es keine gespeicherte cmux-Sitzung." } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "استعادة جلسات الوكيل" + "value": "No hay ninguna sesión de cmux guardada en %@." } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Restaurar sesiones de agente" + "value": "Aucune session cmux enregistrée à l'emplacement %@." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "還原代理工作階段" + "value": "%@ に保存された cmux セッションはありません。" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "恢复代理会话" + "value": "%@ 위치에 저장된 cmux 세션이 없습니다." } }, - "ko": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "에이전트 세션 복원" + "value": "%@ 没有已保存的 cmux 会话。" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "エージェントセッションを復元" + "value": "%@ 沒有已儲存的 cmux 工作階段。" } } } }, - "agentRecovery.alert.notNow": { + "session.import.error.newerSchema": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Not Now" + "value": "%1$@ was saved by a newer cmux (session format %2$@, this cmux reads %3$@). Update cmux to import it." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Nicht jetzt" + "value": "حُفظ %1$@ بواسطة إصدار أحدث من cmux (تنسيق الجلسة %2$@، ويقرأ هذا الـ cmux التنسيق %3$@). حدّث cmux لاستيراده." } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "Pas maintenant" + "value": "%1$@ wurde von einem neueren cmux gespeichert (Sitzungsformat %2$@, dieses cmux liest %3$@). Aktualisieren Sie cmux, um die Datei zu importieren." } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "ليس الآن" + "value": "%1$@ se guardó con un cmux más reciente (formato de sesión %2$@; este cmux lee el %3$@). Actualiza cmux para importarlo." } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Ahora no" + "value": "%1$@ a été enregistré par une version plus récente de cmux (format de session %2$@, ce cmux lit le format %3$@). Mettez cmux à jour pour l'importer." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "稍後" + "value": "%1$@ は新しい cmux で保存されています(セッション形式 %2$@、この cmux が読める形式は %3$@)。読み込むには cmux をアップデートしてください。" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "以后再说" + "value": "%1$@ 파일은 더 새로운 cmux에서 저장되었습니다(세션 형식 %2$@, 이 cmux는 %3$@ 형식을 읽음). 가져오려면 cmux를 업데이트하세요." } }, - "ko": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "나중에" + "value": "%1$@ 由较新版本的 cmux 保存(会话格式 %2$@,此 cmux 读取 %3$@)。请更新 cmux 后再导入。" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "後で" + "value": "%1$@ 是由較新版本的 cmux 儲存(工作階段格式 %2$@,此 cmux 讀取 %3$@)。請更新 cmux 後再匯入。" } } } }, - "settings.notifications.soundWhenFocused.subtitle": { + "session.import.error.noWindows": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Play the notification sound even when the pane that notified is already focused. When off, a focused pane shows its ring without a sound." + "value": "%@ has no windows to restore." } }, "ar": { "stringUnit": { "state": "translated", - "value": "شغّل صوت الإشعار حتى عندما تكون اللوحة التي أرسلت الإشعار في موضع التركيز بالفعل. عند إيقافه، تعرض اللوحة المُركَّز عليها حلقتها دون صوت." + "value": "لا يحتوي %@ على نوافذ لاستعادتها." } }, "de": { "stringUnit": { "state": "translated", - "value": "Den Benachrichtigungston auch abspielen, wenn der benachrichtigende Bereich bereits fokussiert ist. Wenn aus, zeigt ein fokussierter Bereich seinen Ring ohne Ton." + "value": "%@ enthält keine Fenster zum Wiederherstellen." } }, "es": { "stringUnit": { "state": "translated", - "value": "Reproduce el sonido de notificación incluso cuando el panel que notificó ya tiene el foco. Si está desactivado, un panel con foco muestra su anillo sin sonido." + "value": "%@ no tiene ventanas que restaurar." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Jouer le son de notification même lorsque le panneau à l’origine de la notification a déjà le focus. Désactivé, un panneau actif affiche son anneau sans son." + "value": "%@ ne contient aucune fenêtre à restaurer." } }, "ja": { "stringUnit": { "state": "translated", - "value": "通知を送ったペインがすでにフォーカスされている場合でも通知音を鳴らします。オフの場合、フォーカス中のペインは音を鳴らさずにリングを表示します。" + "value": "%@ には復元できるウインドウがありません。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "알림을 보낸 패널이 이미 포커스된 상태여도 알림음을 재생합니다. 끄면 포커스된 패널은 소리 없이 링만 표시합니다." + "value": "%@ 파일에 복원할 창이 없습니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "即使发出通知的窗格已处于焦点状态,也播放通知声音。关闭时,处于焦点的窗格会显示光环但不发出声音。" + "value": "%@ 没有可恢复的窗口。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "即使發出通知的窗格已在焦點中,也播放通知聲音。關閉時,焦點中的窗格會顯示光環但不發出聲音。" + "value": "%@ 沒有可還原的視窗。" } } } }, - "settings.notifications.soundWhenFocused.title": { + "session.import.error.notASnapshot": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sound for Focused Pane" + "value": "%@ is not a cmux session snapshot." } }, "ar": { "stringUnit": { "state": "translated", - "value": "صوت للوحة المُركَّز عليها" + "value": "%@ ليس لقطة جلسة cmux." } }, "de": { "stringUnit": { "state": "translated", - "value": "Ton für fokussierten Bereich" + "value": "%@ ist keine cmux-Sitzungsdatei." } }, "es": { "stringUnit": { "state": "translated", - "value": "Sonido para el panel con foco" + "value": "%@ no es una instantánea de sesión de cmux." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Son pour le panneau actif" + "value": "%@ n'est pas un instantané de session cmux." } }, "ja": { "stringUnit": { "state": "translated", - "value": "フォーカス中のペインの通知音" + "value": "%@ は cmux のセッションスナップショットではありません。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "포커스된 패널 소리" + "value": "%@ 파일은 cmux 세션 스냅샷이 아닙니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "焦点窗格的声音" + "value": "%@ 不是 cmux 会话快照。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "焦點窗格的聲音" + "value": "%@ 不是 cmux 工作階段快照。" } } } }, - "agent.claude.stopFailure.body.generic": { + "session.import.error.nothingRestored": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Claude Code stopped on an API error" + "value": "Nothing in %@ could be reopened." } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "Claude Code は API エラーで停止しました" + "value": "تعذّر إعادة فتح أي شيء من %@." } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "Claude Code 因 API 错误而停止" + "value": "Aus %@ konnte nichts wieder geöffnet werden." } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "Claude Code wurde wegen eines API-Fehlers gestoppt" + "value": "No se pudo volver a abrir nada de %@." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Claude Code s'est arrêté sur une erreur d'API" + "value": "Aucun élément de %@ n'a pu être rouvert." } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "توقف Claude Code بسبب خطأ في واجهة API" + "value": "%@ から再度開けるものはありませんでした。" } }, - "es": { + "ko": { "stringUnit": { "state": "translated", - "value": "Claude Code se detuvo por un error de la API" + "value": "%@ 파일에서 다시 열 수 있는 항목이 없습니다." } }, - "zh-Hant": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Claude Code 因 API 錯誤而停止" + "value": "无法重新打开 %@ 中的任何内容。" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Claude Code가 API 오류로 중지되었습니다" + "value": "無法重新開啟 %@ 中的任何內容。" } } } }, - "agent.claude.stopFailure.status.apiError": { + "session.import.error.olderSchema": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "API error" + "value": "%1$@ uses an older session format (%2$@) that this cmux no longer reads (%3$@)." } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "API エラー" + "value": "يستخدم %1$@ تنسيق جلسة أقدم (%2$@) لم يعد هذا الـ cmux يقرؤه (%3$@)." } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "API 错误" + "value": "%1$@ verwendet ein älteres Sitzungsformat (%2$@), das dieses cmux nicht mehr liest (%3$@)." } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "API-Fehler" + "value": "%1$@ usa un formato de sesión anterior (%2$@) que este cmux ya no lee (%3$@)." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Erreur d'API" + "value": "%1$@ utilise un ancien format de session (%2$@) que ce cmux ne lit plus (%3$@)." } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "خطأ في واجهة API" + "value": "%1$@ はこの cmux が読み込まなくなった古いセッション形式(%2$@)を使用しています(%3$@)。" } }, - "es": { + "ko": { "stringUnit": { "state": "translated", - "value": "Error de la API" + "value": "%1$@ 파일은 이 cmux가 더 이상 읽지 않는 이전 세션 형식(%2$@)을 사용합니다(%3$@)." } }, - "zh-Hant": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "API 錯誤" + "value": "%1$@ 使用较旧的会话格式(%2$@),此 cmux 已不再读取(%3$@)。" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "API 오류" + "value": "%1$@ 使用較舊的工作階段格式(%2$@),此 cmux 已不再讀取(%3$@)。" } } } }, - "agent.claude.stopFailure.status.authentication": { + "session.import.error.unknownChannel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Auth error" + "value": "Unknown cmux channel \"%@\". Use stable, nightly, rc, staging, debug:, or a path to a session file." } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "認証エラー" + "value": "قناة cmux غير معروفة \"%@\". استخدم stable أو nightly أو rc أو staging أو debug: أو مسار ملف جلسة." } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "认证错误" + "value": "Unbekannter cmux-Kanal \"%@\". Verwenden Sie stable, nightly, rc, staging, debug: oder einen Pfad zu einer Sitzungsdatei." } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "Authentifizierungsfehler" + "value": "Canal de cmux desconocido \"%@\". Usa stable, nightly, rc, staging, debug: o la ruta de un archivo de sesión." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Erreur d'authentification" + "value": "Canal cmux inconnu « %@ ». Utilisez stable, nightly, rc, staging, debug: ou le chemin d'un fichier de session." } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "خطأ في المصادقة" + "value": "不明な cmux チャンネル「%@」です。stable、nightly、rc、staging、debug:、またはセッションファイルのパスを指定してください。" } }, - "es": { + "ko": { "stringUnit": { "state": "translated", - "value": "Error de autenticación" + "value": "알 수 없는 cmux 채널 \"%@\"입니다. stable, nightly, rc, staging, debug: 또는 세션 파일 경로를 사용하세요." } }, - "zh-Hant": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "驗證錯誤" + "value": "未知的 cmux 渠道“%@”。请使用 stable、nightly、rc、staging、debug: 或会话文件路径。" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "인증 오류" + "value": "未知的 cmux 通道「%@」。請使用 stable、nightly、rc、staging、debug: 或工作階段檔案路徑。" } } } }, - "agent.claude.stopFailure.status.billing": { + "session.import.error.unreadable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Billing error" + "value": "Could not read %@." } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "請求エラー" + "value": "تعذّرت قراءة %@." } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "账单错误" + "value": "%@ konnte nicht gelesen werden." } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "Abrechnungsfehler" + "value": "No se pudo leer %@." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Erreur de facturation" + "value": "Impossible de lire %@." } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "خطأ في الفوترة" + "value": "%@ を読み込めませんでした。" } }, - "es": { + "ko": { "stringUnit": { "state": "translated", - "value": "Error de facturación" + "value": "%@ 파일을 읽을 수 없습니다." } }, - "zh-Hant": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "帳單錯誤" + "value": "无法读取 %@。" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "결제 오류" + "value": "無法讀取 %@。" } } } }, - "agent.claude.stopFailure.status.connectionDropped": { + "session.import.error.liveSnapshot": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connection dropped" + "value": "%@ is the session this cmux is saving right now. Run cmux restore-session without --from to reopen the previous launch." } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "接続が切断されました" + "value": "%@ ist die Sitzung, die dieses cmux gerade speichert. Führen Sie cmux restore-session ohne --from aus, um den vorherigen Start wieder zu öffnen." } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "连接已断开" + "value": "%@ est la session que ce cmux enregistre actuellement. Exécutez cmux restore-session sans --from pour rouvrir le lancement précédent." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Verbindung abgebrochen" + "value": "%@ هي الجلسة التي يحفظها cmux هذا الآن. شغّل cmux restore-session دون --from لإعادة فتح التشغيل السابق." } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Connexion interrompue" + "value": "%@ es la sesión que este cmux está guardando ahora. Ejecuta cmux restore-session sin --from para volver a abrir el inicio anterior." } }, - "ar": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "انقطع الاتصال" + "value": "%@ 是此 cmux 目前正在儲存的工作階段。執行不含 --from 的 cmux restore-session 以重新開啟上次啟動的內容。" } }, - "es": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Conexión perdida" + "value": "%@ 是此 cmux 当前正在保存的会话。运行不带 --from 的 cmux restore-session 以重新打开上次启动的内容。" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "連線已中斷" + "value": "%@ 파일은 이 cmux가 지금 저장 중인 세션입니다. 이전 실행을 다시 열려면 --from 없이 cmux restore-session을 실행하세요." } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "연결 끊김" + "value": "%@ はこの cmux が現在保存しているセッションです。前回の起動を再度開くには、--from を付けずに cmux restore-session を実行してください。" } } } }, - "agent.claude.stopFailure.status.outputLimit": { + "cli.session.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Output limit" + "value": "Usage: cmux session restore [--list] [--session ]...\n\nReopen Claude sessions that were running when cmux last quit unexpectedly.\ncmux finds them in the agent journal, skips any that are running or already\nopen, and resumes each in its own workspace through the launcher that started\nit.\n\nWithout --session, restore acts only after an unexpected quit.\n\nOptions:\n --list Show the sessions without restoring them.\n --session Restore only this session (repeatable)." } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "出力上限" + "value": "Verwendung: cmux session restore [--list] [--session ]...\n\nÖffnet Claude-Sitzungen erneut, die liefen, als cmux zuletzt unerwartet beendet wurde.\ncmux findet sie im Agent-Journal, überspringt laufende oder bereits geöffnete\nund setzt jede in einem eigenen Workspace\nüber den Launcher fort, der sie gestartet hat.\n\nOhne --session stellt restore nur nach einem unerwarteten Beenden wieder her.\n\nOptionen:\n --list Sitzungen anzeigen, ohne sie wiederherzustellen.\n --session Nur diese Sitzung wiederherstellen (wiederholbar)." } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "输出上限" + "value": "Utilisation : cmux session restore [--list] [--session ]...\n\nRouvre les sessions Claude en cours lorsque cmux s’est fermé de façon inattendue.\ncmux les trouve dans le journal des agents, ignore celles qui tournent ou sont déjà\nouvertes, et reprend chacune dans son propre espace de travail via le lanceur qui l’a\ndémarrée.\n\nSans --session, restore n’agit qu’après une fermeture inattendue.\n\nOptions :\n --list Afficher les sessions sans les restaurer.\n --session Restaurer uniquement cette session (répétable)." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Ausgabelimit" + "value": "الاستخدام: cmux session restore [--list] [--session ]...\n\nيعيد فتح جلسات Claude التي كانت قيد التشغيل عندما أُغلق cmux بشكل غير متوقع آخر مرة.\nيعثر cmux عليها في سجل الوكيل، ويتخطى ما يعمل منها أو المفتوح بالفعل،\nويستأنف كل جلسة في مساحة عمل خاصة بها\nعبر المشغّل الذي بدأها.\n\nبدون --session، لا تعمل الاستعادة إلا بعد إغلاق غير متوقع.\n\nالخيارات:\n --list عرض الجلسات دون استعادتها.\n --session استعادة هذه الجلسة فقط (قابل للتكرار)." } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Limite de sortie" + "value": "Uso: cmux session restore [--list] [--session ]...\n\nVuelve a abrir las sesiones de Claude que estaban en ejecución cuando cmux se cerró inesperadamente.\ncmux las encuentra en el diario de agentes, omite las que están en ejecución o ya\nabiertas, y reanuda cada una en su propio espacio de trabajo mediante el lanzador que\nla inició.\n\nSin --session, restore solo actúa tras un cierre inesperado.\n\nOpciones:\n --list Mostrar las sesiones sin restaurarlas.\n --session Restaurar solo esta sesión (repetible)." } }, - "ar": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "حد المخرجات" + "value": "用法: cmux session restore [--list] [--session ]...\n\n重新開啟 cmux 上次意外結束時仍在執行的 Claude 工作階段。\ncmux 會從代理日誌中找出它們,略過仍在執行或已開啟的工作階段,\n並透過原本啟動它的啟動器\n在各自的工作區中繼續。\n\n未指定 --session 時,restore 只會在意外結束後執行。\n\n選項:\n --list 只列出工作階段,不還原。\n --session 只還原此工作階段(可重複)。" } }, - "es": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Límite de salida" + "value": "用法: cmux session restore [--list] [--session ]...\n\n重新打开 cmux 上次意外退出时仍在运行的 Claude 会话。\ncmux 会从代理日志中找到它们,跳过仍在运行或已打开的会话,\n并通过最初启动它的启动器\n在各自的工作区中继续。\n\n未指定 --session 时,restore 只会在意外退出后执行。\n\n选项:\n --list 只列出会话,不恢复。\n --session 只恢复此会话(可重复)。" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "輸出上限" + "value": "사용법: cmux session restore [--list] [--session ]...\n\ncmux가 마지막으로 예기치 않게 종료될 때 실행 중이던 Claude 세션을 다시 엽니다.\ncmux는 에이전트 저널에서 세션을 찾고, 실행 중이거나 이미 열려 있는 세션은 건너뛰며,\n각 세션을 시작한 런처를 통해\n자체 워크스페이스에서 이어갑니다.\n\n--session이 없으면 restore는 예기치 않은 종료 후에만 동작합니다.\n\n옵션:\n --list 복원하지 않고 세션만 표시합니다.\n --session 이 세션만 복원합니다(반복 가능)." } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "출력 한도" + "value": "使用法: cmux session restore [--list] [--session ]...\n\ncmux が前回予期せず終了したときに実行中だった Claude セッションを再び開きます。\ncmux はエージェントジャーナルからセッションを見つけ、実行中または既に開いている\nものはスキップし、それぞれを起動したランチャー経由で\n専用のワークスペースで再開します。\n\n--session を指定しない場合、restore は予期しない終了の後にのみ動作します。\n\nオプション:\n --list 復元せずにセッションを表示します。\n --session このセッションのみ復元します(複数指定可)。" } } } }, - "agent.claude.stopFailure.status.overloaded": { + "cli.session.error.expectedRestore": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "API overloaded" + "value": "session: expected 'restore' (see cmux help session)" } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "API 過負荷" + "value": "session: 'restore' erwartet (siehe cmux help session)" } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "API 过载" + "value": "session : « restore » attendu (voir cmux help session)" } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "API überlastet" + "value": "session: كان المتوقع 'restore' (راجع cmux help session)" } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "API surchargée" + "value": "session: se esperaba 'restore' (consulta cmux help session)" } }, - "ar": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "واجهة API محمّلة فوق طاقتها" + "value": "session:應為 'restore'(請參閱 cmux help session)" } }, - "es": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "API sobrecargada" + "value": "session:应为 'restore'(请参阅 cmux help session)" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "API 過載" + "value": "session: 'restore'가 필요합니다(cmux help session 참조)" } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "API 과부하" + "value": "session: 'restore' が必要です(cmux help session を参照)" } } } }, - "agent.claude.stopFailure.status.rateLimited": { + "cli.session.error.sessionRequiresId": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rate limited" + "value": "session restore: --session requires an id" } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "レート制限中" + "value": "session restore: --session erfordert eine ID" } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "已限速" + "value": "session restore : --session nécessite un identifiant" } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Ratenbegrenzt" + "value": "session restore: يتطلب --session معرّفًا" } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Limite de requêtes atteinte" + "value": "session restore: --session requiere un id" } }, - "ar": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "تم تقييد المعدل" + "value": "session restore:--session 需要一個 ID" } }, - "es": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Límite de frecuencia alcanzado" + "value": "session restore:--session 需要一个 ID" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "已限速" + "value": "session restore: --session에는 ID가 필요합니다" } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "속도 제한됨" + "value": "session restore: --session には ID が必要です" } } } }, - "agent.claude.stopFailure.status.usageLimit": { + "cli.session.error.unknownArgument": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Usage limit" + "value": "session restore: unknown argument '%@'" } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "使用量の上限" + "value": "session restore: unbekanntes Argument '%@'" } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "用量上限" + "value": "session restore : argument inconnu « %@ »" } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Nutzungslimit" + "value": "session restore: وسيط غير معروف '%@'" } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Limite d'utilisation" + "value": "session restore: argumento desconocido '%@'" } }, - "ar": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "حد الاستخدام" + "value": "session restore:未知的參數 '%@'" } }, - "es": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Límite de uso" + "value": "session restore:未知参数 '%@'" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "用量上限" + "value": "session restore: 알 수 없는 인수 '%@'" } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "사용량 한도" + "value": "session restore: 不明な引数 '%@'" } } } }, - "agent.claude.stopFailure.status.usageLimitResets": { + "cli.session.list.empty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Usage limit, resets %@" + "value": "No agent sessions to restore." } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "使用量の上限、%@ にリセット" + "value": "Keine Agent-Sitzungen zum Wiederherstellen." } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "用量上限,%@ 重置" + "value": "Aucune session d’agent à restaurer." } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Nutzungslimit, Reset %@" + "value": "لا توجد جلسات وكيل لاستعادتها." } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Limite d'utilisation, réinitialisation %@" + "value": "No hay sesiones de agente para restaurar." } }, - "ar": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "حد الاستخدام، يُعاد التعيين %@" + "value": "沒有可還原的代理工作階段。" } }, - "es": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Límite de uso, se restablece %@" + "value": "没有可恢复的代理会话。" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "用量上限,%@ 重設" + "value": "복원할 에이전트 세션이 없습니다." } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "사용량 한도, %@에 초기화" + "value": "復元するエージェントセッションはありません。" } } } }, - "settings.app.reorderOnNotification.mode.agentActivity": { + "cli.session.restore.cleanQuit": { "extractionState": "manual", "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "نشاط الوكيل" - } - }, - "bs": { + "en": { "stringUnit": { "state": "translated", - "value": "Aktivnost agenata" + "value": "No agent sessions restored: cmux last quit normally. To restore one anyway, pass --session from --list." } }, - "da": { + "de": { "stringUnit": { "state": "translated", - "value": "Agentaktivitet" + "value": "Keine Agent-Sitzungen wiederhergestellt: cmux wurde zuletzt normal beendet. Um trotzdem eine wiederherzustellen, übergib --session aus --list." } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Agentenaktivität" + "value": "Aucune session d’agent restaurée : cmux s’est fermé normalement la dernière fois. Pour en restaurer une quand même, passez --session depuis --list." } }, - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Agent Activity" + "value": "لم تتم استعادة أي جلسات وكيل: أُغلق cmux بشكل طبيعي آخر مرة. لاستعادة جلسة على أي حال، مرّر --session من --list." } }, "es": { "stringUnit": { "state": "translated", - "value": "Actividad de agentes" + "value": "No se restauró ninguna sesión de agente: cmux se cerró normalmente la última vez. Para restaurar una de todos modos, pasa --session de --list." } }, - "fr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Activité des agents" + "value": "未還原任何代理工作階段:cmux 上次是正常結束。若仍要還原,請傳入 --list 中的 --session 。" } }, - "it": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Attività degli agenti" + "value": "未恢复任何代理会话:cmux 上次是正常退出。若仍要恢复,请传入 --list 中的 --session 。" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "エージェントのアクティビティ" + "value": "복원된 에이전트 세션이 없습니다. cmux가 마지막에 정상적으로 종료되었습니다. 그래도 복원하려면 --list에서 확인한 --session 를 전달하세요." } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "에이전트 활동" + "value": "エージェントセッションは復元されませんでした。cmux は前回正常に終了しています。それでも復元する場合は、--list の --session を指定してください。" } - }, - "nb": { + } + } + }, + "cli.session.restore.empty": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Agentaktivitet" + "value": "No agent sessions restored." } }, - "pl": { + "de": { "stringUnit": { "state": "translated", - "value": "Aktywność agentów" + "value": "Keine Agent-Sitzungen wiederhergestellt." } }, - "pt-BR": { + "fr": { "stringUnit": { "state": "translated", - "value": "Atividade dos agentes" + "value": "Aucune session d’agent restaurée." } }, - "ru": { + "ar": { "stringUnit": { "state": "translated", - "value": "Активность агентов" + "value": "لم تتم استعادة أي جلسات وكيل." } }, - "th": { + "es": { "stringUnit": { "state": "translated", - "value": "กิจกรรมของเอเจนต์" + "value": "No se restauró ninguna sesión de agente." } }, - "tr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Ajan etkinliği" + "value": "未還原任何代理工作階段。" } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Активність агентів" + "value": "未恢复任何代理会话。" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "代理活动" + "value": "복원된 에이전트 세션이 없습니다." } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "代理活動" + "value": "エージェントセッションは復元されませんでした。" } } } }, - "settings.app.reorderOnNotification.mode.notifications": { + "cli.session.restore.done": { "extractionState": "manual", "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "الإشعارات" - } - }, - "bs": { + "en": { "stringUnit": { "state": "translated", - "value": "Obavještenja" + "value": "Agent sessions restored: %lld" } }, - "da": { + "de": { "stringUnit": { "state": "translated", - "value": "Notifikationer" + "value": "Wiederhergestellte Agent-Sitzungen: %lld" } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Benachrichtigungen" + "value": "Sessions d’agent restaurées : %lld" } }, - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Notifications" + "value": "جلسات الوكيل المستعادة: %lld" } }, "es": { "stringUnit": { "state": "translated", - "value": "Notificaciones" + "value": "Sesiones de agente restauradas: %lld" } }, - "fr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Sur notification" + "value": "已還原的代理工作階段:%lld" } }, - "it": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Notifiche" + "value": "已恢复的代理会话:%lld" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "通知" + "value": "복원된 에이전트 세션: %lld" } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "알림" + "value": "復元したエージェントセッション: %lld" } - }, - "nb": { + } + } + }, + "agentRecovery.alert.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Varsler" + "value": "Restore agent sessions?" } }, - "pl": { + "de": { "stringUnit": { "state": "translated", - "value": "Powiadomienia" + "value": "Agent-Sitzungen wiederherstellen?" } }, - "pt-BR": { + "fr": { "stringUnit": { "state": "translated", - "value": "Notificações" + "value": "Restaurer les sessions d’agent ?" } }, - "ru": { + "ar": { "stringUnit": { "state": "translated", - "value": "Уведомления" + "value": "هل تريد استعادة جلسات الوكيل؟" } }, - "th": { + "es": { "stringUnit": { "state": "translated", - "value": "การแจ้งเตือน" + "value": "¿Restaurar las sesiones de agente?" } }, - "tr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Bildirimler" + "value": "要還原代理工作階段嗎?" } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Сповіщення" + "value": "要恢复代理会话吗?" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "通知" + "value": "에이전트 세션을 복원할까요?" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "通知" + "value": "エージェントセッションを復元しますか?" } } } }, - "settings.app.reorderOnNotification.mode.off": { + "agentRecovery.alert.message": { "extractionState": "manual", "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "إيقاف" - } - }, - "bs": { + "en": { "stringUnit": { "state": "translated", - "value": "Isključeno" + "value": "cmux quit unexpectedly while agent sessions were running. Sessions to restore: %lld. Each one reopens in its own workspace and resumes where it left off." } }, - "da": { + "de": { "stringUnit": { "state": "translated", - "value": "Fra" + "value": "cmux wurde unerwartet beendet, während Agent-Sitzungen liefen. Wiederherzustellende Sitzungen: %lld. Jede wird in einem eigenen Workspace geöffnet und dort fortgesetzt, wo sie aufgehört hat." } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Aus" + "value": "cmux s’est fermé de façon inattendue pendant que des sessions d’agent étaient en cours. Sessions à restaurer : %lld. Chacune rouvre dans son propre espace de travail et reprend là où elle s’était arrêtée." } }, - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Off" + "value": "أُغلق cmux بشكل غير متوقع أثناء تشغيل جلسات الوكيل. الجلسات المطلوب استعادتها: %lld. تُفتح كل جلسة في مساحة عمل خاصة بها وتُستأنف من حيث توقفت." } }, "es": { "stringUnit": { "state": "translated", - "value": "Desactivado" + "value": "cmux se cerró inesperadamente mientras había sesiones de agente en ejecución. Sesiones por restaurar: %lld. Cada una se abre en su propio espacio de trabajo y continúa donde se quedó." } }, - "fr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Désactivé" + "value": "cmux 在代理工作階段執行時意外結束。要還原的工作階段:%lld。每個工作階段都會在各自的工作區重新開啟,並從中斷處繼續。" } }, - "it": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Disattivato" + "value": "cmux 在代理会话运行时意外退出。要恢复的会话:%lld。每个会话都会在各自的工作区重新打开,并从中断处继续。" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "オフ" + "value": "에이전트 세션이 실행 중일 때 cmux가 예기치 않게 종료되었습니다. 복원할 세션: %lld. 각 세션은 자체 워크스페이스에서 다시 열리고 중단된 곳에서 이어집니다." } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "끔" + "value": "エージェントセッションの実行中に cmux が予期せず終了しました。復元するセッション: %lld。各セッションは専用のワークスペースで再び開き、中断したところから再開します。" } - }, - "nb": { + } + } + }, + "agentRecovery.alert.restore": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Av" + "value": "Restore Agent Sessions" } }, - "pl": { + "de": { "stringUnit": { "state": "translated", - "value": "Wyłączone" + "value": "Agent-Sitzungen wiederherstellen" } }, - "pt-BR": { + "fr": { "stringUnit": { "state": "translated", - "value": "Desativado" + "value": "Restaurer les sessions d’agent" } }, - "ru": { + "ar": { "stringUnit": { "state": "translated", - "value": "Выкл." + "value": "استعادة جلسات الوكيل" } }, - "th": { + "es": { "stringUnit": { "state": "translated", - "value": "ปิด" + "value": "Restaurar sesiones de agente" } }, - "tr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Kapalı" + "value": "還原代理工作階段" } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Вимкнено" + "value": "恢复代理会话" } }, - "zh-Hans": { + "ko": { "stringUnit": { "state": "translated", - "value": "关闭" + "value": "에이전트 세션 복원" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "關閉" + "value": "エージェントセッションを復元" } } } }, - "settings.app.reorderOnNotification.modeSubtitle": { + "agentRecovery.alert.notNow": { "extractionState": "manual", "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "ينقل مساحات العمل إلى الأعلى عندما تتلقى إشعارًا. مع نشاط الوكيل، ينقلها أيضًا عندما ترسل مطالبة أو ينهي وكيل دورة أو يحتاج إلى إدخال أو يفشل، لكن ليس أبدًا أثناء وجود المؤشر فوق الشريط الجانبي. الإيقاف يحافظ على ثبات مواضع الاختصارات." - } - }, - "bs": { + "en": { "stringUnit": { "state": "translated", - "value": "Pomjera radne prostore na vrh kada prime obavještenje. Aktivnost agenata ih pomjera i kada pošaljete upit ili agent završi potez, treba unos ili ne uspije, ali nikad dok je pokazivač iznad bočne trake. Isključeno čuva stabilne pozicije prečica." + "value": "Not Now" } }, - "da": { + "de": { "stringUnit": { "state": "translated", - "value": "Flytter arbejdsområder til toppen, når de modtager en notifikation. Agentaktivitet flytter dem også, når du sender en prompt eller en agent afslutter en tur, har brug for input eller fejler, men aldrig mens markøren er over sidepanelet. Fra holder genvejspositionerne stabile." + "value": "Nicht jetzt" } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Verschiebt Workspaces nach oben, wenn sie eine Benachrichtigung erhalten. Mit Agentenaktivität auch dann, wenn Sie einen Prompt senden oder ein Agent einen Durchlauf beendet, eine Eingabe braucht oder fehlschlägt, aber nie, während der Zeiger über der Seitenleiste ist. Aus hält die Positionen der Tastenkürzel stabil." + "value": "Pas maintenant" } }, - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Move workspaces to the top when they receive a notification. Agent Activity also moves them when you send a prompt or an agent finishes a turn, needs input, or fails, but never while you point at the sidebar. Off keeps shortcut positions stable." + "value": "ليس الآن" } }, "es": { "stringUnit": { "state": "translated", - "value": "Mueve los espacios de trabajo arriba cuando reciben una notificación. Con Actividad de agentes, también cuando envías un prompt o un agente termina un turno, necesita una respuesta o falla, pero nunca mientras el puntero está sobre la barra lateral. Desactivado mantiene estables las posiciones de los atajos." + "value": "Ahora no" } }, - "fr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Place les espaces de travail en haut lorsqu’ils reçoivent une notification. Avec Activité des agents, aussi lorsque vous envoyez un prompt ou qu’un agent termine un tour, attend une saisie ou échoue, mais jamais pendant que le pointeur survole la barre latérale. Désactivé garde les positions des raccourcis stables." + "value": "稍後" } }, - "it": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Sposta gli spazi di lavoro in cima quando ricevono una notifica. Attività degli agenti li sposta anche quando invii un prompt o un agente termina un turno, attende un input o fallisce, ma mai mentre il puntatore è sulla barra laterale. Disattivato mantiene stabili le posizioni delle scorciatoie." + "value": "以后再说" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "通知を受け取ったワークスペースを先頭に移動します。「エージェントのアクティビティ」では、プロンプトを送信したときや、エージェントがターンを終えたとき、入力を待っているとき、失敗したときにも移動しますが、ポインタがサイドバー上にある間は移動しません。「オフ」ではショートカットの位置が変わりません。" + "value": "나중에" } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "알림을 받으면 워크스페이스를 맨 위로 옮깁니다. 에이전트 활동을 선택하면 프롬프트를 보내거나 에이전트가 턴을 마치거나 입력이 필요하거나 실패할 때도 옮기지만, 포인터가 사이드바 위에 있을 때는 옮기지 않습니다. 끔은 단축키 위치를 고정합니다." + "value": "後で" } - }, - "nb": { + } + } + }, + "settings.notifications.soundWhenFocused.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Flytter arbeidsområder til toppen når de mottar et varsel. Agentaktivitet flytter dem også når du sender en ledetekst eller en agent fullfører en runde, trenger input eller feiler, men aldri mens pekeren er over sidefeltet. Av holder snarveisposisjonene stabile." + "value": "Play the notification sound even when the pane that notified is already focused. When off, a focused pane shows its ring without a sound." } }, - "pl": { + "ar": { "stringUnit": { "state": "translated", - "value": "Przenosi obszary robocze na górę, gdy otrzymają powiadomienie. Aktywność agentów przenosi je także, gdy wyślesz prompt lub agent zakończy turę, potrzebuje danych lub zakończy się błędem, ale nigdy, gdy wskaźnik jest nad paskiem bocznym. Wyłączone zachowuje stałe pozycje skrótów." + "value": "شغّل صوت الإشعار حتى عندما تكون اللوحة التي أرسلت الإشعار في موضع التركيز بالفعل. عند إيقافه، تعرض اللوحة المُركَّز عليها حلقتها دون صوت." } }, - "pt-BR": { + "de": { "stringUnit": { "state": "translated", - "value": "Move os espaços de trabalho para o topo quando recebem uma notificação. Atividade dos agentes também os move quando você envia um prompt ou um agente termina um turno, precisa de uma resposta ou falha, mas nunca enquanto o ponteiro está sobre a barra lateral. Desativado mantém as posições dos atalhos estáveis." + "value": "Den Benachrichtigungston auch abspielen, wenn der benachrichtigende Bereich bereits fokussiert ist. Wenn aus, zeigt ein fokussierter Bereich seinen Ring ohne Ton." } }, - "ru": { + "es": { "stringUnit": { "state": "translated", - "value": "Перемещает рабочие области наверх, когда они получают уведомление. «Активность агентов» также перемещает их, когда вы отправляете запрос или агент завершает ход, ждёт ввода или завершается с ошибкой, но никогда, пока указатель находится над боковой панелью. «Выкл.» сохраняет позиции сочетаний клавиш." + "value": "Reproduce el sonido de notificación incluso cuando el panel que notificó ya tiene el foco. Si está desactivado, un panel con foco muestra su anillo sin sonido." } }, - "th": { + "fr": { "stringUnit": { "state": "translated", - "value": "ย้ายเวิร์กสเปซขึ้นด้านบนเมื่อได้รับการแจ้งเตือน กิจกรรมของเอเจนต์จะย้ายเมื่อคุณส่งพรอมต์หรือเอเจนต์จบรอบ ต้องการข้อมูล หรือล้มเหลวด้วย แต่จะไม่ย้ายขณะที่ตัวชี้อยู่เหนือแถบด้านข้าง ปิดจะคงตำแหน่งของทางลัดไว้" + "value": "Jouer le son de notification même lorsque le panneau à l’origine de la notification a déjà le focus. Désactivé, un panneau actif affiche son anneau sans son." } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Bildirim aldıklarında çalışma alanlarını en üste taşır. Ajan etkinliği, bir istem gönderdiğinizde, bir ajan turunu bitirdiğinde, girdi beklediğinde veya başarısız olduğunda da taşır, ancak işaretçi kenar çubuğunun üzerindeyken asla taşımaz. Kapalı, kısayol konumlarını sabit tutar." + "value": "通知を送ったペインがすでにフォーカスされている場合でも通知音を鳴らします。オフの場合、フォーカス中のペインは音を鳴らさずにリングを表示します。" } }, - "uk": { + "ko": { "stringUnit": { "state": "translated", - "value": "Переміщує робочі області вгору, коли вони отримують сповіщення. «Активність агентів» також переміщує їх, коли ви надсилаєте запит або агент завершує хід, чекає на введення або завершується з помилкою, але ніколи, поки вказівник над бічною панеллю. «Вимкнено» зберігає позиції скорочень." + "value": "알림을 보낸 패널이 이미 포커스된 상태여도 알림음을 재생합니다. 끄면 포커스된 패널은 소리 없이 링만 표시합니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "工作区收到通知时移到顶部。选择“代理活动”时,你发送提示词或代理完成一轮、需要输入或失败时也会移动,但指针停在侧边栏上时绝不移动。关闭可让快捷键位置保持不变。" + "value": "即使发出通知的窗格已处于焦点状态,也播放通知声音。关闭时,处于焦点的窗格会显示光环但不发出声音。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "工作區收到通知時移到頂部。選擇「代理活動」時,你送出提示詞或代理完成一輪、需要輸入或失敗時也會移動,但指標停在側邊欄上時絕不移動。關閉可讓快捷鍵位置保持不變。" + "value": "即使發出通知的窗格已在焦點中,也播放通知聲音。關閉時,焦點中的窗格會顯示光環但不發出聲音。" } } } }, - "cli.restore.notice.routedLauncherUnavailable": { + "settings.notifications.soundWhenFocused.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "restore: this session was started through the routed launcher '%@', which is not on PATH. Resuming the agent directly instead." + "value": "Sound for Focused Pane" } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "restore: このセッションはルーティングされたランチャー「%@」経由で開始されましたが、PATH にありません。代わりにエージェントを直接再開します。" + "value": "صوت للوحة المُركَّز عليها" } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "restore:此会话是通过路由启动器“%@”启动的,但它不在 PATH 中。将改为直接恢复该代理。" + "value": "Ton für fokussierten Bereich" } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "restore: Diese Sitzung wurde über den weitergeleiteten Launcher „%@“ gestartet, der nicht im PATH liegt. Der Agent wird stattdessen direkt fortgesetzt." + "value": "Sonido para el panel con foco" } }, "fr": { "stringUnit": { "state": "translated", - "value": "restore : cette session a été lancée via le lanceur routé « %@ », qui est introuvable dans le PATH. Reprise directe de l'agent à la place." + "value": "Son pour le panneau actif" } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "restore: بدأت هذه الجلسة عبر المشغّل الموجَّه '%@'، وهو غير موجود في PATH. سيتم استئناف الوكيل مباشرةً بدلاً من ذلك." + "value": "フォーカス中のペインの通知音" } }, - "es": { + "ko": { "stringUnit": { "state": "translated", - "value": "restore: esta sesión se inició mediante el lanzador enrutado '%@', que no está en el PATH. Se reanudará el agente directamente." + "value": "포커스된 패널 소리" } }, - "zh-Hant": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "restore:此工作階段是透過路由啟動器「%@」啟動的,但它不在 PATH 中。將改為直接繼續該代理程式。" + "value": "焦点窗格的声音" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "restore: 이 세션은 라우팅된 런처 '%@'를 통해 시작되었지만 PATH에 없습니다. 대신 에이전트를 직접 재개합니다." + "value": "焦點窗格的聲音" } } } }, - "sidebar.extensions.action.noSpaceForPane": { + "cli.restore.notice.routedLauncherUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No space for new pane" + "value": "restore: this session was started through the routed launcher '%@', which is not on PATH. Resuming the agent directly instead." } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "لا توجد مساحة للوحة جديدة" + "value": "restore: このセッションはルーティングされたランチャー「%@」経由で開始されましたが、PATH にありません。代わりにエージェントを直接再開します。" } }, - "de": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Kein Platz für einen neuen Bereich" + "value": "restore:此会话是通过路由启动器“%@”启动的,但它不在 PATH 中。将改为直接恢复该代理。" } }, - "es": { + "de": { "stringUnit": { "state": "translated", - "value": "No hay espacio para un panel nuevo" + "value": "restore: Diese Sitzung wurde über den weitergeleiteten Launcher „%@“ gestartet, der nicht im PATH liegt. Der Agent wird stattdessen direkt fortgesetzt." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Pas de place pour un nouveau panneau" + "value": "restore : cette session a été lancée via le lanceur routé « %@ », qui est introuvable dans le PATH. Reprise directe de l'agent à la place." } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "新しいペインを置く場所がありません" + "value": "restore: بدأت هذه الجلسة عبر المشغّل الموجَّه '%@'، وهو غير موجود في PATH. سيتم استئناف الوكيل مباشرةً بدلاً من ذلك." } }, - "ko": { + "es": { "stringUnit": { "state": "translated", - "value": "새 창을 위한 공간이 없습니다" + "value": "restore: esta sesión se inició mediante el lanzador enrutado '%@', que no está en el PATH. Se reanudará el agente directamente." } }, - "zh-Hans": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "没有空间容纳新窗格" + "value": "restore:此工作階段是透過路由啟動器「%@」啟動的,但它不在 PATH 中。將改為直接繼續該代理程式。" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "沒有空間容納新窗格" + "value": "restore: 이 세션은 라우팅된 런처 '%@'를 통해 시작되었지만 PATH에 없습니다. 대신 에이전트를 직접 재개합니다." } } } @@ -570193,6 +569898,65 @@ } } }, + "sidebar.extensions.action.noSpaceForPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No space for new pane" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد مساحة للوحة جديدة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Platz für einen neuen Bereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay espacio para un panel nuevo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas de place pour un nouveau panneau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいペインを置く場所がありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 창을 위한 공간이 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有空间容纳新窗格" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有空間容納新窗格" + } + } + } + }, "cli.focus.defaultHelp": { "extractionState": "manual", "localizations": { diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift index b56f52518a11..f6e0edf1c44f 100644 --- a/Sources/TerminalController+Capabilities.swift +++ b/Sources/TerminalController+Capabilities.swift @@ -172,6 +172,11 @@ extension TerminalController { "window.close", "window.displays", "window.display", + "window.record.start", + "window.record.stop", + "window.record.status", + "window.record.note", + "window.record.list", "workspace.list", "workspace.create", "workspace.cloud_vm_open", diff --git a/Sources/TerminalController+WindowRecording.swift b/Sources/TerminalController+WindowRecording.swift new file mode 100644 index 000000000000..deb5c493d9a4 --- /dev/null +++ b/Sources/TerminalController+WindowRecording.swift @@ -0,0 +1,241 @@ +import AppKit +import CmuxFoundation +import CoreGraphics +import Foundation + +extension TerminalController { + /// Socket-worker router for `window.record.*`. + /// + /// These block their worker thread while ScreenCaptureKit answers, so the + /// execution policy keeps them off the main actor (see + /// `ControlCommandExecutionPolicy`). + nonisolated func v2WindowRecordingCommandOnSocketWorker( + method: String, + params: [String: Any] + ) -> V2CallResult { + switch method { + case "window.record.start": + return v2WindowRecordStart(params: params) + case "window.record.stop": + return v2WindowRecordStop(params: params) + case "window.record.status": + return v2WindowRecordStatus(params: params) + case "window.record.note": + return v2WindowRecordNote(params: params) + case "window.record.list": + return v2WindowRecordList() + default: + return .err( + code: "unknown_method", + message: "Unknown method \(method)", + data: nil + ) + } + } + + private nonisolated func v2WindowRecordStart(params: [String: Any]) -> V2CallResult { + let request: WindowRecordingRequest + do { + request = try WindowRecordingRequest.make(params: params) + } catch let failure as WindowRecordingRequest.Failure { + return .err(code: "invalid_params", message: failure.message, data: nil) + } catch { + return .err(code: "invalid_params", message: error.localizedDescription, data: nil) + } + guard let windowID = recordingWindowID(handle: request.windowHandle) else { + return .err( + code: "not_found", + message: request.windowHandle.map { "Window \($0) is not available" } + ?? "No window available", + data: nil + ) + } + let token = UUID() + let registered: Bool? = socketAwaitCallback(timeout: 10) { completion in + Task { + await WindowRecordingRegistry.shared.registerStart(token: token) + completion(true) + } + } + guard registered != nil else { + return .err(code: "timeout", message: "recording start registration timed out", data: nil) + } + return awaitRecordingCall( + timeout: 20, + onTimeout: { + await WindowRecordingRegistry.shared.abandonStart(token: token) + } + ) { + try await WindowRecordingRegistry.shared.start( + request: request, + windowID: windowID, + windowHandle: request.windowHandle, + token: token + ) + } + } + + private nonisolated func v2WindowRecordStop(params: [String: Any]) -> V2CallResult { + let id = (params["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + return awaitRecordingCall(timeout: 30) { + try await WindowRecordingRegistry.shared.stop(id: id?.isEmpty == false ? id : nil) + } + } + + private nonisolated func v2WindowRecordStatus(params: [String: Any]) -> V2CallResult { + let id = (params["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + return awaitRecordingCall(timeout: 10) { + try await WindowRecordingRegistry.shared.status(id: id?.isEmpty == false ? id : nil) + } + } + + private nonisolated func v2WindowRecordNote(params: [String: Any]) -> V2CallResult { + let id = (params["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let text = (params["text"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty else { + return .err(code: "invalid_params", message: "text is required", data: nil) + } + return awaitRecordingCall(timeout: 10) { + try await WindowRecordingRegistry.shared.note( + text: text, + id: id?.isEmpty == false ? id : nil + ) + } + } + + private nonisolated func v2WindowRecordList() -> V2CallResult { + let statuses: [WindowRecordingStatus]? = socketAwaitCallback(timeout: 10) { completion in + Task { + completion(await WindowRecordingRegistry.shared.recentStatuses()) + } + } + guard let statuses else { + return .err(code: "timeout", message: "window.record.list timed out", data: nil) + } + return .ok(["recordings": statuses.map { $0.jsonObject }]) + } + + /// Resolves the window to film: the one named by `window`, else the + /// frontmost cmux window. + private nonisolated func recordingWindowID(handle: String?) -> CGWindowID? { + v2MainSync { + let eligibleWindows = WindowRecordingWindowSelection.eligibleWindows(in: NSApp) + let selected: NSWindow? + if let handle { + guard let windowId = UUID(uuidString: handle), + let requested = AppDelegate.shared?.mainWindow(for: windowId), + eligibleWindows.contains(where: { $0 === requested }) else { + return nil + } + selected = requested + } else { + selected = WindowRecordingWindowSelection.select( + eligibleWindows: eligibleWindows, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow, + terminalWindow: self.tabManager?.window + ) + } + guard let selected else { return nil } + return WindowRecordingWindowSelection.windowID( + fromWindowNumber: selected.windowNumber + ) + } + } + + private nonisolated func awaitRecordingCall( + timeout: TimeInterval, + onTimeout: (@Sendable () async -> Void)? = nil, + _ work: @escaping () async throws -> WindowRecordingStatus + ) -> V2CallResult { + let outcome: Result? = socketAwaitCallback( + timeout: timeout + ) { completion in + Task { + do { + completion(.success(try await work())) + } catch { + completion(.failure(error)) + } + } + } + guard let outcome else { + // The work keeps running after the wait gives up. Let the caller + // undo it before answering, so a "timeout" reply never leaves + // behind the side effect it reports as failed. + if let onTimeout { + let _: Void? = socketAwaitCallback(timeout: 10) { completion in + Task { + await onTimeout() + completion(()) + } + } + } + return .err( + code: "timeout", + message: "recording command timed out after \(Int(timeout)) seconds", + data: nil + ) + } + switch outcome { + case let .success(status): + return .ok(status.jsonObject) + case let .failure(error): + return .err( + code: Self.recordingErrorCode(for: error), + message: error.localizedDescription, + data: nil + ) + } + } + + /// Maps a recorder error onto the socket error code the caller sees. + /// + /// Internal rather than private so a test can pin every case: the codes are + /// what a CLI or a tour branches on, and "a recording is already running" + /// has to stay distinguishable from "your output path is wrong". + nonisolated static func recordingErrorCode(for error: Error) -> String { + if let failure = error as? WindowRecordingRegistry.Failure { + switch failure { + case .busy: + return "conflict" + case .noRecording, .unknownRecording, .alreadyStopped: + return "not_found" + } + } + if let failure = error as? WindowRecordingSessionError { + switch failure { + case .unsupportedSystem: + return "unsupported" + case .windowGone: + return "not_found" + case .outputNotAFile: + // The caller's `out` names something that is not a file the + // recorder may replace, so this is their parameter, not our bug. + return "invalid_params" + case .stoppedWhileStarting: + // A stop of this caller's own arrived while the start was still + // opening the clip, so the two calls conflicted; nothing here is + // cmux's fault and nothing is left running. + return "conflict" + case .captureFailed, .composeFailed, .alreadyFinished: + return "internal_error" + } + } + if let failure = error as? WindowRecordingWriterError { + switch failure { + case .setup: + // The writer refuses to open at a path it cannot write, which is + // the caller's `out` and something they can fix. + return "invalid_params" + case .frame, .finish, .noFrames: + return "internal_error" + } + } + if error is WindowRecordingFrameGeometry.Failure { + return "invalid_params" + } + return "internal_error" + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index a25981a3f3ce..5993f5b57b4e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1756,6 +1756,15 @@ class TerminalController { ) } return v2Ok(id: request.id, result: ["completed": true]) + case "window.record.start", "window.record.stop", "window.record.status", + "window.record.note", "window.record.list": + return v2Result( + id: request.id, + v2WindowRecordingCommandOnSocketWorker( + method: request.method, + params: request.params + ) + ) case "browser.download.list", "browser.download.wait": return v2Result(id: request.id, request.method == "browser.download.list" ? v2BrowserDownloadListOnSocketWorker(params: request.params) : v2BrowserDownloadWaitOnSocketWorker(params: request.params)) case "browser.navigate", "browser.back", "browser.forward", "browser.reload", diff --git a/Sources/WindowRecordingFrameComposer.swift b/Sources/WindowRecordingFrameComposer.swift new file mode 100644 index 000000000000..b20e031219fe --- /dev/null +++ b/Sources/WindowRecordingFrameComposer.swift @@ -0,0 +1,141 @@ +import CmuxFoundation +import CoreGraphics +import CoreText +import Foundation + +/// Turns one captured window image into one encoded frame: crop, fit to the +/// clip's frame size, and draw the caption an agent asked for. +/// +/// CoreText rather than AppKit text drawing, because frames are composed on the +/// recorder's own executor rather than the main actor. +enum WindowRecordingFrameComposer { + static func compose( + source: CGImage, + geometry: WindowRecordingFrameGeometry, + caption: String? + ) -> CGImage? { + guard geometry.outputWidth > 0, geometry.outputHeight > 0 else { return nil } + let cropped = crop(source, to: geometry) ?? source + guard let context = CGContext( + data: nil, + width: geometry.outputWidth, + height: geometry.outputHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + ) else { + return nil + } + context.interpolationQuality = .high + let bounds = CGRect( + x: 0, + y: 0, + width: CGFloat(geometry.outputWidth), + height: CGFloat(geometry.outputHeight) + ) + context.setFillColor(gray: 0, alpha: 1) + context.fill(bounds) + context.draw(cropped, in: Self.fit(CGSize( + width: CGFloat(cropped.width), + height: CGFloat(cropped.height) + ), in: bounds)) + if let caption, !caption.isEmpty { + drawCaption(caption, in: context, bounds: bounds) + } + return context.makeImage() + } + + private static func crop( + _ source: CGImage, + to geometry: WindowRecordingFrameGeometry + ) -> CGImage? { + guard !geometry.cropsNothing + || geometry.cropWidth != source.width + || geometry.cropHeight != source.height else { + return source + } + let rect = CGRect( + x: CGFloat(geometry.cropX), + y: CGFloat(geometry.cropY), + width: CGFloat(geometry.cropWidth), + height: CGFloat(geometry.cropHeight) + ).intersection(CGRect( + x: 0, + y: 0, + width: CGFloat(source.width), + height: CGFloat(source.height) + )) + guard !rect.isEmpty else { return nil } + return source.cropping(to: rect) + } + + /// Centers the captured content without distorting it. The two sizes match + /// for the whole clip unless the window was resized while recording. + /// + /// A window that shrank keeps its own pixel size inside the frame instead of + /// being magnified: blurry text in the middle of a clip reads as a rendering + /// bug, while a black border reads as the window having been resized. + static func fit(_ content: CGSize, in bounds: CGRect) -> CGRect { + guard content.width > 0, content.height > 0 else { return bounds } + let scale = min(1, min(bounds.width / content.width, bounds.height / content.height)) + let size = CGSize(width: content.width * scale, height: content.height * scale) + return CGRect( + x: bounds.minX + ((bounds.width - size.width) / 2).rounded(), + y: bounds.minY + ((bounds.height - size.height) / 2).rounded(), + width: size.width, + height: size.height + ) + } + + private static func drawCaption( + _ caption: String, + in context: CGContext, + bounds: CGRect + ) { + let fontSize = max(12, bounds.height / 42) + let margin = max(8, bounds.height / 60) + let padding = max(5, bounds.height / 110) + let font = CTFontCreateUIFontForLanguage(.system, fontSize, nil) + ?? CTFontCreateWithName("Helvetica" as CFString, fontSize, nil) + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key(kCTFontAttributeName as String): font, + NSAttributedString.Key(kCTForegroundColorAttributeName as String): + CGColor(gray: 1, alpha: 1), + ] + let available = bounds.width - (margin * 2) - (padding * 2) + guard available > fontSize else { return } + var line = CTLineCreateWithAttributedString( + NSAttributedString(string: caption, attributes: attributes) + ) + if CTLineGetTypographicBounds(line, nil, nil, nil) > Double(available) { + let token = CTLineCreateWithAttributedString( + NSAttributedString(string: "\u{2026}", attributes: attributes) + ) + line = CTLineCreateTruncatedLine(line, Double(available), .end, token) ?? line + } + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + let textWidth = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil)) + let box = CGRect( + x: margin, + y: margin, + width: min(textWidth + (padding * 2), bounds.width - (margin * 2)), + height: ascent + descent + (padding * 2) + ) + context.saveGState() + context.setFillColor(gray: 0, alpha: 0.66) + context.addPath(CGPath( + roundedRect: box, + cornerWidth: padding, + cornerHeight: padding, + transform: nil + )) + context.fillPath() + context.textMatrix = .identity + context.textPosition = CGPoint(x: box.minX + padding, y: box.minY + padding + descent) + CTLineDraw(line, context) + context.restoreGState() + } +} diff --git a/Sources/WindowRecordingFrameWriter.swift b/Sources/WindowRecordingFrameWriter.swift new file mode 100644 index 000000000000..dbc1adb872f0 --- /dev/null +++ b/Sources/WindowRecordingFrameWriter.swift @@ -0,0 +1,331 @@ +import AVFoundation +import CmuxFoundation +import CoreGraphics +import CoreVideo +import Foundation +import ImageIO +import UniformTypeIdentifiers + +enum WindowRecordingWriterError: Error, Equatable, LocalizedError { + case setup(String) + case frame(String) + case finish(String) + case noFrames + + var errorDescription: String? { + switch self { + case let .setup(detail): "recorder setup failed: \(detail)" + case let .frame(detail): "frame write failed: \(detail)" + case let .finish(detail): "closing the recording failed: \(detail)" + case .noFrames: "the recording captured no frames" + } + } +} + +/// Writes one recording's frames to disk as they are captured. +/// +/// Frames are never collected as bitmaps: a 30 second clip of a Retina window +/// is a couple of gigabytes of uncompressed frames, which is not something the +/// app may hold in memory for an agent. The mp4 writer streams each frame to +/// disk; the gif writer keeps compressed frames until it knows the count. +/// +/// Frames are timed by the moment they were captured rather than by their +/// index, so a clip plays back at the speed the window was really moving at +/// even when the sampler could not keep up with the requested rate. +protocol WindowRecordingFrameWriter: AnyObject { + func append(_ image: CGImage, atOffsetSeconds offsetSeconds: Double) async throws + func finish() async throws +} + +/// H.264 in an mp4 container, through AVFoundation. +final class WindowRecordingMP4Writer: WindowRecordingFrameWriter { + private let writer: AVAssetWriter + private let input: AVAssetWriterInput + private let adaptor: AVAssetWriterInputPixelBufferAdaptor + private let width: Int + private let height: Int + private let framesPerSecond: Int + private var didStart = false + private var frameCount = 0 + // One timescale tick before zero, so the first frame may present at zero. + private var lastOffsetSeconds = -1.0 / 600 + + init(url: URL, width: Int, height: Int, framesPerSecond: Int) throws { + self.width = width + self.height = height + self.framesPerSecond = framesPerSecond + do { + writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + } catch { + throw WindowRecordingWriterError.setup(error.localizedDescription) + } + input = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: Self.bitRate( + width: width, + height: height, + framesPerSecond: framesPerSecond + ), + AVVideoMaxKeyFrameIntervalKey: max(1, framesPerSecond * 2), + ], + ] + ) + // Frames arrive as fast as the sampler produces them, not on a live + // clock, so the writer may take its time compressing them. + input.expectsMediaDataInRealTime = false + adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: width, + kCVPixelBufferHeightKey as String: height, + kCVPixelBufferCGImageCompatibilityKey as String: true, + kCVPixelBufferCGBitmapContextCompatibilityKey as String: true, + ] + ) + guard writer.canAdd(input) else { + throw WindowRecordingWriterError.setup("the video input was rejected") + } + writer.add(input) + } + + /// Roughly 0.1 bits per pixel per frame, clamped to a range that keeps a + /// terminal clip readable without making a pull request attachment huge. + private static func bitRate(width: Int, height: Int, framesPerSecond: Int) -> Int { + let pixels = Double(width * height) + let estimate = pixels * Double(framesPerSecond) * 0.1 + return Int(min(max(estimate, 600_000), 12_000_000)) + } + + private static let timescale: CMTimeScale = 600 + + func append(_ image: CGImage, atOffsetSeconds offsetSeconds: Double) async throws { + if !didStart { + guard writer.startWriting() else { + throw WindowRecordingWriterError.setup( + writer.error?.localizedDescription ?? "the writer refused to start" + ) + } + writer.startSession(atSourceTime: .zero) + didStart = true + } + try await waitUntilReady() + guard let buffer = makePixelBuffer(from: image) else { + throw WindowRecordingWriterError.frame("could not allocate a pixel buffer") + } + // Presentation times have to keep increasing even if two samples land + // in the same 1/600 second. + let offset = max(offsetSeconds, lastOffsetSeconds + (1 / Double(Self.timescale))) + lastOffsetSeconds = offset + guard adaptor.append( + buffer, + withPresentationTime: CMTime(seconds: offset, preferredTimescale: Self.timescale) + ) else { + throw WindowRecordingWriterError.frame( + writer.error?.localizedDescription ?? "the writer rejected a frame" + ) + } + frameCount += 1 + } + + /// The compressor applies backpressure between frames; wait for it rather + /// than dropping frames, and give up if it stalls for good. + private func waitUntilReady() async throws { + let deadline = ProcessInfo.processInfo.systemUptime + 5 + while !input.isReadyForMoreMediaData { + if writer.status == .failed { + throw WindowRecordingWriterError.frame( + writer.error?.localizedDescription ?? "the writer failed" + ) + } + guard ProcessInfo.processInfo.systemUptime < deadline else { + throw WindowRecordingWriterError.frame("the video compressor stalled") + } + await Self.pause() + } + } + + /// Two milliseconds that a cancelled task still waits out. + /// + /// `stop()` and `abandon()` cancel the sampling loop before they take the + /// writer's turn, so this wait often runs inside a cancelled task. A + /// `Task.sleep` throws there at once, and swallowing that turns the wait + /// into a spin that holds the writer against the very stop that cancelled + /// it. The frame in hand is worth finishing, so the delay stays. + private static func pause() async { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(2)) { + continuation.resume() + } + } + } + + private func makePixelBuffer(from image: CGImage) -> CVPixelBuffer? { + var buffer: CVPixelBuffer? + if let pool = adaptor.pixelBufferPool { + CVPixelBufferPoolCreatePixelBuffer(nil, pool, &buffer) + } + if buffer == nil { + CVPixelBufferCreate( + nil, + width, + height, + kCVPixelFormatType_32BGRA, + [ + kCVPixelBufferCGImageCompatibilityKey: true, + kCVPixelBufferCGBitmapContextCompatibilityKey: true, + ] as CFDictionary, + &buffer + ) + } + guard let pixelBuffer = buffer else { return nil } + CVPixelBufferLockBaseAddress(pixelBuffer, []) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } + guard let base = CVPixelBufferGetBaseAddress(pixelBuffer), + let context = CGContext( + data: base, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + ) else { + return nil + } + context.draw( + image, + in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)) + ) + return pixelBuffer + } + + func finish() async throws { + guard didStart, frameCount > 0 else { + // Cancelling a writer that never started raises an exception rather + // than returning an error, so only cancel one that is writing. + if writer.status == .writing { + writer.cancelWriting() + } + throw WindowRecordingWriterError.noFrames + } + input.markAsFinished() + await withCheckedContinuation { continuation in + writer.finishWriting { + continuation.resume() + } + } + if writer.status != .completed { + throw WindowRecordingWriterError.finish( + writer.error?.localizedDescription ?? "the writer did not complete" + ) + } + } +} + +/// An animated gif, through ImageIO. +/// +/// A gif carries a delay per frame rather than a frame rate, and a frame's delay +/// is only known once the next frame has been captured, so one frame is always +/// held back. +/// +/// `CGImageDestinationFinalize` fails unless the destination received exactly +/// the image count it was created with, and a recording's frame count is only +/// known when it stops. Frames are therefore kept as compressed PNG data (a +/// terminal frame compresses to a small fraction of its bitmap) and the gif is +/// assembled in `finish`, once the count is known. +final class WindowRecordingGIFWriter: WindowRecordingFrameWriter { + private let url: URL + private let nominalDelaySeconds: Double + private var pending: (image: CGImage, offsetSeconds: Double)? + private var frames: [(png: Data, delaySeconds: Double)] = [] + + /// Browsers clamp very short gif delays to a tenth of a second; staying at + /// or above two hundredths keeps playback predictable. + private static let delayRange = 0.02...10.0 + + init(url: URL, framesPerSecond: Int) throws { + self.url = url + nominalDelaySeconds = 1 / Double(max(1, framesPerSecond)) + } + + func append(_ image: CGImage, atOffsetSeconds offsetSeconds: Double) async throws { + if let pending { + try add(pending.image, delaySeconds: offsetSeconds - pending.offsetSeconds) + } + pending = (image, offsetSeconds) + } + + private func add(_ image: CGImage, delaySeconds: Double) throws { + let delay = min( + max(delaySeconds.isFinite ? delaySeconds : nominalDelaySeconds, Self.delayRange.lowerBound), + Self.delayRange.upperBound + ) + let data = NSMutableData() + guard let encoder = CGImageDestinationCreateWithData( + data as CFMutableData, + UTType.png.identifier as CFString, + 1, + nil + ) else { + throw WindowRecordingWriterError.frame("could not encode a gif frame") + } + CGImageDestinationAddImage(encoder, image, nil) + guard CGImageDestinationFinalize(encoder) else { + throw WindowRecordingWriterError.frame("could not encode a gif frame") + } + frames.append((data as Data, delay)) + } + + func finish() async throws { + if let pending { + try add(pending.image, delaySeconds: nominalDelaySeconds) + self.pending = nil + } + guard !frames.isEmpty else { + throw WindowRecordingWriterError.noFrames + } + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.gif.identifier as CFString, + frames.count, + nil + ) else { + throw WindowRecordingWriterError.setup("could not create the gif at \(url.path)") + } + CGImageDestinationSetProperties( + destination, + [ + kCGImagePropertyGIFDictionary: [ + kCGImagePropertyGIFLoopCount: 0, + ], + ] as CFDictionary + ) + for frame in frames { + guard let source = CGImageSourceCreateWithData(frame.png as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + throw WindowRecordingWriterError.finish("a gif frame could not be decoded") + } + CGImageDestinationAddImage( + destination, + image, + [ + kCGImagePropertyGIFDictionary: [ + kCGImagePropertyGIFDelayTime: frame.delaySeconds, + kCGImagePropertyGIFUnclampedDelayTime: frame.delaySeconds, + ], + ] as CFDictionary + ) + } + frames = [] + guard CGImageDestinationFinalize(destination) else { + throw WindowRecordingWriterError.finish("the gif could not be finalized") + } + } +} diff --git a/Sources/WindowRecordingRegistry.swift b/Sources/WindowRecordingRegistry.swift new file mode 100644 index 000000000000..9e1759cf816b --- /dev/null +++ b/Sources/WindowRecordingRegistry.swift @@ -0,0 +1,208 @@ +import CmuxFoundation +import CoreGraphics +import Foundation + +/// Owns the one recording cmux may have in flight, and remembers the last few +/// that finished so an agent can still ask where the file went. +/// +/// One at a time on purpose: two recorders sampling the same window would each +/// see the other's frame rate collapse, and the interesting failure ("I started +/// a second clip and both are half speed") is much harder to read than an error. +actor WindowRecordingRegistry { + static let shared = WindowRecordingRegistry() + + private static let historyLimit = 8 + + private var active: WindowRecordingSession? + /// The `record start` call that claimed `active`, so a caller that timed + /// out can end exactly the session it opened and nothing newer. + private var activeStartToken: UUID? + /// Starts still running, and those whose caller has already given up. + private var registeredStarts: Set = [] + private var pendingStarts: Set = [] + private var history: [WindowRecordingStatus] = [] + + enum Failure: Error, LocalizedError { + case busy(String) + case noRecording + case unknownRecording(String) + case alreadyStopped(String) + + var errorDescription: String? { + switch self { + case let .busy(id): + "a recording is already running (\(id)); stop it first" + case .noRecording: + "no recording is running" + case let .unknownRecording(id): + "no recording with id \(id)" + case let .alreadyStopped(id): + "recording \(id) already stopped" + } + } + } + + /// Registers a start token before the socket worker launches the operation. + /// This closes the gap where a timeout could be observed before `start` + /// first entered this actor. + func registerStart(token: UUID) { + registeredStarts.insert(token) + } + + func start( + request: WindowRecordingRequest, + windowID: CGWindowID, + windowHandle: String?, + token: UUID = UUID() + ) async throws -> WindowRecordingStatus { + guard registeredStarts.remove(token) != nil else { + throw WindowRecordingSessionError.alreadyFinished + } + pendingStarts.insert(token) + defer { + pendingStarts.remove(token) + } + await harvestFinishedRecording() + if let active { + throw Failure.busy(active.id) + } + let identifier = WindowRecordingRequest.recordingIdentifier(date: Date()) + let session = WindowRecordingSession( + id: identifier, + request: request, + outputURL: Self.outputURL(request: request, identifier: identifier), + windowID: windowID, + windowHandle: windowHandle + ) + // Claim the slot before the first await: opening a recording takes a + // capture round trip, and a second `record start` arriving during it + // would otherwise pass the check above and replace this session. + active = session + activeStartToken = token + do { + try await session.start() + } catch { + releaseSlot(held: session) + throw error + } + // `abandonStart` may have ended the session during the await above. + guard await session.isRecording else { + releaseSlot(held: session) + throw WindowRecordingSessionError.alreadyFinished + } + return await session.status + } + + /// Called when the socket caller of `start(token:)` stopped waiting. The + /// caller was told the start failed, so the session it would have opened + /// must not keep recording: end and discard it, or make a start that has + /// not claimed the slot yet give up when it gets there. Returns once the + /// session has released its writer and partial file. + func abandonStart(token: UUID) async { + if registeredStarts.remove(token) != nil { return } + guard pendingStarts.contains(token) || activeStartToken == token else { return } + guard let session = active, activeStartToken == token else { + return + } + await session.abandon(reason: "record start timed out; the recording was discarded") + remember(await session.status) + releaseSlot(held: session) + } + + private func releaseSlot(held session: WindowRecordingSession) { + guard active === session else { return } + active = nil + activeStartToken = nil + } + + func stop(id: String?) async throws -> WindowRecordingStatus { + await harvestFinishedRecording() + if let active, id == nil || id == active.id { + let status = await active.stop() + releaseSlot(held: active) + remember(status) + return status + } + guard let id else { + // A clip that reached its own `--max-seconds` limit has already + // closed its file, so report it rather than claiming nothing ran. + guard let latest = history.last else { throw Failure.noRecording } + return latest + } + guard let remembered = history.last(where: { $0.id == id }) else { + throw Failure.unknownRecording(id) + } + return remembered + } + + func status(id: String?) async throws -> WindowRecordingStatus { + await harvestFinishedRecording() + if let active, id == nil || id == active.id { + return await active.status + } + guard let id else { + guard let latest = history.last else { throw Failure.noRecording } + return latest + } + guard let remembered = history.last(where: { $0.id == id }) else { + throw Failure.unknownRecording(id) + } + return remembered + } + + /// Adds a caption to the running clip. Captions are what make a clip + /// readable in a pull request: an agent labels each step as it takes it. + func note(text: String, id: String?) async throws -> WindowRecordingStatus { + await harvestFinishedRecording() + guard let active, id == nil || id == active.id else { + guard let id else { throw Failure.noRecording } + if history.contains(where: { $0.id == id }) { + throw Failure.alreadyStopped(id) + } + throw Failure.unknownRecording(id) + } + _ = await active.note(text) + return await active.status + } + + func recentStatuses() async -> [WindowRecordingStatus] { + await harvestFinishedRecording() + var statuses = history + if let active { + statuses.append(await active.status) + } + return statuses + } + + /// A recording that reached its own frame or time limit has already closed + /// its file; move it into history the next time anyone asks. + private func harvestFinishedRecording() async { + guard let active else { return } + let isRecording = await active.isRecording + guard !isRecording else { return } + remember(await active.status) + releaseSlot(held: active) + } + + /// Files a finished clip in history. Internal rather than private so tests + /// can set up a registry without a window on screen. + func remember(_ status: WindowRecordingStatus) { + history.removeAll { $0.id == status.id } + history.append(status) + if history.count > Self.historyLimit { + history.removeFirst(history.count - Self.historyLimit) + } + } + + private static func outputURL( + request: WindowRecordingRequest, + identifier: String + ) -> URL { + if let outputPath = request.outputPath { + return URL(fileURLWithPath: outputPath) + } + return FileManager.default.temporaryDirectory + .appendingPathComponent(WindowRecordingRequest.outputDirectoryName) + .appendingPathComponent(request.outputFilename(identifier: identifier)) + } +} diff --git a/Sources/WindowRecordingSession.swift b/Sources/WindowRecordingSession.swift new file mode 100644 index 000000000000..696898575612 --- /dev/null +++ b/Sources/WindowRecordingSession.swift @@ -0,0 +1,467 @@ +import CmuxFoundation +import CoreGraphics +import Foundation +import ScreenCaptureKit + +enum WindowRecordingSessionError: Error, LocalizedError { + case unsupportedSystem + case windowGone + case captureFailed(String) + case composeFailed + case alreadyFinished + case stoppedWhileStarting + case outputNotAFile(String) + + var errorDescription: String? { + switch self { + case .unsupportedSystem: + "recording a cmux window needs macOS 14.4 or later" + case .windowGone: + "the window is no longer available" + case let .captureFailed(detail): + "window capture failed: \(detail)" + case .composeFailed: + "a frame could not be composed" + case .alreadyFinished: + "the recording has already finished" + case .stoppedWhileStarting: + "the recording was stopped before it captured anything" + case let .outputNotAFile(path): + "\(path) is not a file the recording may replace" + } + } +} + +/// What `window.record.start`, `.status` and `.stop` report back. +struct WindowRecordingStatus: Sendable { + enum State: String, Sendable { + case recording + case finished + case failed + } + + let id: String + let state: State + let format: WindowRecordingRequest.Format + let path: String + let label: String + let frames: Int + let seconds: Double + let width: Int + let height: Int + let notes: Int + let requestedFramesPerSecond: Int + let maximumSeconds: Double + let error: String? + + var jsonObject: [String: Any] { + var payload: [String: Any] = [ + "id": id, + "state": state.rawValue, + "format": format.rawValue, + "path": path, + "frames": frames, + "seconds": (seconds * 1000).rounded() / 1000, + "width": width, + "height": height, + "notes": notes, + "fps_requested": requestedFramesPerSecond, + "max_seconds": maximumSeconds, + ] + if !label.isEmpty { + payload["label"] = label + } + // What the sampler actually managed. A clip whose effective rate is far + // below the requested one still plays at the right speed, because frames + // are timed by when they were captured. + if frames > 1, seconds > 0 { + payload["fps_effective"] = ((Double(frames - 1) / seconds) * 10).rounded() / 10 + } + if let error { + payload["error"] = error + } + return payload + } +} + +/// One in-flight recording of one of cmux's own windows. +/// +/// Frames are sampled with ScreenCaptureKit's screenshot API on a schedule +/// instead of through a live `SCStream`: the same permission-free own-process +/// path the window screenshot command already uses, and no queue of +/// full-resolution frames waiting in memory. +actor WindowRecordingSession { + let id: String + let request: WindowRecordingRequest + let outputURL: URL + let windowHandle: String? + + private let windowID: CGWindowID + /// Frames go here while the clip runs; `outputURL` only appears once the + /// clip closes cleanly. A failed, interrupted or killed recording therefore + /// never leaves a broken file where the caller asked for the clip, and an + /// existing file there survives until there is something to replace it with. + private let workingURL: URL + private var filter: SCContentFilter? + private var writer: WindowRecordingFrameWriter? + private var geometry: WindowRecordingFrameGeometry? + private var captions: WindowRecordingCaptionTrack + private var state: WindowRecordingStatus.State = .recording + private var failure: String? + private var frames = 0 + private var startUptime = ProcessInfo.processInfo.systemUptime + private var lastOffsetSeconds: Double = 0 + private var loop: Task? + // Appends and the final close must not interleave: this actor suspends + // inside every append, so a `stop` arriving mid-append would otherwise + // close the file underneath it, and AVFoundation answers a frame appended + // after that with an exception no caller can catch. + private var writerBusy = false + private var writerWaiters: [CheckedContinuation] = [] + + init( + id: String, + request: WindowRecordingRequest, + outputURL: URL, + windowID: CGWindowID, + windowHandle: String? + ) { + self.id = id + self.request = request + self.outputURL = outputURL + self.windowID = windowID + self.windowHandle = windowHandle + workingURL = Self.workingURL(for: outputURL, id: id) + captions = WindowRecordingCaptionTrack() + } + + /// A hidden sibling of the clip, named after the recording so two sessions + /// and a stale leftover cannot be confused for each other. + private static func workingURL(for outputURL: URL, id: String) -> URL { + outputURL.deletingLastPathComponent().appendingPathComponent( + ".\(outputURL.lastPathComponent).recording-\(id).partial" + ) + } + + var isRecording: Bool { + state == .recording + } + + var status: WindowRecordingStatus { + WindowRecordingStatus( + id: id, + state: state, + format: request.format, + path: outputURL.path, + label: request.label, + frames: frames, + seconds: lastOffsetSeconds, + width: geometry?.outputWidth ?? 0, + height: geometry?.outputHeight ?? 0, + notes: captions.count, + requestedFramesPerSecond: request.framesPerSecond, + maximumSeconds: request.maximumSeconds, + error: failure + ) + } + + /// Opens the output and captures the first frame, so a caller that cannot + /// record at all learns about it from `record start` rather than from an + /// empty file later. + func start() async throws { + filter = try await resolveFilter() + let sample = try await sample() + let planned = try WindowRecordingFrameGeometry.plan( + windowPixelWidth: sample.image.width, + windowPixelHeight: sample.image.height, + pointPixelScale: sample.pointPixelScale, + request: request + ) + geometry = planned + // A caller that gave up on this start (see `abandon`) may have ended + // the session while the capture above was in flight; open nothing then. + guard state == .recording else { + throw WindowRecordingSessionError.stoppedWhileStarting + } + writer = try makeWriter(geometry: planned) + startUptime = ProcessInfo.processInfo.systemUptime + do { + try await write(sample: sample, offsetSeconds: 0) + } catch { + // A first frame that cannot be written leaves an open writer and a + // partial file behind; close and discard both before giving up. + await fail(error) + throw error + } + guard state == .recording else { + throw WindowRecordingSessionError.stoppedWhileStarting + } + loop = Task { [weak self] in + await self?.run() + } + } + + /// Adds a caption at the current point in the clip. + func note(_ text: String) -> Bool { + guard state == .recording else { return false } + return captions.append( + text: text, + atOffsetSeconds: ProcessInfo.processInfo.systemUptime - startUptime + ) + } + + /// Ends the recording and closes the file. Idempotent: an agent may stop a + /// clip that already reached its own limit. + func stop() async -> WindowRecordingStatus { + loop?.cancel() + loop = nil + if state == .recording { + await finalize() + } + return status + } + + /// Ends the session and throws away whatever it captured. Used when the + /// `record start` that opened it already reported a timeout: the caller + /// believes nothing is recording, so nothing may be left recording or on + /// disk. Idempotent, and safe while `start` is still suspended. + func abandon(reason: String) async { + await acquireWriter() + defer { releaseWriter() } + // Cancelled after the writer turn, not before: a `start` that was + // mid-append when this was called creates its sample loop only once + // that append returns. + loop?.cancel() + loop = nil + await discardWriter() + guard state == .recording else { return } + state = .failed + failure = reason + } + + private func run() async { + while !Task.isCancelled, state == .recording { + let target = startUptime + (Double(frames) * request.frameInterval) + let now = ProcessInfo.processInfo.systemUptime + if target > now { + try? await Task.sleep(nanoseconds: UInt64((target - now) * 1_000_000_000)) + } + guard !Task.isCancelled, state == .recording else { return } + let offset = ProcessInfo.processInfo.systemUptime - startUptime + if offset >= request.maximumSeconds || frames >= request.frameBudget { + await finalize() + return + } + do { + try await write(sample: try await sample(), offsetSeconds: offset) + } catch { + await fail(error) + return + } + if frames >= request.frameBudget { + await finalize() + return + } + } + } + + private func write(sample: Sample, offsetSeconds: Double) async throws { + await acquireWriter() + defer { releaseWriter() } + guard state == .recording, let writer, let geometry else { + throw WindowRecordingSessionError.alreadyFinished + } + // A window resized mid-clip is re-cropped into the frame size the clip + // opened with, rather than ending the recording. + let planned = try WindowRecordingFrameGeometry.plan( + windowPixelWidth: sample.image.width, + windowPixelHeight: sample.image.height, + pointPixelScale: sample.pointPixelScale, + request: request + ) + let composed = WindowRecordingFrameComposer.compose( + source: sample.image, + geometry: geometry.adoptingCrop(of: planned), + caption: request.drawsCaptions ? captions.caption(atOffsetSeconds: offsetSeconds) : nil + ) + guard let composed else { throw WindowRecordingSessionError.composeFailed } + try await writer.append(composed, atOffsetSeconds: offsetSeconds) + frames += 1 + lastOffsetSeconds = offsetSeconds + } + + private func finalize() async { + await acquireWriter() + defer { releaseWriter() } + guard state == .recording else { return } + guard let writer else { + // `start` claims the registry slot before it opens the file, so a + // stop can land while the first capture is still in flight. There + // is nothing to close and nothing on disk, and calling that "the + // recording has already finished" describes the wrong event: the + // clip never started. The `start` still in flight sees the state + // change and gives up the same way. + state = .failed + failure = WindowRecordingSessionError.stoppedWhileStarting.localizedDescription + return + } + self.writer = nil + do { + try await writer.finish() + try promote() + state = .finished + } catch { + state = .failed + failure = error.localizedDescription + try? FileManager.default.removeItem(at: workingURL) + } + } + + private func fail(_ error: Error) async { + await acquireWriter() + defer { releaseWriter() } + // A clip that already closed keeps its own verdict: a stop racing a + // capture failure must not turn a finished clip into a failed one. A + // writer opened by a `start` that lost that race is still released. + guard state == .recording else { + await discardWriter() + return + } + // Keep whatever was captured before the failure: a clip that ends when + // the window closes is still evidence of what happened before that. + if let writer { + self.writer = nil + do { + // Close the writer even with no frames, so an append that + // started it and then failed does not leave it open. + try await writer.finish() + if frames > 0 { + try promote() + } + } catch { + // Nothing usable was written; the partial file goes away below. + } + } + try? FileManager.default.removeItem(at: workingURL) + state = .failed + failure = error.localizedDescription + } + + /// Drops an open writer and its partial file without promoting anything. + /// The writer is closed first so no handle outlives the file it points at. + private func discardWriter() async { + guard let writer else { return } + self.writer = nil + try? await writer.finish() + try? FileManager.default.removeItem(at: workingURL) + } + + /// Moves the finished clip to the path the caller asked for. + private func promote() throws { + let manager = FileManager.default + if manager.fileExists(atPath: outputURL.path) { + _ = try manager.replaceItemAt(outputURL, withItemAt: workingURL) + } else { + try manager.moveItem(at: workingURL, to: outputURL) + } + } + + /// Only one operation touches the writer at a time. An actor alone is not + /// enough: every append suspends this actor, which lets `stop` in. + private func acquireWriter() async { + while writerBusy { + await withCheckedContinuation { (continuation: CheckedContinuation) in + writerWaiters.append(continuation) + } + } + writerBusy = true + } + + private func releaseWriter() { + writerBusy = false + guard !writerWaiters.isEmpty else { return } + writerWaiters.removeFirst().resume() + } + + private func makeWriter( + geometry: WindowRecordingFrameGeometry + ) throws -> WindowRecordingFrameWriter { + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // A directory, socket or device at the output path is not something a + // recording gets to delete on the caller's behalf. + if let attributes = try? FileManager.default.attributesOfItem(atPath: outputURL.path), + let type = attributes[.type] as? FileAttributeType, + type != .typeRegular, type != .typeSymbolicLink { + throw WindowRecordingSessionError.outputNotAFile(outputURL.path) + } + try? FileManager.default.removeItem(at: workingURL) + switch request.format { + case .mp4: + return try WindowRecordingMP4Writer( + url: workingURL, + width: geometry.outputWidth, + height: geometry.outputHeight, + framesPerSecond: request.framesPerSecond + ) + case .gif: + return try WindowRecordingGIFWriter( + url: workingURL, + framesPerSecond: request.framesPerSecond + ) + } + } + + private struct Sample { + let image: CGImage + let pointPixelScale: Double + } + + private func resolveFilter() async throws -> SCContentFilter { + if #available(macOS 14.4, *) { + // The current-process query captures cmux's own windows without + // Screen Recording permission, and cannot reach another app's + // windows even if that permission was granted. + let content: SCShareableContent + do { + content = try await SCShareableContent.currentProcess + } catch { + throw WindowRecordingSessionError.captureFailed(error.localizedDescription) + } + guard let window = content.windows.first(where: { $0.windowID == windowID }) else { + throw WindowRecordingSessionError.windowGone + } + return SCContentFilter(desktopIndependentWindow: window) + } + throw WindowRecordingSessionError.unsupportedSystem + } + + private func sample() async throws -> Sample { + guard let filter else { throw WindowRecordingSessionError.windowGone } + let info = SCShareableContent.info(for: filter) + // A window closed mid-clip reports an empty rectangle rather than an + // error, and capturing that gives a one-pixel frame stretched over the + // whole clip. End the recording instead and keep what came before. + guard info.contentRect.width > 1, info.contentRect.height > 1 else { + throw WindowRecordingSessionError.windowGone + } + let pixelScale = Double(info.pointPixelScale) > 0 ? Double(info.pointPixelScale) : 1 + let configuration = SCStreamConfiguration() + configuration.width = max(1, Int((Double(info.contentRect.width) * pixelScale).rounded(.up))) + configuration.height = max(1, Int((Double(info.contentRect.height) * pixelScale).rounded(.up))) + configuration.showsCursor = false + configuration.ignoreShadowsSingleWindow = true + configuration.captureResolution = .best + do { + let image = try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: configuration + ) + return Sample(image: image, pointPixelScale: pixelScale) + } catch { + throw WindowRecordingSessionError.captureFailed(error.localizedDescription) + } + } +} diff --git a/Sources/WindowRecordingWindowSelection.swift b/Sources/WindowRecordingWindowSelection.swift new file mode 100644 index 000000000000..fe533d28e100 --- /dev/null +++ b/Sources/WindowRecordingWindowSelection.swift @@ -0,0 +1,45 @@ +import AppKit +import CoreGraphics + +/// Picks the cmux window a recording films, and validates its identifier. +/// +/// The same preference order as the debug screenshot command's +/// `WindowScreenshotWindowSelector`, kept separate because that one is a +/// DEBUG-only type and recording ships in Release. Nothing here captures +/// anything; it only chooses a window that is on screen and big enough to be +/// worth filming. +enum WindowRecordingWindowSelection { + @MainActor + static func eligibleWindows(in application: NSApplication) -> [NSWindow] { + application.windows.filter { window in + window.isVisible && + !window.isMiniaturized && + window.contentView != nil && + !window.frame.isEmpty + } + } + + @MainActor + static func select( + eligibleWindows: [NSWindow], + keyWindow: NSWindow?, + mainWindow: NSWindow?, + terminalWindow: NSWindow? + ) -> NSWindow? { + let preferred = [keyWindow, mainWindow, terminalWindow].compactMap { $0 } + if let match = preferred.first(where: { candidate in + eligibleWindows.contains(where: { $0 === candidate }) + }) { + return match + } + return eligibleWindows.max { lhs, rhs in + lhs.frame.width * lhs.frame.height < rhs.frame.width * rhs.frame.height + } + } + + /// ScreenCaptureKit takes an unsigned window id; AppKit hands out a signed + /// window number, and a negative one belongs to no capturable window. + static func windowID(fromWindowNumber windowNumber: Int) -> CGWindowID? { + CGWindowID(exactly: windowNumber) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 08e77f7a70c7..d8ea2f56ad43 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1100,6 +1100,7 @@ B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */; }; 126810B60000000000000002 /* CMUXCLI+ProcessSignalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126810B60000000000000001 /* CMUXCLI+ProcessSignalState.swift */; }; A1E8DF699F239A8A5A2FC122 /* CMUXCLI+PromptSubmit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B782728C37B72B061710BA7 /* CMUXCLI+PromptSubmit.swift */; }; + A10021000000000000000001 /* CMUXCLI+Record.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10021000000000000000002 /* CMUXCLI+Record.swift */; }; C73660010000000000000001 /* CMUXCLI+RemotePTYErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73660010000000000000002 /* CMUXCLI+RemotePTYErrors.swift */; }; REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */; }; D7AB12048000000000000001 /* CMUXCLI+RemoteWorkingDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB12048000000000000002 /* CMUXCLI+RemoteWorkingDirectory.swift */; }; @@ -3472,6 +3473,7 @@ A10020000000000000000005 /* TerminalController+SurfaceSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000006 /* TerminalController+SurfaceSelection.swift */; }; DC6E1A6F7452404AA8DE3F5E /* TerminalController+VaultCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E042D748CB4949BE375319 /* TerminalController+VaultCommands.swift */; }; D6212D0C00000000000000D3 /* TerminalController+WindowDockBrowserRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6212D0C00000000000000D4 /* TerminalController+WindowDockBrowserRouting.swift */; }; + 9065A1010000000000000001 /* TerminalController+WindowRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A1010000000000000002 /* TerminalController+WindowRecording.swift */; }; 9065A0010000000000000001 /* TerminalController+WindowScreenshotCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A0010000000000000002 /* TerminalController+WindowScreenshotCapture.swift */; }; C0DE00000000000000000C84 /* TerminalController+WorkspaceCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */; }; D1A70000000000000000000C /* TerminalController+WorkspaceCreateExecutionPreparation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A70000000000000000000B /* TerminalController+WorkspaceCreateExecutionPreparation.swift */; }; @@ -3821,6 +3823,13 @@ 8770D0F2D2BB45359D6BE5E3 /* WindowKeyDownReplayGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */; }; 129170000000000000000001 /* WindowOverlayChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 129170000000000000000002 /* WindowOverlayChromeTests.swift */; }; 973DC7D4CD41516D459E17FC /* WindowPointHitTestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BCEFD7668C02FB795ED26F /* WindowPointHitTestTests.swift */; }; + 3D9E5674AF892C5F1A2C7720 /* WindowRecordingErrorCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04A01C2D99463F9AB538969F /* WindowRecordingErrorCodeTests.swift */; }; + 9065A1020000000000000001 /* WindowRecordingFrameComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A1020000000000000002 /* WindowRecordingFrameComposer.swift */; }; + 9065A1030000000000000001 /* WindowRecordingFrameWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A1030000000000000002 /* WindowRecordingFrameWriter.swift */; }; + A6CC55B334F22314E39BE6A6 /* WindowRecordingPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB0A04BC0DF676A62F70094 /* WindowRecordingPipelineTests.swift */; }; + 9065A1040000000000000001 /* WindowRecordingRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A1040000000000000002 /* WindowRecordingRegistry.swift */; }; + 9065A1050000000000000001 /* WindowRecordingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A1050000000000000002 /* WindowRecordingSession.swift */; }; + 9065A1060000000000000001 /* WindowRecordingWindowSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A1060000000000000002 /* WindowRecordingWindowSelection.swift */; }; 6042C0056042C0056042C005 /* WindowScopedShortcutHintModifierMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6042D0056042D0056042D005 /* WindowScopedShortcutHintModifierMonitor.swift */; }; 9065A0090000000000000001 /* WindowScreenshotBackendAttempt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A0090000000000000002 /* WindowScreenshotBackendAttempt.swift */; }; 9065A0050000000000000001 /* WindowScreenshotCaptureCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A0050000000000000002 /* WindowScreenshotCaptureCoordinator.swift */; }; @@ -5250,6 +5259,7 @@ B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Process.swift"; sourceTree = ""; }; 126810B60000000000000001 /* CMUXCLI+ProcessSignalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ProcessSignalState.swift"; sourceTree = ""; }; 0B782728C37B72B061710BA7 /* CMUXCLI+PromptSubmit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+PromptSubmit.swift"; sourceTree = ""; }; + A10021000000000000000002 /* CMUXCLI+Record.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Record.swift"; sourceTree = ""; }; C73660010000000000000002 /* CMUXCLI+RemotePTYErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RemotePTYErrors.swift"; sourceTree = ""; }; REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Remotes.swift"; sourceTree = ""; }; D7AB12048000000000000002 /* CMUXCLI+RemoteWorkingDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RemoteWorkingDirectory.swift"; sourceTree = ""; }; @@ -7492,6 +7502,7 @@ A10020000000000000000006 /* TerminalController+SurfaceSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SurfaceSelection.swift"; sourceTree = ""; }; 78E042D748CB4949BE375319 /* TerminalController+VaultCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+VaultCommands.swift"; sourceTree = ""; }; D6212D0C00000000000000D4 /* TerminalController+WindowDockBrowserRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WindowDockBrowserRouting.swift"; sourceTree = ""; }; + 9065A1010000000000000002 /* TerminalController+WindowRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WindowRecording.swift"; sourceTree = ""; }; 9065A0010000000000000002 /* TerminalController+WindowScreenshotCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WindowScreenshotCapture.swift"; sourceTree = ""; }; C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreate.swift"; sourceTree = ""; }; D1A70000000000000000000B /* TerminalController+WorkspaceCreateExecutionPreparation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateExecutionPreparation.swift"; sourceTree = ""; }; @@ -7839,6 +7850,13 @@ B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowKeyDownReplayGuardTests.swift; sourceTree = ""; }; 129170000000000000000002 /* WindowOverlayChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowOverlayChromeTests.swift; sourceTree = ""; }; 02BCEFD7668C02FB795ED26F /* WindowPointHitTestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WindowPointHitTestTests.swift; sourceTree = ""; }; + 04A01C2D99463F9AB538969F /* WindowRecordingErrorCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowRecordingErrorCodeTests.swift"; sourceTree = ""; }; + 9065A1020000000000000002 /* WindowRecordingFrameComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowRecordingFrameComposer.swift; sourceTree = ""; }; + 9065A1030000000000000002 /* WindowRecordingFrameWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowRecordingFrameWriter.swift; sourceTree = ""; }; + 7AB0A04BC0DF676A62F70094 /* WindowRecordingPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowRecordingPipelineTests.swift"; sourceTree = ""; }; + 9065A1040000000000000002 /* WindowRecordingRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowRecordingRegistry.swift; sourceTree = ""; }; + 9065A1050000000000000002 /* WindowRecordingSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowRecordingSession.swift; sourceTree = ""; }; + 9065A1060000000000000002 /* WindowRecordingWindowSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowRecordingWindowSelection.swift; sourceTree = ""; }; 6042D0056042D0056042D005 /* WindowScopedShortcutHintModifierMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScopedShortcutHintModifierMonitor.swift; sourceTree = ""; }; 9065A0090000000000000002 /* WindowScreenshotBackendAttempt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotBackendAttempt.swift; sourceTree = ""; }; 9065A0050000000000000002 /* WindowScreenshotCaptureCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotCaptureCoordinator.swift; sourceTree = ""; }; @@ -9843,6 +9861,12 @@ 9065A0060000000000000002 /* WindowScreenshotLabel.swift */, 9065A00A0000000000000002 /* WindowScreenshotScreenCapturePolicy.swift */, 9065A0070000000000000002 /* WindowScreenshotTarget.swift */, + 9065A1010000000000000002 /* TerminalController+WindowRecording.swift */, + 9065A1020000000000000002 /* WindowRecordingFrameComposer.swift */, + 9065A1030000000000000002 /* WindowRecordingFrameWriter.swift */, + 9065A1040000000000000002 /* WindowRecordingRegistry.swift */, + 9065A1050000000000000002 /* WindowRecordingSession.swift */, + 9065A1060000000000000002 /* WindowRecordingWindowSelection.swift */, 9065A0080000000000000002 /* WindowScreenshotWindowSelector.swift */, A79840030000000000000001 /* TerminalController+SocketConfiguration.swift */, A79840040000000000000001 /* TerminalController+SocketCleanup.swift */, @@ -11128,6 +11152,7 @@ C08672010000000000000008 /* PiCompactedFeedEventExpander.swift */, B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */, A10020000000000000000008 /* CMUXCLI+SurfaceSelection.swift */, + A10021000000000000000002 /* CMUXCLI+Record.swift */, 5A57E0000000000000000002 /* CMUXCLI+Paste.swift */, A64B5058972147F941BD6FA5 /* CMUXCLI+WindowDispatch.swift */, E1A09ECE26DDC67D94CF18B1 /* CMUXCLI+Current.swift */, @@ -12307,6 +12332,8 @@ 3D5E43A566AB0B441162EC5A /* CloudTreeNodeCacheTests.swift */, 23E35F95320CBED308AD98CF /* CloudTreeTogglePerformanceTests.swift */, 0C37EA14DE74095FB1BB23E1 /* CloudTreeDisclosureIntentTests.swift */, + 7AB0A04BC0DF676A62F70094 /* WindowRecordingPipelineTests.swift */, + 04A01C2D99463F9AB538969F /* WindowRecordingErrorCodeTests.swift */, 1C38B9AFEEE4FAE6089C250F /* CloudTreeRowToolTipTests.swift */, 6F27EA9972F21B98FB4A7312 /* SurfaceCatalogObservationTests.swift */, DEAB02E73CAA231F5E266CF7 /* ClaudeStopFailureStatusTests.swift */, @@ -15067,6 +15094,7 @@ A10020000000000000000005 /* TerminalController+SurfaceSelection.swift in Sources */, DC6E1A6F7452404AA8DE3F5E /* TerminalController+VaultCommands.swift in Sources */, D6212D0C00000000000000D3 /* TerminalController+WindowDockBrowserRouting.swift in Sources */, + 9065A1010000000000000001 /* TerminalController+WindowRecording.swift in Sources */, 9065A0010000000000000001 /* TerminalController+WindowScreenshotCapture.swift in Sources */, C0DE00000000000000000C84 /* TerminalController+WorkspaceCreate.swift in Sources */, D1A70000000000000000000C /* TerminalController+WorkspaceCreateExecutionPreparation.swift in Sources */, @@ -15308,6 +15336,11 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, D0B10024A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift in Sources */, 313585583035A1E685010A97 /* WindowKeyDownReplayGuard.swift in Sources */, + 9065A1020000000000000001 /* WindowRecordingFrameComposer.swift in Sources */, + 9065A1030000000000000001 /* WindowRecordingFrameWriter.swift in Sources */, + 9065A1040000000000000001 /* WindowRecordingRegistry.swift in Sources */, + 9065A1050000000000000001 /* WindowRecordingSession.swift in Sources */, + 9065A1060000000000000001 /* WindowRecordingWindowSelection.swift in Sources */, 6042C0056042C0056042C005 /* WindowScopedShortcutHintModifierMonitor.swift in Sources */, 9065A0090000000000000001 /* WindowScreenshotBackendAttempt.swift in Sources */, 9065A0050000000000000001 /* WindowScreenshotCaptureCoordinator.swift in Sources */, @@ -15627,6 +15660,7 @@ B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */, 126810B60000000000000002 /* CMUXCLI+ProcessSignalState.swift in Sources */, A1E8DF699F239A8A5A2FC122 /* CMUXCLI+PromptSubmit.swift in Sources */, + A10021000000000000000001 /* CMUXCLI+Record.swift in Sources */, C73660010000000000000001 /* CMUXCLI+RemotePTYErrors.swift in Sources */, REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */, D7AB12048000000000000001 /* CMUXCLI+RemoteWorkingDirectory.swift in Sources */, @@ -16934,6 +16968,8 @@ 8770D0F2D2BB45359D6BE5E3 /* WindowKeyDownReplayGuardTests.swift in Sources */, 129170000000000000000001 /* WindowOverlayChromeTests.swift in Sources */, 973DC7D4CD41516D459E17FC /* WindowPointHitTestTests.swift in Sources */, + 3D9E5674AF892C5F1A2C7720 /* WindowRecordingErrorCodeTests.swift in Sources */, + A6CC55B334F22314E39BE6A6 /* WindowRecordingPipelineTests.swift in Sources */, 606600010000000000000001 /* WindowTerminalHostViewTitlebarHitTests.swift in Sources */, 604500100000000000000003 /* WindowTitleTemplateTests.swift in Sources */, 7F0A0E04A8CADC84BB4F1DF7 /* WorkspaceActionDispatcherTests.swift in Sources */, diff --git a/cmuxTests/WindowRecordingErrorCodeTests.swift b/cmuxTests/WindowRecordingErrorCodeTests.swift new file mode 100644 index 000000000000..12e1a52fdb7c --- /dev/null +++ b/cmuxTests/WindowRecordingErrorCodeTests.swift @@ -0,0 +1,108 @@ +import CmuxFoundation +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Pins the socket error code every recorder failure comes back as. +/// +/// A CLI, a dogfood tour and an agent all branch on the code rather than the +/// message: `conflict` means "stop the other recording and retry", `not_found` +/// means "pick another window", `invalid_params` means "fix your flags" and +/// `internal_error` means "this is cmux's fault". Getting one wrong sends a +/// caller down the wrong path, and a case added without a mapping here reads as +/// an internal error even when the caller could fix it. +@Suite struct WindowRecordingErrorCodeTests { + @Test func registryFailuresSeparateConflictFromNotFound() { + #expect(TerminalController.recordingErrorCode(for: WindowRecordingRegistry.Failure.busy("abc")) == "conflict") + #expect(TerminalController.recordingErrorCode(for: WindowRecordingRegistry.Failure.noRecording) == "not_found") + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingRegistry.Failure.unknownRecording("abc") + ) == "not_found" + ) + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingRegistry.Failure.alreadyStopped("abc") + ) == "not_found" + ) + } + + @Test func sessionFailuresReportWhoseFaultTheyAre() { + #expect(TerminalController.recordingErrorCode(for: WindowRecordingSessionError.unsupportedSystem) == "unsupported") + #expect(TerminalController.recordingErrorCode(for: WindowRecordingSessionError.windowGone) == "not_found") + #expect(TerminalController.recordingErrorCode(for: WindowRecordingSessionError.composeFailed) == "internal_error") + #expect(TerminalController.recordingErrorCode(for: WindowRecordingSessionError.alreadyFinished) == "internal_error") + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingSessionError.captureFailed("no window server") + ) == "internal_error" + ) + } + + /// A stop that lands while the start is still opening the clip conflicts + /// with it; neither call is at fault and nothing is left recording. + @Test func aStopDuringStartIsAConflict() { + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingSessionError.stoppedWhileStarting + ) == "conflict" + ) + } + + /// The writer's own failures were missing from the switch, so `--out` on a + /// read-only volume read as cmux's fault. Only opening the file is the + /// caller's parameter; a frame that will not encode is ours. + @Test func writerFailuresSeparateSetupFromEncoding() { + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingWriterError.setup("could not create the gif at /clip.gif") + ) == "invalid_params" + ) + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingWriterError.frame("the writer rejected a frame") + ) == "internal_error" + ) + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingWriterError.finish("the writer did not complete") + ) == "internal_error" + ) + #expect( + TerminalController.recordingErrorCode(for: WindowRecordingWriterError.noFrames) == "internal_error" + ) + } + + /// The case the router's switch was missing: a caller who points `--out` at + /// a directory gets told their parameter is wrong, not that cmux broke. + @Test func anUnusableOutputPathIsTheCallersParameter() { + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingSessionError.outputNotAFile("/tmp") + ) == "invalid_params" + ) + } + + @Test func geometryFailuresAreParameterFailures() { + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingFrameGeometry.Failure.regionOutsideWindow + ) == "invalid_params" + ) + #expect( + TerminalController.recordingErrorCode( + for: WindowRecordingFrameGeometry.Failure.emptyWindow + ) == "invalid_params" + ) + } + + @Test func anUnrecognizedErrorStaysAnInternalError() { + struct Surprise: Error {} + #expect(TerminalController.recordingErrorCode(for: Surprise()) == "internal_error") + } +} diff --git a/cmuxTests/WindowRecordingPipelineTests.swift b/cmuxTests/WindowRecordingPipelineTests.swift new file mode 100644 index 000000000000..e0c7087829ed --- /dev/null +++ b/cmuxTests/WindowRecordingPipelineTests.swift @@ -0,0 +1,473 @@ +import AVFoundation +import CmuxFoundation +import CoreGraphics +import Foundation +import ImageIO +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Covers the two halves of `cmux record` that do not need a window on screen: +/// composing a captured image into an encoded frame, and writing frames out as +/// an mp4 or a gif. +@Suite struct WindowRecordingPipelineTests { + // MARK: Fixtures + + private static func image( + width: Int, + height: Int, + gray: Double = 1 + ) -> CGImage { + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + )! + context.setFillColor(gray: gray, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) + return context.makeImage()! + } + + /// An image with a known color in one quadrant, so a crop can be checked by + /// reading a pixel rather than by trusting the rectangle arithmetic. + private static func quadrantImage(width: Int, height: Int) -> CGImage { + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + )! + context.setFillColor(gray: 0.2, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) + // Top-left quadrant in CGImage coordinates, which have y growing down. + context.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) + context.fill(CGRect( + x: 0, + y: CGFloat(height) / 2, + width: CGFloat(width) / 2, + height: CGFloat(height) / 2 + )) + return context.makeImage()! + } + + /// Tuples are not `Equatable`, and the caption test compares pixels. + private struct Pixel: Equatable { + let r: Int + let g: Int + let b: Int + } + + private static func pixel(_ image: CGImage, x: Int, y: Int) -> Pixel? { + var bytes = [UInt8](repeating: 0, count: 4) + guard let context = CGContext( + data: &bytes, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + ) else { + return nil + } + context.draw(image, in: CGRect(x: -x, y: -(image.height - 1 - y), width: image.width, height: image.height)) + // 32BGRA little endian: bytes are B, G, R, then the skipped alpha. + return Pixel(r: Int(bytes[2]), g: Int(bytes[1]), b: Int(bytes[0])) + } + + private static func geometry(params: [String: Any] = [:], width: Int, height: Int) throws + -> WindowRecordingFrameGeometry { + try WindowRecordingFrameGeometry.plan( + windowPixelWidth: width, + windowPixelHeight: height, + pointPixelScale: 1, + request: try WindowRecordingRequest.make(params: params) + ) + } + + private static func temporaryURL(extension pathExtension: String) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-recording-test-\(UUID().uuidString).\(pathExtension)" + ) + } + + // MARK: Composer + + @Test func composedFramesAreTheEncodedFrameSize() throws { + let geometry = try Self.geometry(params: ["scale": 0.5], width: 800, height: 600) + + let frame = try #require(WindowRecordingFrameComposer.compose( + source: Self.image(width: 800, height: 600), + geometry: geometry, + caption: nil + )) + + #expect(frame.width == geometry.outputWidth) + #expect(frame.height == geometry.outputHeight) + } + + @Test func aRegionCropKeepsOnlyTheRequestedPixels() throws { + let source = Self.quadrantImage(width: 400, height: 400) + // The red quadrant sits at the top-left of the image. + let geometry = try Self.geometry(params: ["region": "0,0,200,200"], width: 400, height: 400) + + let frame = try #require(WindowRecordingFrameComposer.compose( + source: source, + geometry: geometry, + caption: nil + )) + + #expect(frame.width == 200) + #expect(frame.height == 200) + let center = try #require(Self.pixel(frame, x: 100, y: 100)) + #expect(center.r > 200) + #expect(center.g < 60) + } + + @Test func aWindowResizedMidClipIsLetterboxedRatherThanStretched() throws { + // A clip that opened on a 400x400 window keeps that frame size; a + // later 400x200 capture has to be centered inside it with black bars. + let opening = try Self.geometry(width: 400, height: 400) + let resized = try Self.geometry(width: 400, height: 200) + + let frame = try #require(WindowRecordingFrameComposer.compose( + source: Self.image(width: 400, height: 200), + geometry: opening.adoptingCrop(of: resized), + caption: nil + )) + + #expect(frame.width == 400) + #expect(frame.height == 400) + let top = try #require(Self.pixel(frame, x: 200, y: 4)) + let middle = try #require(Self.pixel(frame, x: 200, y: 200)) + #expect(top.r == 0) + #expect(middle.r > 200) + } + + @Test func aCaptionDarkensTheBottomLeftAndLeavesTheRestAlone() throws { + let geometry = try Self.geometry(width: 600, height: 400) + let source = Self.image(width: 600, height: 400) + + let plain = try #require(WindowRecordingFrameComposer.compose( + source: source, + geometry: geometry, + caption: nil + )) + let captioned = try #require(WindowRecordingFrameComposer.compose( + source: source, + geometry: geometry, + caption: "opened the command palette" + )) + + // The caption box hugs the bottom-left corner, so its exact width + // depends on the system font; assert on the band it occupies. + let changedInBand = (2..<150).filter { step in + Self.pixel(plain, x: step * 4, y: 380) != Self.pixel(captioned, x: step * 4, y: 380) + } + #expect(!changedInBand.isEmpty) + let inBox = try #require(Self.pixel(captioned, x: 30, y: 380)) + #expect(inBox.r < 160) + let untouched = try #require(Self.pixel(captioned, x: 300, y: 20)) + #expect(untouched == Self.pixel(plain, x: 300, y: 20)) + } + + @Test func fittingNeverUpscalesPastTheFrame() { + let bounds = CGRect(x: 0, y: 0, width: 400, height: 400) + + let wide = WindowRecordingFrameComposer.fit(CGSize(width: 800, height: 200), in: bounds) + + #expect(wide.width == 400) + #expect(wide.height == 100) + #expect(wide.minY == 150) + + // A window that shrank mid-clip keeps its own pixel size, centered, + // rather than being blown up to fill the frame it opened with. + let small = WindowRecordingFrameComposer.fit(CGSize(width: 200, height: 100), in: bounds) + + #expect(small.width == 200) + #expect(small.height == 100) + #expect(small.minX == 100) + #expect(small.minY == 150) + } + + // MARK: mp4 + + @Test func anMP4CarriesTheFramesAndTheirElapsedTiming() async throws { + let url = Self.temporaryURL(extension: "mp4") + defer { try? FileManager.default.removeItem(at: url) } + let writer = try WindowRecordingMP4Writer(url: url, width: 160, height: 120, framesPerSecond: 10) + let frame = Self.image(width: 160, height: 120) + + // Deliberately uneven gaps: a sampler that fell behind must not make + // the clip play fast. + for offset in [0.0, 0.1, 0.45, 0.5] { + try await writer.append(frame, atOffsetSeconds: offset) + } + try await writer.finish() + + #expect(FileManager.default.fileExists(atPath: url.path)) + let asset = AVURLAsset(url: url) + // The clip's encoded length, not a measured time: the frames above + // are stamped up to 0.5 s, so a clip timed by capture spans past 0.4 s. + let clipSeconds = try await asset.load(.duration).seconds + #expect(clipSeconds > 0.4) + let track = try #require(try await asset.loadTracks(withMediaType: .video).first) + let size = try await track.load(.naturalSize) + #expect(Int(size.width) == 160) + #expect(Int(size.height) == 120) + } + + @Test func twoFramesInTheSameTickStillGetIncreasingTimes() async throws { + let url = Self.temporaryURL(extension: "mp4") + defer { try? FileManager.default.removeItem(at: url) } + let writer = try WindowRecordingMP4Writer(url: url, width: 64, height: 64, framesPerSecond: 12) + let frame = Self.image(width: 64, height: 64) + + try await writer.append(frame, atOffsetSeconds: 0) + try await writer.append(frame, atOffsetSeconds: 0) + try await writer.finish() + + // Both frames have to survive, and the second one has to present later + // than the first: an equal timestamp makes AVFoundation drop it. + let times = try await Self.presentationTimes(url: url) + #expect(times.count == 2) + #expect(times.first == 0) + if times.count == 2 { + #expect(times[1] > times[0]) + // One 1/600 second tick, the smallest step the clip can carry. + #expect(abs(times[1] - (1.0 / 600)) < 1e-6) + } + } + + @Test func anMP4WithNoFramesFailsInsteadOfLeavingAnEmptyFile() async throws { + let url = Self.temporaryURL(extension: "mp4") + defer { try? FileManager.default.removeItem(at: url) } + let writer = try WindowRecordingMP4Writer(url: url, width: 64, height: 64, framesPerSecond: 12) + + await #expect(throws: WindowRecordingWriterError.noFrames) { + try await writer.finish() + } + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + + /// Reads back what the file really carries, rather than trusting the asset's + /// rounded duration. + private static func presentationTimes(url: URL) async throws -> [Double] { + let asset = AVURLAsset(url: url) + guard let track = try await asset.loadTracks(withMediaType: .video).first else { + return [] + } + let reader = try AVAssetReader(asset: asset) + let output = AVAssetReaderTrackOutput(track: track, outputSettings: nil) + reader.add(output) + reader.startReading() + var times: [Double] = [] + while let sample = output.copyNextSampleBuffer() { + // The reader also hands back empty marker buffers; only buffers + // that carry a frame count. + guard CMSampleBufferGetNumSamples(sample) > 0 else { continue } + times.append(CMSampleBufferGetPresentationTimeStamp(sample).seconds) + } + return times + } + + // MARK: gif + + @Test func aGIFHoldsEveryFrameAndItsMeasuredDelay() async throws { + let url = Self.temporaryURL(extension: "gif") + defer { try? FileManager.default.removeItem(at: url) } + let writer = try WindowRecordingGIFWriter(url: url, framesPerSecond: 4) + + try await writer.append(Self.image(width: 80, height: 60, gray: 1), atOffsetSeconds: 0) + try await writer.append(Self.image(width: 80, height: 60, gray: 0.5), atOffsetSeconds: 0.5) + try await writer.append(Self.image(width: 80, height: 60, gray: 0), atOffsetSeconds: 0.75) + try await writer.finish() + + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + #expect(CGImageSourceGetCount(source) == 3) + #expect(Self.gifDelay(source, at: 0) == 0.5) + #expect(Self.gifDelay(source, at: 1) == 0.25) + // The last frame has no successor, so it takes the nominal delay. + #expect(Self.gifDelay(source, at: 2) == 0.25) + } + + @Test func aGIFDelayStaysInThePlayableRange() async throws { + let url = Self.temporaryURL(extension: "gif") + defer { try? FileManager.default.removeItem(at: url) } + let writer = try WindowRecordingGIFWriter(url: url, framesPerSecond: 8) + + try await writer.append(Self.image(width: 40, height: 40), atOffsetSeconds: 0) + // A 40 second gap would stall a viewer; a zero gap would be dropped. + try await writer.append(Self.image(width: 40, height: 40, gray: 0), atOffsetSeconds: 40) + try await writer.append(Self.image(width: 40, height: 40, gray: 0.5), atOffsetSeconds: 40) + try await writer.finish() + + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + #expect(Self.gifDelay(source, at: 0) == 10) + #expect(Self.gifDelay(source, at: 1) == 0.02) + } + + /// The normal flow: a clip with a 30 second budget that an agent stops after + /// three frames still has to produce a gif. + @Test func aGIFStoppedLongBeforeItsBudgetStillFinalizes() async throws { + let url = Self.temporaryURL(extension: "gif") + defer { try? FileManager.default.removeItem(at: url) } + let writer = try WindowRecordingGIFWriter(url: url, framesPerSecond: 8) + + for step in 0..<3 { + try await writer.append( + Self.image(width: 40, height: 40, gray: Double(step) / 3), + atOffsetSeconds: Double(step) * 0.125 + ) + } + try await writer.finish() + + #expect(FileManager.default.fileExists(atPath: url.path)) + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + #expect(CGImageSourceGetCount(source) == 3) + } + + @Test func aGIFWithNoFramesFails() async throws { + let url = Self.temporaryURL(extension: "gif") + defer { try? FileManager.default.removeItem(at: url) } + let writer = try WindowRecordingGIFWriter(url: url, framesPerSecond: 8) + + await #expect(throws: WindowRecordingWriterError.noFrames) { + try await writer.finish() + } + } + + private static func gifDelay(_ source: CGImageSource, at index: Int) -> Double? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) + as NSDictionary?, + let gif = properties[kCGImagePropertyGIFDictionary] as? NSDictionary else { + return nil + } + return (gif[kCGImagePropertyGIFUnclampedDelayTime] as? NSNumber)?.doubleValue + } +} + +/// Covers the bookkeeping `cmux record` needs without a window on screen: which +/// clip `stop`, `status` and `note` resolve to once a recording has ended. +@Suite struct WindowRecordingRegistryTests { + private static func finished(id: String) -> WindowRecordingStatus { + WindowRecordingStatus( + id: id, + state: .finished, + format: .mp4, + path: "/tmp/\(id).mp4", + label: "", + frames: 12, + seconds: 1, + width: 320, + height: 200, + notes: 0, + requestedFramesPerSecond: 12, + maximumSeconds: 15, + error: nil + ) + } + + /// A clip that reached its own `--max-seconds` limit is no longer active, so + /// the `cmux record stop` an agent runs afterwards has to report that clip + /// rather than claim nothing was recorded. + @Test func stoppingAfterTheClipStoppedItselfReportsThatClip() async throws { + let registry = WindowRecordingRegistry() + await registry.remember(Self.finished(id: "a")) + + let stopped = try await registry.stop(id: nil) + + #expect(stopped.id == "a") + #expect(stopped.state == .finished) + #expect(stopped.path == "/tmp/a.mp4") + } + + @Test func stoppingWithNothingEverRecordedSaysSo() async { + let registry = WindowRecordingRegistry() + + await #expect(throws: WindowRecordingRegistry.Failure.self) { + _ = try await registry.stop(id: nil) + } + } + + @Test func anUnknownIdIsNotAnsweredWithTheLatestClip() async { + let registry = WindowRecordingRegistry() + await registry.remember(Self.finished(id: "a")) + + await #expect(throws: WindowRecordingRegistry.Failure.self) { + _ = try await registry.stop(id: "b") + } + await #expect(throws: WindowRecordingRegistry.Failure.self) { + _ = try await registry.status(id: "b") + } + } + + @Test func aNoteForAStoppedClipIsRefused() async { + let registry = WindowRecordingRegistry() + await registry.remember(Self.finished(id: "a")) + + await #expect(throws: WindowRecordingRegistry.Failure.self) { + _ = try await registry.note(text: "too late", id: "a") + } + } + + @Test func historyKeepsTheMostRecentClipsOnly() async { + let registry = WindowRecordingRegistry() + for index in 0..<12 { + await registry.remember(Self.finished(id: "clip-\(index)")) + } + + let recent = await registry.recentStatuses() + + #expect(recent.count == 8) + #expect(recent.first?.id == "clip-4") + #expect(recent.last?.id == "clip-11") + } + + /// A timed-out `record start` whose call already ended must not reach into + /// whatever is recorded now. + @Test func abandoningAStartThatIsNoLongerRunningChangesNothing() async throws { + let registry = WindowRecordingRegistry() + await registry.remember(Self.finished(id: "a")) + + await registry.abandonStart(token: UUID()) + + let status = try await registry.status(id: nil) + #expect(status.id == "a") + #expect(status.state == .finished) + } + + /// The session a timed-out start opened ends as failed, with a reason the + /// caller can read from `record status`, and a later stop keeps that verdict. + @Test func anAbandonedSessionStopsRecordingAndStaysFailed() async throws { + let session = WindowRecordingSession( + id: "abandoned", + request: try WindowRecordingRequest.make(params: [:]), + outputURL: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-abandoned-\(UUID().uuidString).mp4"), + windowID: 0, + windowHandle: nil + ) + + await session.abandon(reason: "timed out") + + #expect(await session.isRecording == false) + let stopped = await session.stop() + #expect(stopped.state == .failed) + #expect(stopped.error == "timed out") + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 3886f1dd50a1..8814060ad707 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -75,7 +75,7 @@ Environment: | `restore` | Replace the CLI with a process restored from structured surface state. | | `fork` | Replace the CLI with a provider fork process restored from structured surface state. | | `restore-session` | Restore the previously saved cmux session; `--from ` reopens another install's saved session (full trust) or an exported file (untrusted: no automatic resume of custom commands, SSH, or environment from the file) as additional windows, and `--export [--force]` writes this install's saved session to a file. Both require cmux to be running. | -| `open` | Open files, directories, or URLs in cmux. Files and web pages take focus only when run interactively; run by an agent or script they open in the background. `--focus ` and `--no-focus` override. | +| `open` | Open files, directories, or URLs in cmux. | | `feedback` | Open feedback UI or submit feedback with `--email`, `--body`, and repeated `--image`. | | `feed` | Open the keyboard-first Feed TUI or manage persisted Feed workstream history. | | `themes` | List, set, clear, or interactively pick Ghostty themes. | @@ -122,7 +122,7 @@ Environment: | `move-tab-to-new-workspace` | Move a tab or surface into a newly created workspace. | | `list-workspaces` | List workspaces. | | `new-workspace` | Create a workspace, optionally with cwd, command, description, layout, and per-workspace environment variables (`--env KEY=VALUE` repeatable, `--env-file `). See [Workspace environment variables](#workspace-environment-variables). `--command ` runs in the initial interactive shell; see [Initial terminal command](#initial-terminal-command). | -| `ssh` | Open an SSH-backed workspace. Switches to it only when run interactively (`--focus [true\|false]`, `--focus=` and `--no-focus` override; `ssh-tmux` takes the same flags). Preserves the caller's live `SSH_AUTH_SOCK` for app-launched OpenSSH processes so `ForwardAgent yes` from ssh_config works normally. Supports `-A` / `--forward-agent` to request forwarding and `-a` / `--no-forward-agent` to disable forwarding for a workspace. Agent forwarding remains opt-in because forwarded agents can be used by processes on the remote host while the SSH session is active. | +| `ssh` | Open an SSH-backed workspace. Preserves the caller's live `SSH_AUTH_SOCK` for app-launched OpenSSH processes so `ForwardAgent yes` from ssh_config works normally. Supports `-A` / `--forward-agent` to request forwarding and `-a` / `--no-forward-agent` to disable forwarding for a workspace. Agent forwarding remains opt-in because forwarded agents can be used by processes on the remote host while the SSH session is active. | | `local-tmux` | Opt in to a user-owned local tmux server. `start`, `attach`, `list`, `status`, `detach`, `close`, and `cleanup` preserve and manage named sessions independently of the cmux GUI; `cleanup` previews stale records unless `--prune` is supplied. `list`, `status`, `detach`, `close`, `cleanup`, and `attach --headless` work without a running cmux control socket. This preserves live processes across cmux lifecycle events, not a machine shutdown or reboot; use a remote tmux owner for continuity while the Mac is offline. See [`docs/local-tmux.md`](local-tmux.md). | | `tmux attach` | Compatibility alias for `local-tmux attach`. | | `remote-daemon-status` | Print bundled remote daemon version, asset, checksum, and cache status. | @@ -157,6 +157,7 @@ Environment: | `current-workspace` | Print current workspace information. | | `read-selection` | Read the active selection from a terminal, file preview, Markdown, or browser surface. Plain output includes available source context; `--json` returns the complete socket response. | | `read-screen` | Read terminal text from a surface. `--selection` is a text-only compatibility alias for `read-selection`. | +| `record` | Record a cmux window or a region of one to an mp4 or gif (`window.record.*`). `start` returns a recording id and the output path, `stop` closes the clip, `status` reports progress, `note` adds a caption drawn into later frames, `list` shows the current and recent recordings. One recording at a time; a recording stops itself at `--max-seconds`. The clip appears at its path when the recording ends, so an existing file there is replaced only once there is a finished clip to replace it with, and a recording that never closes leaves the path alone. Frames go to a hidden working file beside the output path, so quitting cmux mid-clip loses the clip and leaves that file behind. Local socket only: `window.record.*` is not on the `cmux ssh` relay allowlist. | | `send` | Send text to a terminal surface as keystrokes (`surface.send_text`). `--paste`, before the text, sends it unchanged through the Cmd+V paste path (`terminal.paste`) instead, like `cmux paste`. Without `--paste`, large multi-line text prints a hint on stderr recommending it. | | `send-key` | Send one key to a terminal surface. | | `paste` | Paste text from an argument or stdin into a terminal surface through the Cmd+V paste path (`terminal.paste`). The CLI sends the text unchanged; Ghostty brackets it when the program enabled bracketed paste (otherwise newlines become Enter) and replaces unsafe control bytes with spaces. `--submit` presses the agent-aware submit key afterwards. Local socket only: `terminal.paste` is not on the `cmux ssh` relay allowlist. | @@ -341,7 +342,6 @@ VM subcommands: | Command | Contract | | --- | --- | | `vm ls`, `vm list` | List VMs. | -| Focus on open | Verbs that open a workspace or pane (`vm new`, `vm base open\|reset`, `vm fork`, `vm restore`, `vm shell`/`attach`, `vm ssh`, `vm tui`, `vm open`, `vm workspace new\|open`, `vm agent`, `vm dev`, `vm layout apply --open`, `surface open`, `surface new-terminal`) take `--focus [true\|false]`, `--focus=` and `--no-focus`, and always send an explicit `focus`. Without a flag they focus only when run interactively (a terminal on stdin and stdout, no coding-agent environment) and stay in the background when run by an agent or script; `CMUX_FOCUS_NEW=1\|0` overrides the detection. The `vm.*`/`surface.*` socket methods default `focus` to false, and a pane opened without focus is marked unread. | | `cloud domains [list]`, `vm domains [list]` | List the account's VM-port publications (`vm.publication_list`). `--json` returns `{publications: [...]}`. | | `cloud domains zones`, `vm domains zones` | List custom domains owned by the account (`vm.domain_list`); `--json` returns `{domains: [...]}`. | | `cloud domains verify `, `vm domains verify ` | Start or complete ownership/certificate verification for a custom zone (`vm.domain_verify`). The first call prints the DNS checklist; add the records and rerun it. A publication hostname resolves to its zone; generated cmux names need no verification. | @@ -349,8 +349,8 @@ VM subcommands: | `cloud domains access [--team ]`, `vm domains access …` | Change an existing publication's viewer policy (`vm.publication_update`); the first argument is a publication hostname or id, not a VM id. | | `cloud domains rm `, `vm domains rm …` | Remove a publication (`vm.publication_delete`). | | `vm tree [\|local] [--refresh] [--json]` | The surface catalog (`surface.catalog`), rendered Finder-style: **This Mac** first (its terminals grouped by the local workspace showing them, then its browsers), then every cloud machine — Workspaces, Ports, VNC Displays (one row per screen), and a final Terminals section containing every machine-owned terminal. Workspace folders include their terminal, browser, and display layout; a canonical `browser/port:` resource is also listed in the machine's Ports folder. Every line carries an address `vm open` or `surface open` accepts. `--refresh` re-syncs every provider first. `--json` prints the catalog payload with `cloud_states` (`sync_mode`: `journaled` or `snapshot_only`, cursor `(generation, revision)`, freshness, and pending writes) and resources with exact `remote_views: [{tab_id, workspace: {id, name, index, focused}, screen_id?, pane_id?, name?, index?, focused?, screen_index?, pane_index?}]`. A snapshot-only VM remains readable but rejects revision-fenced rename writes until its daemon is upgraded. Same as `surface ls`. | -| `vm workspace new [--name ] [--focus\|--no-focus] [--json]` | `vm.workspace_new`: creates a cmux-tui workspace on the machine (its ⌘N, with a first terminal) and opens it as a new local workspace. Prints `OK workspace= remote_workspace= machine=`. | -| `vm workspace open [--here] [--tabs] [--workspace ] [--pane [--left\|--right\|--up\|--down]] [--focus\|--no-focus] [--json]` | `vm.workspace_open`: the machine workspace's terminals, browsers and pinned displays as a new local workspace, one pane each (what clicking the sidebar row does). `` is the `ws_…` id or an unambiguous workspace name, resolved exactly like the sidebar row (every view of every terminal counts); the payload's `remote_workspace_id` is the resolved id. An existing workspace with nothing in it opens nothing and answers `Nothing to open: … cmux vm open / starts a terminal there`. `--here`/`--tabs`/`--pane`+side instead project the group into an existing local workspace (`here: true` + the `surface open` destination params; one pane at the destination, the rest as tabs) — the sidebar's "Open All Here" / "Open All in New Tabs" / drop on a pane edge. | +| `vm workspace new [--name ] [--json]` | `vm.workspace_new`: creates a cmux-tui workspace on the machine (its ⌘N, with a first terminal) and opens it as a new local workspace. Prints `OK workspace= remote_workspace= machine=`. | +| `vm workspace open [--here] [--tabs] [--workspace ] [--pane [--left\|--right\|--up\|--down]] [--json]` | `vm.workspace_open`: the machine workspace's terminals, browsers and pinned displays as a new local workspace, one pane each (what clicking the sidebar row does). `` is the `ws_…` id or an unambiguous workspace name, resolved exactly like the sidebar row (every view of every terminal counts); the payload's `remote_workspace_id` is the resolved id. An existing workspace with nothing in it opens nothing and answers `Nothing to open: … cmux vm open / starts a terminal there`. `--here`/`--tabs`/`--pane`+side instead project the group into an existing local workspace (`here: true` + the `surface open` destination params; one pane at the destination, the rest as tabs) — the sidebar's "Open All Here" / "Open All in New Tabs" / drop on a pane edge. | | `vm prompt [--json]` / `vm prompt --open ` (alias `skill`) | `vm.cloud_prompt` / `vm.cloud_agent_open`: installs the bundled cmux-cloud skill file at `~/.config/cmux/skills/cmux-cloud.md` and prints the kickoff prompt for any agent (the Machines panel's "Copy Cloud Prompt"), or opens a local terminal running claude\|codex\|opencode\|pi with it ("Open Cloud Agent"). | | `vm workspace rename [--json]` | `vm.workspace_rename`: renames the cmux-tui workspace (the sidebar row's "Rename…") through the catalog's machine-scoped rename lane. | | `vm tab rename [--json]` | `vm.tab_rename`: renames one exact cmux-tui tab placement through the catalog's machine-scoped rename lane. Pass `""` as `` to clear that tab's custom label and restore its generated title. Use the tab id from `vm tree --json`; the daemon revision fence prevents overwriting a concurrent rename. | @@ -363,7 +363,7 @@ VM subcommands: | `vm terminal rename [--json]` | `vm.terminal_rename`: names the terminal's daemon tab view(s) (the sidebar's Rename…) through the same machine-scoped rename lane; every client shows the name in place of the PTY title, and the daemon persists it. Pass `""` to clear the custom label on every placement. A zero-view pool terminal has no tab to name. | | `vm new`, `vm create` | Create a VM: the devbox with devtools, the coding agents and a screen (TigerVNC + openbox + noVNC on 6901). The CLI always sends the machine **kind** `desktop` and the backend maps kind and size to the image its deployment supports (one snapshot ladder serves both kinds, so a request that names `base` gets the same machine); `--desktop` / `--base` / `--no-desktop` are accepted for older scripts and change nothing. `--image ` is the explicit override and is the only way an image id leaves the client. Supports `--size <4g\|8g\|16g\|24g\|32g\|64g>`, `--name