diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e57a4582..632e2c2269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to cmux are documented here. +## [0.64.0] - 2026-04-04 + +### Added +- Local daemon for tmux-like session detach/reattach — terminal sessions persist after closing the app and auto-restore on relaunch +- Sidebar UI showing detached daemon sessions with one-click reattach +- Daemon auto-start via launchd (sessions survive reboots) +- Session disk persistence (daemon remembers sessions across daemon restarts) +- Editable workspace descriptions (#2475) +- Claude Binary Path setting — configure a custom path for the Claude CLI (#2514) +- Keyboard shortcuts cheatsheet page +- Localized tab context menu and alert strings (#1998) + +### Changed +- Pane focus shortcuts changed to Cmd+Shift+H/J/K/L (was Cmd+Option+Arrow) +- Open Browser shortcut moved to Cmd+Ctrl+Option+L to avoid conflict + +### Fixed +- Suppress fallback text during IME composition (Korean, Japanese, Chinese input) +- Fix split divider drags escaping to adjacent panes +- Fix browser portal sync flickering during split drag +- Fix external insertText escape handling +- Fix paste from Raycast and other apps using alternate plain-text UTIs +- Fix terminal clipboard rich text and image fallbacks +- Fix macOS Tahoe glass window compatibility (#2459) +- Fix CLI stealing app focus when running commands +- Preserve symlink aliases for external file opens +- Fix terminal Cmd scroll bug + +### Removed +- Remove copy-on-select setting + ## [0.63.1] - 2026-03-28 ### Fixed diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4a2a58908e..c512407d19 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -529,9 +529,9 @@ enum CLIIDFormat: String { } enum SocketPasswordResolver { - private static let service = "com.cmuxterm.app.socket-control" + private static let service = "com.jmux.app.socket-control" private static let account = "local-socket-password" - private static let directoryName = "cmux" + private static let directoryName = "jmux" private static let fileName = "socket-control-password" static func resolve(explicit: String?, socketPath: String) -> String? { @@ -591,7 +591,7 @@ enum SocketPasswordResolver { } let candidate = URL(fileURLWithPath: socketPath).lastPathComponent - let prefixes = ["cmux-debug-", "cmux-"] + let prefixes = ["jmux-debug-", "cmux-"] for prefix in prefixes { guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) @@ -660,12 +660,12 @@ private enum CLISocketPathSource { } private enum CLISocketPathResolver { - private static let appSupportDirectoryName = "cmux" - private static let stableSocketFileName = "cmux.sock" + private static let appSupportDirectoryName = "jmux" + private static let stableSocketFileName = "jmux.sock" private static let lastSocketPathFileName = "last-socket-path" - static let legacyDefaultSocketPath = "/tmp/cmux.sock" - private static let fallbackSocketPath = "/tmp/cmux-debug.sock" - private static let stagingSocketPath = "/tmp/cmux-staging.sock" + static let legacyDefaultSocketPath = "/tmp/jmux.sock" + private static let fallbackSocketPath = "/tmp/jmux-debug.sock" + private static let stagingSocketPath = "/tmp/jmux-staging.sock" private static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path" static var defaultSocketPath: String { @@ -708,7 +708,7 @@ private enum CLISocketPathResolver { if let tag = normalized(environment["CMUX_TAG"]) { let slug = sanitizeTagSlug(tag) - candidates.append("/tmp/cmux-debug-\(slug).sock") + candidates.append("/tmp/jmux-debug-\(slug).sock") candidates.append("/tmp/cmux-\(slug).sock") } @@ -1273,7 +1273,7 @@ struct CMUXCLI { return nil } guard let hinted = normalizedEnvValue(raw), - hinted.hasPrefix("/tmp/cmux-debug"), + hinted.hasPrefix("/tmp/jmux-debug"), hinted.hasSuffix(".sock"), pathIsSocket(hinted) else { return nil @@ -1292,9 +1292,9 @@ struct CMUXCLI { if let hinted = debugSocketPathFromHintFile() { return hinted } - return "/tmp/cmux-debug.sock" + return "/tmp/jmux-debug.sock" #else - return "/tmp/cmux.sock" + return "/tmp/jmux.sock" #endif } @@ -7330,10 +7330,10 @@ struct CMUXCLI { return true } - private static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app" + private static let cmuxThemeOverrideBundleIdentifier = "com.jmux.app" private static let cmuxThemesBlockStart = "# cmux themes start" private static let cmuxThemesBlockEnd = "# cmux themes end" - private static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config" + private static let cmuxThemesReloadNotificationName = "com.jmux.themes.reload-config" private struct ThemeSelection { let rawValue: String? diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1325f3b418..28282a51bf 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -74,6 +74,8 @@ A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; A5001652 /* CmuxConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* CmuxConfigExecutor.swift */; }; A5001654 /* CmuxDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxDirectoryTrust.swift */; }; + A5001661 /* DaemonSessionBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001660 /* DaemonSessionBinding.swift */; }; + A5001663 /* LocalDaemonManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001662 /* LocalDaemonManager.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; @@ -103,6 +105,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + A4D36FFC5D3B5FD5B9B03C37 /* DaemonSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F748BC866745969FCF8BA6A3 /* DaemonSessionTests.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; @@ -269,6 +272,8 @@ A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = ""; }; A5001653 /* CmuxConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigExecutor.swift; sourceTree = ""; }; A5001655 /* CmuxDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxDirectoryTrust.swift; sourceTree = ""; }; + A5001660 /* DaemonSessionBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaemonSessionBinding.swift; sourceTree = ""; }; + A5001662 /* LocalDaemonManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalDaemonManager.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; @@ -299,6 +304,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + F748BC866745969FCF8BA6A3 /* DaemonSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaemonSessionTests.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = ""; }; @@ -513,6 +519,8 @@ A5001651 /* CmuxConfig.swift */, A5001653 /* CmuxConfigExecutor.swift */, A5001655 /* CmuxDirectoryTrust.swift */, + A5001660 /* DaemonSessionBinding.swift */, + A5001662 /* LocalDaemonManager.swift */, ); path = Sources; sourceTree = ""; @@ -580,6 +588,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + F748BC866745969FCF8BA6A3 /* DaemonSessionTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, @@ -836,6 +845,8 @@ A5001650 /* CmuxConfig.swift in Sources */, A5001652 /* CmuxConfigExecutor.swift in Sources */, A5001654 /* CmuxDirectoryTrust.swift in Sources */, + A5001661 /* DaemonSessionBinding.swift in Sources */, + A5001663 /* LocalDaemonManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -878,6 +889,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + A4D36FFC5D3B5FD5B9B03C37 /* DaemonSessionTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */, @@ -1011,7 +1023,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -1020,7 +1032,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.63.1; + MARKETING_VERSION = 0.64.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -1034,8 +1046,8 @@ "-framework", Carbon, ); - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.debug; - PRODUCT_NAME = "cmux DEV"; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.app.debug; + PRODUCT_NAME = "jmux DEV"; SPARKLE_PUBLIC_KEY = "avjcgKibf1FTvhIjLBxhd+0HSpsXU4D0IGlVk8cgqRc="; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "cmux-Bridging-Header.h"; @@ -1050,7 +1062,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -1059,7 +1071,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.63.1; + MARKETING_VERSION = 0.64.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -1074,8 +1086,8 @@ Carbon, ); ONLY_ACTIVE_ARCH = NO; - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app; - PRODUCT_NAME = cmux; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.app; + PRODUCT_NAME = jmux; SPARKLE_PUBLIC_KEY = "avjcgKibf1FTvhIjLBxhd+0HSpsXU4D0IGlVk8cgqRc="; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "cmux-Bridging-Header.h"; @@ -1088,17 +1100,17 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "cmux Dock Tile Plugin"; + INFOPLIST_KEY_CFBundleDisplayName = "jmux Dock Tile Plugin"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.63.1; - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin.debug; + MARKETING_VERSION = 0.64.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.app.docktileplugin.debug; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1113,17 +1125,17 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "cmux Dock Tile Plugin"; + INFOPLIST_KEY_CFBundleDisplayName = "jmux Dock Tile Plugin"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.63.1; - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin; + MARKETING_VERSION = 0.64.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.app.docktileplugin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1143,7 +1155,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_NAME = cmux; + PRODUCT_NAME = jmux; PRODUCT_MODULE_NAME = cmux_cli; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1162,7 +1174,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_NAME = cmux; + PRODUCT_NAME = jmux; PRODUCT_MODULE_NAME = cmux_cli; ONLY_ACTIVE_ARCH = NO; SWIFT_COMPILATION_MODE = wholemodule; @@ -1175,12 +1187,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.63.1; + MARKETING_VERSION = 0.64.0; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_VERSION = 5.0; @@ -1192,12 +1204,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.63.1; + MARKETING_VERSION = 0.64.0; ONLY_ACTIVE_ARCH = NO; - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = GhosttyTabs; @@ -1209,16 +1221,16 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.63.1; + MARKETING_VERSION = 0.64.0; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/cmux DEV.app/Contents/MacOS/cmux DEV"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/jmux DEV.app/Contents/MacOS/jmux DEV"; TEST_TARGET_NAME = GhosttyTabs; }; name = Debug; @@ -1228,15 +1240,15 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 78; + CURRENT_PROJECT_VERSION = 79; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.63.1; + MARKETING_VERSION = 0.64.0; ONLY_ACTIVE_ARCH = NO; - PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; + PRODUCT_BUNDLE_IDENTIFIER = com.jmux.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/cmux.app/Contents/MacOS/cmux"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/jmux.app/Contents/MacOS/jmux"; TEST_TARGET_NAME = GhosttyTabs; }; name = Release; diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux-ci.xcscheme similarity index 80% rename from GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme rename to GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux-ci.xcscheme index 415b3867a5..eeb90c1aa4 100644 --- a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux-ci.xcscheme @@ -3,34 +3,34 @@ - + - + - + - + - + - + - + diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux-unit.xcscheme similarity index 82% rename from GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme rename to GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux-unit.xcscheme index 965b79c1e3..6f97122438 100644 --- a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux-unit.xcscheme @@ -3,31 +3,31 @@ - + - + - + - + - + - + diff --git a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux.xcscheme similarity index 85% rename from GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme rename to GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux.xcscheme index c8f698bcb8..06f484de6b 100644 --- a/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme +++ b/GhosttyTabs.xcodeproj/xcshareddata/xcschemes/jmux.xcscheme @@ -3,28 +3,28 @@ - + - + - + - + - + diff --git a/Resources/Info.plist b/Resources/Info.plist index f41ba03688..151649749a 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -44,9 +44,9 @@ NSMainStoryboardFile NSMicrophoneUsageDescription - A program running within cmux would like to use your microphone. + A program running within jmux would like to use your microphone. NSCameraUsageDescription - A program running within cmux would like to use your camera. + A program running within jmux would like to use your camera. CFBundleURLTypes @@ -124,9 +124,9 @@ UTTypeIdentifier - com.cmux.sidebar-tab-reorder + com.jmux.sidebar-tab-reorder UTTypeDescription - cmux Sidebar Tab Reorder + jmux Sidebar Tab Reorder UTTypeConformsTo public.data @@ -139,7 +139,7 @@ NSExceptionDomains - cmux-loopback.localtest.me + jmux-loopback.localtest.me NSExceptionAllowsInsecureHTTPLoads @@ -151,9 +151,9 @@ SUAutomaticallyUpdate SUEnableAutomaticChecks - + SUFeedURL - https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml + SUScheduledCheckInterval 86400 SUSendProfileInfo diff --git a/Resources/InfoPlist.xcstrings b/Resources/InfoPlist.xcstrings index baeee7089d..5913ab287e 100644 --- a/Resources/InfoPlist.xcstrings +++ b/Resources/InfoPlist.xcstrings @@ -8,13 +8,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "A program running within cmux would like to use your camera." + "value": "A program running within jmux would like to use your camera." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux 内で実行中のプログラムがカメラの使用を求めています。" + "value": "jmux 内で実行中のプログラムがカメラの使用を求めています。" } } } @@ -25,109 +25,109 @@ "en": { "stringUnit": { "state": "translated", - "value": "A program running within cmux would like to use your microphone." + "value": "A program running within jmux would like to use your microphone." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux 内で実行中のプログラムがマイクの使用を求めています。" + "value": "jmux 内で実行中のプログラムがマイクの使用を求めています。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "在 cmux 中运行的程序想要使用您的麦克风。" + "value": "在 jmux 中运行的程序想要使用您的麦克风。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "在 cmux 中執行的程式想要使用您的麥克風。" + "value": "在 jmux 中執行的程式想要使用您的麥克風。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "cmux 내에서 실행 중인 프로그램이 마이크를 사용하려고 합니다." + "value": "jmux 내에서 실행 중인 프로그램이 마이크를 사용하려고 합니다." } }, "de": { "stringUnit": { "state": "translated", - "value": "Ein in cmux ausgeführtes Programm möchte Ihr Mikrofon verwenden." + "value": "Ein in jmux ausgeführtes Programm möchte Ihr Mikrofon verwenden." } }, "es": { "stringUnit": { "state": "translated", - "value": "Un programa en ejecución dentro de cmux desea usar tu micrófono." + "value": "Un programa en ejecución dentro de jmux desea usar tu micrófono." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Un programme s'exécutant dans cmux souhaite utiliser votre microphone." + "value": "Un programme s'exécutant dans jmux souhaite utiliser votre microphone." } }, "it": { "stringUnit": { "state": "translated", - "value": "Un programma in esecuzione in cmux desidera utilizzare il microfono." + "value": "Un programma in esecuzione in jmux desidera utilizzare il microfono." } }, "da": { "stringUnit": { "state": "translated", - "value": "Et program, der kører i cmux, vil gerne bruge din mikrofon." + "value": "Et program, der kører i jmux, vil gerne bruge din mikrofon." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Program działający w cmux chciałby użyć Twojego mikrofonu." + "value": "Program działający w jmux chciałby użyć Twojego mikrofonu." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Программа, запущенная в cmux, хотела бы использовать ваш микрофон." + "value": "Программа, запущенная в jmux, хотела бы использовать ваш микрофон." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Program koji se izvršava unutar cmux želi koristiti vaš mikrofon." + "value": "Program koji se izvršava unutar jmux želi koristiti vaš mikrofon." } }, "ar": { "stringUnit": { "state": "translated", - "value": "يرغب برنامج يعمل داخل cmux في استخدام الميكروفون." + "value": "يرغب برنامج يعمل داخل jmux في استخدام الميكروفون." } }, "nb": { "stringUnit": { "state": "translated", - "value": "Et program som kjører i cmux ønsker å bruke mikrofonen din." + "value": "Et program som kjører i jmux ønsker å bruke mikrofonen din." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Um programa em execução no cmux gostaria de usar seu microfone." + "value": "Um programa em execução no jmux gostaria de usar seu microfone." } }, "th": { "stringUnit": { "state": "translated", - "value": "โปรแกรมที่ทำงานภายใน cmux ต้องการใช้ไมโครโฟนของคุณ" + "value": "โปรแกรมที่ทำงานภายใน jmux ต้องการใช้ไมโครโฟนของคุณ" } }, "tr": { "stringUnit": { "state": "translated", - "value": "cmux içinde çalışan bir program mikrofonunuzu kullanmak istiyor." + "value": "jmux içinde çalışan bir program mikrofonunuzu kullanmak istiyor." } } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 921cdba207..74310fe35b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1218,6 +1218,98 @@ } } }, + "sidebar.detachedSessions.header": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detached Sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切断済みセッション" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Від'єднані сеанси" + } + } + } + }, + "sidebar.detachedSessions.panes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d panes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%dペイン" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%d панелей" + } + } + } + }, + "sidebar.detachedSessions.reattach.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reattach session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを再接続" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Під'єднати сеанс" + } + } + } + }, + "sidebar.detachedSessions.row.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detached session: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切断済みセッション: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Від'єднаний сеанс: %@" + } + } + } + }, "sidebar.help.button": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0e01c367dd..bec506d6a4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -34,7 +34,7 @@ final class MainWindowHostingView: NSHostingView { } private enum CmuxThemeNotifications { - static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") + static let reloadConfig = Notification.Name("com.jmux.themes.reload-config") } func isCommandPaletteFocusStealingTerminalOrBrowserResponder(_ responder: NSResponder) -> Bool { @@ -2349,11 +2349,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false private let sessionPersistenceQueue = DispatchQueue( - label: "com.cmuxterm.app.sessionPersistence", + label: "com.jmux.app.sessionPersistence", qos: .utility ) private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( - label: "com.cmuxterm.app.launchServicesRegistration", + label: "com.jmux.app.launchServicesRegistration", qos: .utility ) private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { @@ -2554,6 +2554,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installShortcutMonitor() installShortcutDefaultsObserver() NSApp.servicesProvider = self + + // Start local daemon for session persistence (non-blocking). + if !isRunningUnderXCTest { + Task { @MainActor in + await LocalDaemonManager.shared.ensureRunning() + + // After daemon is running and sessions are restored, reattach + // any daemon sessions that were persisted across the app restart. + // Small delay to ensure session restore has completed. + try? await Task.sleep(nanoseconds: 1_000_000_000) + + guard LocalDaemonManager.shared.isRunning else { return } + for context in self.mainWindowContexts.values { + for workspace in context.tabManager.tabs { + await workspace.reattachDaemonSessionIfNeeded() + } + } + + // Start periodic refresh of detached session tracking. + LocalDaemonManager.shared.startPeriodicRefresh() + } + } + #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) if env["CMUX_UI_TEST_MODE"] == "1" { @@ -10124,7 +10147,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). + // Pane focus navigation (defaults to Cmd+Shift+HJKL, but can be customized to letter/number keys). if matchDirectionalShortcut( event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .focusLeft), @@ -12084,7 +12107,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @MainActor final class MenuBarExtraController: NSObject, NSMenuDelegate { private let statusItem: NSStatusItem - private let menu = NSMenu(title: "cmux") + private let menu = NSMenu(title: "jmux") private let notificationStore: TerminalNotificationStore private let onShowNotifications: () -> Void private let onOpenNotification: (TerminalNotification) -> Void @@ -12136,7 +12159,7 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { button.imagePosition = .imageOnly button.imageScaling = .scaleProportionallyDown button.image = MenuBarIconRenderer.makeImage(unreadCount: 0) - button.toolTip = "cmux" + button.toolTip = "jmux" } notificationsCancellable = notificationStore.$notifications @@ -12240,7 +12263,7 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { if let button = statusItem.button { button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) button.toolTip = displayedUnreadCount == 0 - ? "cmux" + ? "jmux" : displayedUnreadCount == 1 ? "cmux: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") : "cmux: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") diff --git a/Sources/AppIconDockTilePlugin.swift b/Sources/AppIconDockTilePlugin.swift index 47f68526a5..f0e7026ff6 100644 --- a/Sources/AppIconDockTilePlugin.swift +++ b/Sources/AppIconDockTilePlugin.swift @@ -1,6 +1,6 @@ import AppKit -private let cmuxAppIconDidChangeNotification = Notification.Name("com.cmuxterm.appIconDidChange") +private let cmuxAppIconDidChangeNotification = Notification.Name("com.jmux.appIconDidChange") private let cmuxAppIconModeKey = "appIconMode" private enum DockTileAppIconMode: String { diff --git a/Sources/CmuxDirectoryTrust.swift b/Sources/CmuxDirectoryTrust.swift index 1c906fbada..3d85008768 100644 --- a/Sources/CmuxDirectoryTrust.swift +++ b/Sources/CmuxDirectoryTrust.swift @@ -13,7 +13,7 @@ final class CmuxDirectoryTrust { private init() { let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appendingPathComponent("cmux") + ).first!.appendingPathComponent("jmux") storePath = appSupport.appendingPathComponent("trusted-directories.json").path let fm = FileManager.default diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 956b802db6..c53643171f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9872,6 +9872,7 @@ struct VerticalTabsSidebar: View { let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore + @ObservedObject var daemonManager: LocalDaemonManager = .shared @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -9998,6 +9999,13 @@ struct VerticalTabsSidebar: View { } .padding(.vertical, 8) + if !daemonManager.detachedSessions.isEmpty { + SidebarDetachedSessionsSection( + sessions: daemonManager.detachedSessions, + tabManager: tabManager + ) + } + SidebarEmptyArea( rowSpacing: tabRowSpacing, selection: $selection, @@ -10115,12 +10123,7 @@ enum ShortcutHintModifierPolicy { for modifierFlags: NSEvent.ModifierFlags, defaults: UserDefaults = .standard ) -> Bool { - let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard normalized == KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber).modifierFlags else { - return false - } - return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) + return false } static func isCurrentWindow( @@ -12270,6 +12273,171 @@ private struct SidebarEmptyArea: View { } } +// MARK: - Detached Daemon Sessions + +/// Lightweight value wrapper for a detached daemon session dictionary, providing +/// `Identifiable` conformance for SwiftUI `ForEach`. +private struct DetachedSessionItem: Identifiable { + let id: String + let raw: [String: Any] + + init(_ dict: [String: Any]) { + self.id = dict["session_id"] as? String ?? UUID().uuidString + self.raw = dict + } +} + +private struct SidebarDetachedSessionsSection: View { + let sessions: [[String: Any]] + let tabManager: TabManager + + private var items: [DetachedSessionItem] { + sessions.map { DetachedSessionItem($0) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Section header + HStack(spacing: 4) { + Image(systemName: "terminal") + .font(.system(size: 9, weight: .semibold)) + Text(String(localized: "sidebar.detachedSessions.header", defaultValue: "Detached Sessions")) + .font(.system(size: 10, weight: .semibold)) + .textCase(.uppercase) + Text("\(sessions.count)") + .font(.system(size: 9, weight: .semibold)) + .monospacedDigit() + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.2)) + .clipShape(Capsule()) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + + ForEach(items) { item in + SidebarDetachedSessionRow(session: item.raw, tabManager: tabManager) + } + } + .padding(.bottom, 4) + } +} + +private struct SidebarDetachedSessionRow: View { + let session: [String: Any] + let tabManager: TabManager + @State private var isHovered = false + @State private var isReattaching = false + + private var sessionID: String { + session["session_id"] as? String ?? "" + } + + private var sessionName: String { + session["name"] as? String ?? sessionID + } + + private var shellName: String { + guard let shell = session["shell"] as? String else { return "" } + return (shell as NSString).lastPathComponent + } + + private var paneCount: Int { + session["pane_count"] as? Int ?? 1 + } + + private var createdAtText: String { + guard let raw = session["created_at"] as? String else { return "" } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: raw) else { return "" } + let relative = RelativeDateTimeFormatter() + relative.unitsStyle = .abbreviated + return relative.localizedString(for: date, relativeTo: Date()) + } + + var body: some View { + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 1) { + Text(sessionName) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: 4) { + if !shellName.isEmpty { + Text(shellName) + } + if paneCount > 1 { + Text(String.localizedStringWithFormat( + String(localized: "sidebar.detachedSessions.panes", defaultValue: "%d panes"), + paneCount + )) + } + if !createdAtText.isEmpty { + Text(createdAtText) + } + } + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + reattachSession() + } label: { + if isReattaching { + ProgressView() + .controlSize(.small) + .frame(width: 14, height: 14) + } else { + Image(systemName: "arrow.uturn.backward.circle") + .font(.system(size: 14)) + } + } + .buttonStyle(.plain) + .foregroundStyle(isHovered ? .primary : .secondary) + .help(String(localized: "sidebar.detachedSessions.reattach.tooltip", defaultValue: "Reattach session")) + .disabled(isReattaching) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .background(isHovered ? Color.secondary.opacity(0.1) : Color.clear) + .cornerRadius(4) + .padding(.horizontal, 4) + .onHover { hovering in + isHovered = hovering + } + .accessibilityIdentifier("DetachedSessionRow.\(sessionID)") + .accessibilityLabel(Text(String.localizedStringWithFormat( + String(localized: "sidebar.detachedSessions.row.accessibilityLabel", defaultValue: "Detached session: %@"), + sessionName + ))) + } + + private func reattachSession() { + guard !isReattaching else { return } + isReattaching = true + + let workspace = tabManager.addWorkspace( + title: sessionName, + select: true + ) + workspace.daemonSessionID = sessionID + + Task { + await workspace.reattachDaemonSessionIfNeeded() + await MainActor.run { + isReattaching = false + } + } + } +} + enum SidebarPathFormatter { static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path diff --git a/Sources/DaemonSessionBinding.swift b/Sources/DaemonSessionBinding.swift new file mode 100644 index 0000000000..88deb4ccf8 --- /dev/null +++ b/Sources/DaemonSessionBinding.swift @@ -0,0 +1,891 @@ +import Foundation +#if DEBUG +import Bonsplit +#endif + +/// Bridges a cmux workspace terminal to a daemon-managed PTY session. +/// Maintains a persistent socket connection for streaming PTY I/O. +/// +/// Usage: +/// 1. Create with a daemon session ID and output handler. +/// 2. Call `attach(cols:rows:)` to connect to the daemon and start receiving output. +/// 3. Call `sendInput(_:)` to forward keyboard input to the PTY. +/// 4. Call `detach()` when the workspace closes — the PTY keeps running in the daemon. +/// 5. On restore, create a new binding with the same session ID and `attach()` again. +final class DaemonSessionBinding: @unchecked Sendable { + let sessionID: String + let sessionName: String + + // All mutable state is protected by `lock`. + private let lock = NSLock() + private var _socketFD: Int32 = -1 + private var _readThread: Thread? + private var _isAttached: Bool = false + + /// Monotonically increasing RPC request ID counter. + private var _nextRPCID: Int = 1 + + /// Pending RPC responses keyed by request ID. Completions are called from the + /// read thread (or readJSONLine) when a response with a matching `id` arrives. + private var _pendingResponses: [Int: (([String: Any]) -> Void)] = [:] + + /// Data left over from readJSONLine that must be consumed by the read loop. + private var _pendingData = Data() + + /// Signaled by the read loop when it exits, so teardown can join. + private let readThreadDone = DispatchSemaphore(value: 0) + + /// Whether the read thread has already been joined (waited on). + /// Protected by `lock`. Prevents double-wait on `readThreadDone`. + private var _readThreadJoined: Bool = false + + private let outputHandler: (Data) -> Void + + /// Primary socket path in the private state directory. + private static var socketPath: String { + NSHomeDirectory() + "/.local/state/cmux/daemon-local.sock" + } + + /// Legacy /tmp socket path for backward compatibility. + private static var legacySocketPath: String { + "/tmp/cmux-local-\(getuid()).sock" + } + + /// Returns the best available socket path: primary if it exists, + /// otherwise falls back to the legacy /tmp path. + private static var resolvedSocketPath: String { + let primary = socketPath + if FileManager.default.fileExists(atPath: primary) { + return primary + } + let legacy = legacySocketPath + if FileManager.default.fileExists(atPath: legacy) { + return legacy + } + return primary + } + + init(sessionID: String, sessionName: String, outputHandler: @escaping (Data) -> Void) { + self.sessionID = sessionID + self.sessionName = sessionName + self.outputHandler = outputHandler + } + + deinit { + // Properly tear down via close() which synchronizes with the read thread. + close() + } + + // MARK: - Locked accessors + + private var isAttached: Bool { + get { lock.lock(); defer { lock.unlock() }; return _isAttached } + set { lock.lock(); defer { lock.unlock() }; _isAttached = newValue } + } + + private var socketFD: Int32 { + get { lock.lock(); defer { lock.unlock() }; return _socketFD } + set { lock.lock(); defer { lock.unlock() }; _socketFD = newValue } + } + + /// Allocate a unique RPC ID. Must be called without holding `lock`. + func allocateRPCID() -> Int { + lock.lock() + let id = _nextRPCID + _nextRPCID += 1 + lock.unlock() + return id + } + + /// Register a pending response handler for the given RPC ID. + /// The completion will be called from the read thread or from `readJSONLine`. + private func registerPendingResponse(id: Int, completion: @escaping ([String: Any]) -> Void) { + lock.lock() + _pendingResponses[id] = completion + lock.unlock() + } + + /// Remove and return the pending response handler for the given ID, if any. + private func takePendingResponse(id: Int) -> (([String: Any]) -> Void)? { + lock.lock() + let handler = _pendingResponses.removeValue(forKey: id) + lock.unlock() + return handler + } + + /// Cancel all pending responses (called during teardown). + private func cancelAllPendingResponses() { + lock.lock() + let handlers = _pendingResponses + _pendingResponses.removeAll() + lock.unlock() + // Signal nil-equivalent (empty dict) so waiters unblock. + for (_, handler) in handlers { + handler([:]) + } + } + + // MARK: - Public API + + /// Attach to the daemon session — sends `session.attach` RPC, then enters + /// streaming mode receiving `pty.replay` and `pty.output` events. + func attach(cols: Int, rows: Int) throws { + lock.lock() + guard !_isAttached else { lock.unlock(); return } + lock.unlock() + + let fd = try Self.connectSocket() + + lock.lock() + _socketFD = fd + lock.unlock() + + // Send session.attach RPC + let rpcID = allocateRPCID() + let request: [String: Any] = [ + "id": rpcID, + "method": "session.attach", + "params": [ + "session_id": sessionID, + "cols": cols, + "rows": rows, + ], + ] + + try sendJSON(request, on: fd) + let (response, remainder) = try readJSONLine(from: fd) + + guard response["ok"] as? Bool == true else { + lock.lock() + _socketFD = -1 + lock.unlock() + Darwin.close(fd) + let message = (response["error"] as? [String: Any])?["message"] as? String ?? "attach failed" + throw DaemonSessionError.attachFailed(message) + } + + lock.lock() + _isAttached = true + _pendingData = remainder + lock.unlock() + + startReadThread() + } + + /// Detach from the session. The daemon keeps the PTY alive. + func detach() { + lock.lock() + guard _isAttached else { lock.unlock(); return } + _isAttached = false + let fd = _socketFD + let thread = _readThread + lock.unlock() + + // Send detach RPC and wait briefly for acknowledgment. + if fd >= 0 { + let rpcID = allocateRPCID() + let sem = DispatchSemaphore(value: 0) + registerPendingResponse(id: rpcID) { _ in + sem.signal() + } + let request: [String: Any] = [ + "id": rpcID, + "method": "session.detach", + "params": ["session_id": sessionID], + ] + if (try? sendJSON(request, on: fd)) != nil { + // Wait up to 2 seconds for the response before proceeding with teardown. + _ = sem.wait(timeout: .now() + 2.0) + } + // Clean up in case the response never arrived. + _ = takePendingResponse(id: rpcID) + } + + // Unblock the read() call by shutting down the socket. + if fd >= 0 { + shutdown(fd, SHUT_RDWR) + } + + // Cancel the thread and wait for it to exit. Only one caller joins. + thread?.cancel() + lock.lock() + let shouldJoin = thread != nil && !_readThreadJoined + if shouldJoin { _readThreadJoined = true } + lock.unlock() + if shouldJoin { + readThreadDone.wait() + } + + // Now safe to close the FD — read loop has exited. + lock.lock() + if _socketFD >= 0 { + Darwin.close(_socketFD) + _socketFD = -1 + } + _readThread = nil + lock.unlock() + } + + /// Send keyboard input to the PTY (fire-and-forget). + func sendInput(_ data: Data) { + lock.lock() + guard _isAttached, _socketFD >= 0 else { lock.unlock(); return } + let fd = _socketFD + lock.unlock() + + let base64 = data.base64EncodedString() + let rpcID = allocateRPCID() + let request: [String: Any] = [ + "id": rpcID, + "method": "pty.input", + "params": [ + "session_id": sessionID, + "data_base64": base64, + ], + ] + try? sendJSON(request, on: fd) + } + + /// Resize the PTY. + func resize(cols: Int, rows: Int) { + lock.lock() + guard _isAttached, _socketFD >= 0 else { lock.unlock(); return } + let fd = _socketFD + lock.unlock() + + let rpcID = allocateRPCID() + let request: [String: Any] = [ + "id": rpcID, + "method": "session.resize", + "params": [ + "session_id": sessionID, + "cols": cols, + "rows": rows, + ], + ] + try? sendJSON(request, on: fd) + } + + /// Close the session entirely (kills the PTY in the daemon). + func close() { + lock.lock() + let wasAttached = _isAttached + _isAttached = false + let fd = _socketFD + let thread = _readThread + lock.unlock() + + // Send close RPC and wait briefly for acknowledgment. + if fd >= 0 && wasAttached { + let rpcID = allocateRPCID() + let sem = DispatchSemaphore(value: 0) + registerPendingResponse(id: rpcID) { _ in + sem.signal() + } + let request: [String: Any] = [ + "id": rpcID, + "method": "session.close", + "params": ["session_id": sessionID], + ] + if (try? sendJSON(request, on: fd)) != nil { + // Wait up to 2 seconds for the response before proceeding with teardown. + _ = sem.wait(timeout: .now() + 2.0) + } + // Clean up in case the response never arrived. + _ = takePendingResponse(id: rpcID) + } + + // Unblock the read() call. + if fd >= 0 { + shutdown(fd, SHUT_RDWR) + } + + // Cancel the thread and wait for it to exit. Only one caller joins. + thread?.cancel() + lock.lock() + let shouldJoin = thread != nil && !_readThreadJoined + if shouldJoin { _readThreadJoined = true } + lock.unlock() + if shouldJoin { + readThreadDone.wait() + } + + // Now safe to close the FD. + lock.lock() + if _socketFD >= 0 { + Darwin.close(_socketFD) + _socketFD = -1 + } + _readThread = nil + lock.unlock() + } + + // MARK: - Private + + private static func connectSocket() throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw DaemonSessionError.socketCreateFailed + } + + // Disable SIGPIPE + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, ptr, socklen_t(MemoryLayout.size)) + } + + // Set send/receive timeouts (5 seconds) to avoid permanent hangs. + var socketTimeout = timeval(tv_sec: 5, tv_usec: 0) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ptr, socklen_t(MemoryLayout.size)) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, ptr, socklen_t(MemoryLayout.size)) + } + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let pathBytes = Array(resolvedSocketPath.utf8CString) + guard pathBytes.count <= maxLen else { + Darwin.close(fd) + throw DaemonSessionError.socketPathTooLong + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for index in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + pathBytes.count) + addr.sun_len = UInt8(min(Int(addrLen), 255)) + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { + Darwin.close(fd) + throw DaemonSessionError.connectFailed(errno) + } + + return fd + } + + private func sendJSON(_ object: [String: Any], on fd: Int32) throws { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: []), + var payload = String(data: data, encoding: .utf8) else { + throw DaemonSessionError.serializationFailed + } + payload += "\n" + + let success = payload.withCString { cString in + var remaining = strlen(cString) + var pointer = UnsafeRawPointer(cString) + while remaining > 0 { + let written = write(fd, pointer, remaining) + if written <= 0 { return false } + remaining -= written + pointer = pointer.advanced(by: written) + } + return true + } + guard success else { + throw DaemonSessionError.writeFailed + } + } + + /// Read a single JSON line from the socket. Returns the parsed JSON and any + /// remaining data that was read beyond the first newline, so the caller can + /// feed it into the read loop without data loss. + private func readJSONLine(from fd: Int32) throws -> ([String: Any], Data) { + var accumulated = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + + while true { + let count = read(fd, &buffer, buffer.count) + if count <= 0 { + throw DaemonSessionError.readFailed + } + accumulated.append(contentsOf: buffer[0..= 0, !Thread.current.isCancelled else { break } + + let count = read(fd, &buffer, buffer.count) + if count <= 0 { + // Connection closed, shutdown, or error — mark detached. + lock.lock() + _isAttached = false + lock.unlock() + break + } + + accumulated.append(contentsOf: buffer[0.. & cat '` +/// which creates a bidirectional bridge without modifying ghostty internals: +/// - `cat ` reads daemon output from the FIFO and writes to stdout (rendered by ghostty) +/// - `cat > ` captures keyboard input from stdin and writes to the input FIFO +/// - A reader thread on the input FIFO forwards data to `DaemonSessionBinding.sendInput()` +final class DaemonPTYBridge { + let outputFIFOPath: String + let inputFIFOPath: String + + /// Private temp directory containing the FIFOs (created with 0700 permissions). + private let fifoDirectory: String + + /// The command to use as the ghostty surface's `initialCommand`. + /// Ghostty spawns this instead of the default shell. + var surfaceCommand: String { + // The shell command sets up bidirectional I/O: + // 1. Background: read stdin and write to input FIFO (forwards keyboard to daemon) + // 2. Foreground: read output FIFO and write to stdout (displays daemon output) + // + // `cat > input_fifo` runs in background reading the surface's stdin (keyboard input + // from ghostty's PTY) and writing to the input FIFO. `cat output_fifo` reads daemon + // output from the output FIFO and writes to stdout for ghostty to render. + // + // When the output FIFO is closed (bridge teardown), `cat` exits, the shell exits, + // and ghostty sees the child process terminate. + // + // FIFO paths are under a mkdtemp directory with sanitized session IDs, so they + // contain only safe characters. We still shell-escape them for defense in depth. + let escapedInput = shellEscape(inputFIFOPath) + let escapedOutput = shellEscape(outputFIFOPath) + return "/bin/sh -c 'cat >\(escapedInput) & cat \(escapedOutput)'" + } + + /// Escape a string for safe embedding inside a single-quoted shell context. + /// Ends the current single-quote, inserts an escaped literal single-quote, + /// then reopens the single-quote: ' -> '\'' + func shellEscape(_ path: String) -> String { + path.replacingOccurrences(of: "'", with: "'\\''") + } + + private let lock = NSLock() + private var _outputFD: Int32 = -1 + private var _inputReadThread: Thread? + private var _isTornDown = false + private var _inputReaderJoined = false + private weak var binding: DaemonSessionBinding? + + /// Signaled by the input reader thread when it exits, so teardown can join. + private let inputReaderDone = DispatchSemaphore(value: 0) + + /// Activation timeout in seconds. If the output FIFO cannot be opened within + /// this duration (e.g., the surface process failed to launch), activate gives up. + private static let activationTimeoutSeconds: Int = 10 + + /// Sanitize a session ID to only contain safe characters (alphanumeric, dash, + /// underscore). All other characters are replaced with underscores. + static func sanitizeSessionID(_ sessionID: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + return String(sessionID.unicodeScalars.map { allowed.contains($0) ? Character($0) : Character("_") }) + } + + /// Create a private temporary directory with 0700 permissions for FIFO files. + /// Uses mkdtemp for atomicity. Returns the directory path. + static func createPrivateTempDirectory(uid: uid_t, sanitizedID: String) -> String? { + let parentDir = "/tmp/cmux-\(uid)" + + // Ensure parent directory exists with 0700 permissions. + var isDir: ObjCBool = false + if !FileManager.default.fileExists(atPath: parentDir, isDirectory: &isDir) { + mkdir(parentDir, 0o700) + } + + // Use mkdtemp to create a unique subdirectory. + let template = parentDir + "/bridge-\(sanitizedID)-XXXXXX" + var templateBytes = Array(template.utf8CString) + guard let result = mkdtemp(&templateBytes) else { + return nil + } + return String(cString: result) + } + + /// Create a FIFO bridge for the given daemon session. + /// - Parameter sessionID: Used to generate unique FIFO paths. Sanitized internally. + /// - Parameter binding: The daemon session binding to forward input to. + /// - Throws: If FIFO creation fails. + init(sessionID: String, binding: DaemonSessionBinding) throws { + let sanitized = Self.sanitizeSessionID(String(sessionID.prefix(12))) + guard let dir = Self.createPrivateTempDirectory(uid: getuid(), sanitizedID: sanitized) else { + throw DaemonBridgeError.fifoDirectoryCreationFailed + } + self.fifoDirectory = dir + self.outputFIFOPath = dir + "/out.fifo" + self.inputFIFOPath = dir + "/in.fifo" + self.binding = binding + try createFIFOs() + } + + deinit { + teardown() + } + + // MARK: - Setup + + private func createFIFOs() throws { + // Remove stale FIFOs from a previous session (should not exist in mkdtemp dir, + // but be defensive). + unlink(outputFIFOPath) + unlink(inputFIFOPath) + + // Create FIFOs with owner-only permissions. + guard mkfifo(outputFIFOPath, 0o600) == 0 else { + // Clean up the directory since we can't proceed. + rmdir(fifoDirectory) + throw DaemonBridgeError.mkfifoFailed(outputFIFOPath, errno) + } + guard mkfifo(inputFIFOPath, 0o600) == 0 else { + // Clean up the output FIFO and directory. + unlink(outputFIFOPath) + rmdir(fifoDirectory) + throw DaemonBridgeError.mkfifoFailed(inputFIFOPath, errno) + } + } + + /// Open the output FIFO for writing and start the input reader thread. + /// Must be called AFTER the terminal surface has been created and its child + /// process has opened the FIFO for reading (otherwise `open` blocks). + /// + /// This method is designed to be called from a background thread since + /// opening a FIFO blocks until the other end opens it too. + /// + /// Uses O_WRONLY|O_NONBLOCK with a retry loop and timeout to avoid blocking + /// a GCD thread forever if the surface process fails to launch. + func activate() { + lock.lock() + guard !_isTornDown else { lock.unlock(); return } + lock.unlock() + + // Open the output FIFO for writing with a timeout. O_WRONLY|O_NONBLOCK + // returns ENXIO if the read end isn't open yet, so we retry in a loop. + let deadline = DispatchTime.now() + .seconds(Self.activationTimeoutSeconds) + var outFD: Int32 = -1 + + while DispatchTime.now() < deadline { + // Check for teardown between retries. + lock.lock() + if _isTornDown { lock.unlock(); return } + lock.unlock() + + outFD = open(outputFIFOPath, O_WRONLY | O_NONBLOCK) + if outFD >= 0 { + break + } + if errno == ENXIO { + // Read end not open yet; wait 50ms and retry. + usleep(50_000) + continue + } + // Some other error (e.g., file deleted by teardown). + return + } + + guard outFD >= 0 else { + // Timed out waiting for surface to open the FIFO. + teardown() + return + } + + // Clear O_NONBLOCK now that the FIFO is connected, so writes block normally. + let flags = fcntl(outFD, F_GETFL) + if flags >= 0 { + _ = fcntl(outFD, F_SETFL, flags & ~O_NONBLOCK) + } + + // Disable SIGPIPE on this fd. FIFOs use fcntl, not setsockopt. + _ = fcntl(outFD, F_SETNOSIGPIPE, 1) + + // Re-check teardown under lock after the potentially long open. + // If teardown() ran while we were waiting, close the FD to avoid a leak. + lock.lock() + if _isTornDown { + lock.unlock() + Darwin.close(outFD) + return + } + _outputFD = outFD + lock.unlock() + + startInputReaderThread() + } + + // MARK: - Output (daemon → surface) + + /// Write raw PTY output data to the output FIFO. + /// Called from the `DaemonSessionBinding` read thread. + /// + /// On write failure (broken pipe, etc.), logs the error and triggers teardown + /// to prevent the bridge from entering a zombie state. + func writeOutput(_ data: Data) { + lock.lock() + let fd = _outputFD + guard fd >= 0, !_isTornDown else { lock.unlock(); return } + lock.unlock() + + var writeFailed = false + + data.withUnsafeBytes { rawBuffer in + guard var ptr = rawBuffer.baseAddress else { return } + var remaining = rawBuffer.count + while remaining > 0 { + let written = write(fd, ptr, remaining) + if written <= 0 { + writeFailed = true + break + } + remaining -= written + ptr = ptr.advanced(by: written) + } + } + + if writeFailed { + #if DEBUG + dlog("DaemonPTYBridge.writeOutput: write failed (errno \(errno)), triggering teardown") + #endif + teardown() + } + } + + // MARK: - Input (surface → daemon) + + private func startInputReaderThread() { + let thread = Thread { [weak self] in + self?.inputReadLoop() + } + thread.name = "DaemonPTYBridge.inputReader" + thread.qualityOfService = .userInteractive + + lock.lock() + _inputReadThread = thread + lock.unlock() + + thread.start() + } + + private func inputReadLoop() { + defer { + inputReaderDone.signal() + } + + // Open the input FIFO for reading. By the time this runs, the surface's + // `cat > ` (background) should already have the write end open + // because activate() waits for the foreground `cat ` first, and + // the shell starts the background process before the foreground one. + // If the writer hasn't connected yet, this blocks briefly until it does. + // + // Teardown sends EOF by opening and immediately closing the write end, + // which unblocks any pending read(). + let inFD = open(inputFIFOPath, O_RDONLY) + guard inFD >= 0 else { return } + defer { Darwin.close(inFD) } + + var buffer = [UInt8](repeating: 0, count: 4096) + while !Thread.current.isCancelled { + let count = read(inFD, &buffer, buffer.count) + if count <= 0 { break } + let data = Data(buffer[0..= 0 { + Darwin.close(outFD) + } + + // Cancel the input reader thread and unblock it. + // If the reader is blocked on read(), opening the write end and closing + // it delivers EOF. If the reader is still blocked on open() (rare race), + // the O_NONBLOCK open may fail with ENXIO; the subsequent unlink ensures + // the thread exits once the kernel releases the path reference. + thread?.cancel() + let kickFD = open(inputFIFOPath, O_WRONLY | O_NONBLOCK) + if kickFD >= 0 { + Darwin.close(kickFD) + } + + // Wait for the input reader thread to finish. + if shouldJoinInput { + inputReaderDone.wait() + } + + // Clean up FIFO files and private temp directory. + unlink(outputFIFOPath) + unlink(inputFIFOPath) + rmdir(fifoDirectory) + } +} + +// MARK: - Bridge Errors + +enum DaemonBridgeError: Error, LocalizedError { + case fifoDirectoryCreationFailed + case mkfifoFailed(String, Int32) + + var errorDescription: String? { + switch self { + case .fifoDirectoryCreationFailed: + return "Failed to create private temp directory for FIFO bridge" + case .mkfifoFailed(let path, let code): + return "mkfifo failed for \(path) (errno \(code))" + } + } +} + +// MARK: - Errors + +enum DaemonSessionError: Error, LocalizedError { + case socketCreateFailed + case socketPathTooLong + case connectFailed(Int32) + case serializationFailed + case writeFailed + case readFailed + case parseFailed + case attachFailed(String) + + var errorDescription: String? { + switch self { + case .socketCreateFailed: return "Failed to create Unix socket" + case .socketPathTooLong: return "Daemon socket path too long" + case .connectFailed(let code): return "Failed to connect to daemon (errno \(code))" + case .serializationFailed: return "Failed to serialize RPC request" + case .writeFailed: return "Failed to write to daemon socket" + case .readFailed: return "Failed to read from daemon socket" + case .parseFailed: return "Failed to parse daemon response" + case .attachFailed(let msg): return "Daemon attach failed: \(msg)" + } + } +} diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 13f78f1257..1fae9f4efa 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -7,7 +7,7 @@ struct GhosttyConfig { case dark } - private static let cmuxReleaseBundleIdentifier = "com.cmuxterm.app" + private static let cmuxReleaseBundleIdentifier = "com.jmux.app" private static let loadCacheLock = NSLock() private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:] diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ad4d468fae..6aaa0f868e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -933,7 +933,7 @@ private final class GhosttySurfaceCallbackContext { class GhosttyApp { static let shared = GhosttyApp() - private static let releaseBundleIdentifier = "com.cmuxterm.app" + private static let releaseBundleIdentifier = "com.jmux.app" private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -6285,11 +6285,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.text = nil keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) - // Refresh ghostty's mouse position so quicklook_word uses current coordinates - // when Cmd is pressed while the pointer is stationary. - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) - updateWordPathHover(cmdHeld: event.modifierFlags.contains(.command)) + // 只在鼠标左键未按下时刷新鼠标位置,避免在选择拖拽期间(触控板手指 + // 仍按着)按 Cmd 键时触发 Ghostty 的选择自动滚动逻辑。 + // quicklook_word 的坐标刷新只在指针静止时才有意义。 + if NSEvent.pressedMouseButtons & 0x1 == 0 { + let point = convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + updateWordPathHover(cmdHeld: event.modifierFlags.contains(.command)) + } } private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 7261e4352b..27ef16b52a 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -148,7 +148,7 @@ enum KeyboardShortcutSettings { case .jumpToUnread: return StoredShortcut(key: "u", command: true, shift: true, option: false, control: false) case .triggerFlash: - return StoredShortcut(key: "h", command: true, shift: true, option: false, control: false) + return StoredShortcut(key: "h", command: true, shift: false, option: true, control: true) case .nextSidebarTab: return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: @@ -162,13 +162,13 @@ enum KeyboardShortcutSettings { case .closeWorkspace: return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false) case .focusLeft: - return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false) + return StoredShortcut(key: "h", command: true, shift: true, option: false, control: false) case .focusRight: - return StoredShortcut(key: "→", command: true, shift: false, option: true, control: false) + return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) case .focusUp: - return StoredShortcut(key: "↑", command: true, shift: false, option: true, control: false) + return StoredShortcut(key: "k", command: true, shift: true, option: false, control: false) case .focusDown: - return StoredShortcut(key: "↓", command: true, shift: false, option: true, control: false) + return StoredShortcut(key: "j", command: true, shift: true, option: false, control: false) case .splitRight: return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: @@ -192,7 +192,7 @@ enum KeyboardShortcutSettings { case .selectWorkspaceByNumber: return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) case .openBrowser: - return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + return StoredShortcut(key: "l", command: true, shift: false, option: true, control: true) case .toggleBrowserDeveloperTools: // Safari default: Show Web Inspector. return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false) diff --git a/Sources/LocalDaemonManager.swift b/Sources/LocalDaemonManager.swift new file mode 100644 index 0000000000..006b84f255 --- /dev/null +++ b/Sources/LocalDaemonManager.swift @@ -0,0 +1,550 @@ +import Foundation + +/// Manages the lifecycle of the cmuxd-local daemon process. +/// The daemon owns PTYs and enables session detach/reattach. +/// The daemon is intentionally NOT killed on app quit so sessions persist. +@MainActor +final class LocalDaemonManager: ObservableObject { + static let shared = LocalDaemonManager() + + @Published private(set) var isRunning: Bool = false + @Published private(set) var daemonVersion: String? + @Published private(set) var isLaunchAgentInstalled: Bool = false + + /// Sessions that are running in the daemon but not currently attached to any workspace. + /// Updated periodically when the daemon is running. + @Published private(set) var detachedSessions: [[String: Any]] = [] + + /// Number of detached sessions, suitable for badge/indicator display. + var detachedSessionCount: Int { detachedSessions.count } + + private var daemonProcess: Process? + private var refreshTask: Task? + + nonisolated private static let launchAgentLabel = "com.cmux.daemon-local" + + nonisolated static var launchAgentPlistPath: String { + NSHomeDirectory() + "/Library/LaunchAgents/com.cmux.daemon-local.plist" + } + + /// Private state directory for the daemon (~/.local/state/cmux/). + /// Matches the Go daemon's DefaultStateDir(). + nonisolated static var stateDir: String { + NSHomeDirectory() + "/.local/state/cmux" + } + + /// Primary socket path in the private state directory. + nonisolated static var socketPath: String { + stateDir + "/daemon-local.sock" + } + + /// Legacy /tmp socket path for backward compatibility. + nonisolated static var legacySocketPath: String { + "/tmp/cmux-local-\(getuid()).sock" + } + + /// Returns the best available socket path: primary if it exists, + /// otherwise falls back to the legacy /tmp path. + nonisolated static var resolvedSocketPath: String { + let primary = socketPath + if FileManager.default.fileExists(atPath: primary) { + return primary + } + let legacy = legacySocketPath + if FileManager.default.fileExists(atPath: legacy) { + return legacy + } + return primary + } + + private init() { + // Check if the launch agent plist is already installed. + isLaunchAgentInstalled = FileManager.default.fileExists(atPath: Self.launchAgentPlistPath) + } + + // MARK: - Public API + + /// Start the daemon if not already running. Safe to call multiple times. + /// Async to avoid blocking the main thread while waiting for the daemon to become ready. + /// + /// If a launchd agent is installed, checks `launchctl list` for the daemon + /// and kicks it via `launchctl kickstart` if needed. Falls back to direct + /// process spawn when no launch agent is configured. + func ensureRunning() async { + // Fast path: daemon already confirmed running. + if isRunning, Self.probeSync() { + return + } + + // Check if an external daemon (or launchd-managed) is already listening. + if Self.probeSync() { + isRunning = true + await fetchVersion() + return + } + + // If a launchd agent is installed, use launchctl to start/restart it. + if isLaunchAgentInstalled { + let started = await ensureRunningViaLaunchd() + if started { + return + } + // Launchd start failed — fall through to direct spawn as a fallback. + } + + // Direct spawn: launch a new daemon process. + guard let binaryPath = locateBinary() else { + isRunning = false + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = [] + // Detach stdout/stderr so the app doesn't capture daemon output. + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + } catch { + isRunning = false + return + } + + daemonProcess = process + + // Wait up to 3 seconds for the socket to appear and become reachable. + // Uses Task.sleep instead of Thread.sleep to avoid blocking the main thread. + // Probe runs off-main via Task.detached to keep the main thread free. + var connected = false + for _ in 0..<30 { + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + let probeResult = await Task.detached { Self.probeSync() }.value + if probeResult { + connected = true + break + } + } + + isRunning = connected + if connected { + await fetchVersion() + } + } + + /// Attempt to start the daemon via launchd. Returns true if the daemon + /// becomes reachable after kickstarting the agent. + private func ensureRunningViaLaunchd() async -> Bool { + // Check if launchd already knows about the service by listing it. + let isLoaded = await Task.detached { + Self.launchdServiceIsLoaded() + }.value + + if !isLoaded { + // The plist exists but hasn't been bootstrapped. Bootstrap it now. + let bootstrapSuccess = await Task.detached { + Self.runLaunchctl(arguments: ["bootstrap", "gui/\(getuid())", Self.launchAgentPlistPath]) + }.value + if !bootstrapSuccess { + return false + } + } + + // Kickstart the service in case it's not running (idempotent if already running). + _ = await Task.detached { + Self.runLaunchctl(arguments: ["kickstart", "gui/\(getuid())/\(Self.launchAgentLabel)"]) + }.value + + // Wait up to 3 seconds for the daemon to become reachable. + var connected = false + for _ in 0..<30 { + try? await Task.sleep(nanoseconds: 100_000_000) + let probeResult = await Task.detached { Self.probeSync() }.value + if probeResult { + connected = true + break + } + } + + isRunning = connected + if connected { + await fetchVersion() + } + return connected + } + + /// Check if the daemon socket is reachable (nonisolated, safe to call from any thread). + nonisolated static func probeSync() -> Bool { + guard let response = rpcSync(method: "ping") else { return false } + return response["ok"] as? Bool == true + } + + /// Send an RPC request to the daemon and return the parsed response. + /// Nonisolated — performs blocking socket I/O, must NOT be called on the main thread. + /// Use `rpcAsync` from MainActor contexts instead. + nonisolated static func rpcSync(method: String, params: [String: Any] = [:]) -> [String: Any]? { + let request: [String: Any] = [ + "id": 1, + "method": method, + "params": params, + ] + guard JSONSerialization.isValidJSONObject(request), + let data = try? JSONSerialization.data(withJSONObject: request, options: []), + let payload = String(data: data, encoding: .utf8) else { + return nil + } + guard let raw = sendSocketCommand(payload, socketPath: resolvedSocketPath, timeout: 5.0) else { return nil } + guard let responseData = raw.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else { + return nil + } + return parsed + } + + /// Async wrapper that dispatches the blocking RPC to a background thread. + /// Safe to call from MainActor contexts. + func rpcAsync(method: String, params: [String: Any] = [:]) async -> [String: Any]? { + await Task.detached { + Self.rpcSync(method: method, params: params) + }.value + } + + /// List active sessions from the daemon (nonisolated, blocking). + nonisolated static func listSessionsSync() -> [[String: Any]] { + guard let response = rpcSync(method: "session.list") else { return [] } + guard response["ok"] as? Bool == true, + let result = response["result"] as? [String: Any], + let sessions = result["sessions"] as? [[String: Any]] else { + return [] + } + return sessions + } + + /// Async wrapper for listing sessions, safe to call from MainActor. + func listSessionsAsync() async -> [[String: Any]] { + await Task.detached { + Self.listSessionsSync() + }.value + } + + /// Stop the daemon. Only call on explicit user request — NOT on app quit. + func stop() async { + refreshTask?.cancel() + refreshTask = nil + await rpcAsync(method: "shutdown") + daemonProcess?.terminate() + daemonProcess = nil + isRunning = false + daemonVersion = nil + detachedSessions = [] + } + + /// Refresh the list of detached (running but not attached) sessions from the daemon. + /// Fetches sessions off-main, then updates the published property on MainActor. + func refreshDetachedSessions() async { + let all = await Task.detached { + Self.listSessionsSync() + }.value + detachedSessions = all.filter { session in + let attached = session["attached"] as? Int ?? 0 + let status = session["status"] as? String ?? "" + return attached == 0 && status == "running" + } + } + + /// Begin periodic polling of detached sessions. Called once after daemon startup. + func startPeriodicRefresh() { + guard refreshTask == nil else { return } + // Do an initial refresh. + Task { await refreshDetachedSessions() } + // Start a detached loop that polls every 5 seconds. + refreshTask = Task.detached { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard !Task.isCancelled else { return } + let sessions = Self.listSessionsSync() + let detached = sessions.filter { session in + let attached = session["attached"] as? Int ?? 0 + let status = session["status"] as? String ?? "" + return attached == 0 && status == "running" + } + await MainActor.run { [weak self] in + self?.detachedSessions = detached + } + } + } + } + + // MARK: - Launch Agent Management + + /// Install a launchd user agent so the daemon auto-starts on login. + /// Copies the plist template, substitutes the binary path, and bootstraps the agent. + /// Runs file I/O and launchctl off the main thread. + func installLaunchAgent() async throws { + guard let binaryPath = locateBinary() else { + throw LaunchAgentError.binaryNotFound( + String(localized: "daemon.launchAgent.error.binaryNotFound", + defaultValue: "Could not find the cmuxd-local binary to install the launch agent.") + ) + } + + let home = NSHomeDirectory() + let plistDestination = Self.launchAgentPlistPath + + // Read the plist template from the app bundle or the source tree. + guard let templateURL = Bundle.main.url(forResource: "com.cmux.daemon-local", withExtension: "plist"), + let templateData = try? Data(contentsOf: templateURL), + var templateString = String(data: templateData, encoding: .utf8) else { + throw LaunchAgentError.templateNotFound( + String(localized: "daemon.launchAgent.error.templateNotFound", + defaultValue: "Could not find the launch agent plist template in the app bundle.") + ) + } + + // Substitute placeholders. + templateString = templateString + .replacingOccurrences(of: "__CMUXD_LOCAL_BINARY_PATH__", with: binaryPath) + .replacingOccurrences(of: "__HOME__", with: home) + + // Write the plist and bootstrap — all off-main. + try await Task.detached { + let launchAgentsDir = home + "/Library/LaunchAgents" + let fm = FileManager.default + + // Ensure ~/Library/LaunchAgents exists. + if !fm.fileExists(atPath: launchAgentsDir) { + try fm.createDirectory(atPath: launchAgentsDir, withIntermediateDirectories: true) + } + + // If already installed, bootout the old one first (ignore errors). + if fm.fileExists(atPath: plistDestination) { + _ = Self.runLaunchctl(arguments: ["bootout", "gui/\(getuid())/\(Self.launchAgentLabel)"]) + try? fm.removeItem(atPath: plistDestination) + } + + // Write the substituted plist. + guard let plistData = templateString.data(using: .utf8) else { + throw LaunchAgentError.writeFailed( + String(localized: "daemon.launchAgent.error.writeFailed", + defaultValue: "Failed to write the launch agent plist file.") + ) + } + try plistData.write(to: URL(fileURLWithPath: plistDestination)) + + // Bootstrap the agent. + let success = Self.runLaunchctl(arguments: ["bootstrap", "gui/\(getuid())", plistDestination]) + if !success { + // Clean up on failure. + try? fm.removeItem(atPath: plistDestination) + throw LaunchAgentError.bootstrapFailed( + String(localized: "daemon.launchAgent.error.bootstrapFailed", + defaultValue: "Failed to bootstrap the launch agent with launchctl.") + ) + } + }.value + + isLaunchAgentInstalled = true + } + + /// Uninstall the launchd user agent. Stops the daemon via launchctl and removes the plist. + /// Runs launchctl and file removal off the main thread. + func uninstallLaunchAgent() async throws { + let plistPath = Self.launchAgentPlistPath + + try await Task.detached { + // Bootout the service (stops the daemon if running). + _ = Self.runLaunchctl(arguments: ["bootout", "gui/\(getuid())/\(Self.launchAgentLabel)"]) + + // Remove the plist file. + let fm = FileManager.default + if fm.fileExists(atPath: plistPath) { + try fm.removeItem(atPath: plistPath) + } + }.value + + isLaunchAgentInstalled = false + } + + // MARK: - Private + + /// Check if the launchd service is currently loaded (known to launchd). + nonisolated private static func launchdServiceIsLoaded() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["print", "gui/\(getuid())/\(launchAgentLabel)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + /// Run a launchctl command with the given arguments. Returns true on success. + @discardableResult + nonisolated private static func runLaunchctl(arguments: [String]) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + private func fetchVersion() async { + guard let response = await rpcAsync(method: "hello") else { return } + if let result = response["result"] as? [String: Any], + let version = result["version"] as? String { + daemonVersion = version + } + } + + /// Locate the cmuxd-local binary, checking bundled, user-installed, and project paths. + private func locateBinary() -> String? { + let candidates: [String] = [ + // 1. Bundled inside the app + Bundle.main.resourcePath.map { $0 + "/cmuxd-local" }, + // 2. User-installed + NSHomeDirectory() + "/.cmux/bin/cmuxd-local", + // 3. Homebrew (Apple Silicon) + "/opt/homebrew/bin/cmuxd-local", + // 4. Homebrew (Intel) + "/usr/local/bin/cmuxd-local", + ].compactMap { $0 } + + for path in candidates { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + + /// Connect to the Unix domain socket, send a command, and read the first response line. + /// Nonisolated static method — safe to call from any thread. + nonisolated private static func sendSocketCommand(_ command: String, socketPath: String, timeout: TimeInterval) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + // Configure timeouts + let normalizedTimeout = max(timeout, 0) + let seconds = floor(normalizedTimeout) + let microseconds = (normalizedTimeout - seconds) * 1_000_000 + var socketTimeout = timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds.rounded())) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ptr, socklen_t(MemoryLayout.size)) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, ptr, socklen_t(MemoryLayout.size)) + } + + // Disable SIGPIPE + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, ptr, socklen_t(MemoryLayout.size)) + } + + // Connect to Unix socket + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for index in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + pathBytes.count) + addr.sun_len = UInt8(min(Int(addrLen), 255)) + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { return nil } + + // Write the command + newline + let payload = command + "\n" + let wroteAll = payload.withCString { cString in + var remaining = strlen(cString) + var pointer = UnsafeRawPointer(cString) + while remaining > 0 { + let written = write(fd, pointer, remaining) + if written <= 0 { return false } + remaining -= written + pointer = pointer.advanced(by: written) + } + return true + } + guard wroteAll else { return nil } + + // Read response until newline + var buffer = [UInt8](repeating: 0, count: 4096) + var response = "" + + while true { + let count = read(fd, &buffer, buffer.count) + if count < 0 { + let readErrno = errno + if readErrno == EAGAIN || readErrno == EWOULDBLOCK { + break + } + return nil + } + if count == 0 { + break + } + if let chunk = String(bytes: buffer[0.. String { - if bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") { - return "com.cmuxterm.app.debug" + if bundleIdentifier.hasPrefix("com.jmux.app.debug.") { + return "com.jmux.app.debug" } - if bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.") { - return "com.cmuxterm.app.staging" + if bundleIdentifier.hasPrefix("com.jmux.app.staging.") { + return "com.jmux.app.staging" } return bundleIdentifier } @@ -1508,7 +1508,7 @@ final class BrowserHistoryStore: ObservableObject { guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } - let bundleId = Bundle.main.bundleIdentifier ?? "cmux" + let bundleId = Bundle.main.bundleIdentifier ?? "jmux" let namespace = normalizedBrowserHistoryNamespace(bundleIdentifier: bundleId) let dir = appSupport.appendingPathComponent(namespace, isDirectory: true) return dir.appendingPathComponent("browser_history.json", isDirectory: false) @@ -1730,7 +1730,7 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { - private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + private static let remoteLoopbackProxyAliasHost = "jmux-loopback.localtest.me" private static let remoteLoopbackHosts: Set = [ "localhost", "127.0.0.1", diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index c5d3a79f25..4d40d170b6 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -181,6 +181,26 @@ final class TerminalPanel: Panel, ObservableObject { surface.teardownSurface() } + /// Detach from a daemon session instead of killing the PTY. + /// Called when a workspace with an active daemon session is closed — + /// the PTY keeps running in the daemon for later reattach. + func detachFromDaemon() { + surface.beginPortalCloseLifecycle(reason: "daemon.detach") +#if DEBUG + let frame = String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height) + let bounds = String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height) + dlog( + "surface.panel.daemon_detach panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspaceId.uuidString.prefix(5)) frame=\(frame) bounds=\(bounds)" + ) +#endif + unfocus() + hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.detach(hostedView: hostedView) + // Tear down the UI surface but NOT the underlying PTY — the daemon owns it. + surface.teardownSurface() + } + func requestViewReattach() { viewReattachToken &+= 1 } diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 90eb071fc3..d77a9d36dd 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -216,7 +216,7 @@ final class PostHogAnalytics { } nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] { - var properties: [String: Any] = ["platform": "cmuxterm"] + var properties: [String: Any] = ["platform": "jmux"] properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new } return properties } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index a6498b8bec..957c59b9aa 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -341,6 +341,9 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var logEntries: [SessionLogEntrySnapshot] var progress: SessionProgressSnapshot? var gitBranch: SessionGitBranchSnapshot? + /// Daemon session ID for PTY reattach after workspace restore. + /// When present, the daemon still owns a live PTY for this workspace. + var daemonSessionID: String? } struct SessionTabManagerSnapshot: Codable, Sendable { @@ -414,14 +417,14 @@ enum SessionPersistenceStore { } let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? bundleIdentifier! - : "com.cmuxterm.app" + : "com.jmux.app" let safeBundleId = bundleId.replacingOccurrences( of: "[^A-Za-z0-9._-]", with: "_", options: .regularExpression ) return resolvedAppSupport - .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("jmux", isDirectory: true) .appendingPathComponent("session-\(safeBundleId).json", isDirectory: false) } } diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 9524cf51ee..2f96c1b129 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -61,11 +61,11 @@ enum SocketControlMode: String, CaseIterable, Identifiable { } enum SocketControlPasswordStore { - static let directoryName = "cmux" + static let directoryName = "jmux" static let fileName = "socket-control-password" private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion" private static let keychainMigrationVersion = 1 - private static let legacyKeychainService = "com.cmuxterm.app.socket-control" + private static let legacyKeychainService = "com.jmux.app.socket-control" private static let legacyKeychainAccount = "local-socket-password" private struct LazyKeychainFallbackCache { var hasLoaded = false @@ -292,11 +292,11 @@ struct SocketControlSettings { static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" static let launchTagEnvKey = "CMUX_TAG" - static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug" - private static let socketDirectoryName = "cmux" + static let baseDebugBundleIdentifier = "com.jmux.app.debug" + private static let socketDirectoryName = "jmux" private static let stableSocketFileName = "cmux.sock" private static let lastSocketPathFileName = "last-socket-path" - static let legacyStableDefaultSocketPath = "/tmp/cmux.sock" + static let legacyStableDefaultSocketPath = "/tmp/jmux.sock" static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path" static var stableDefaultSocketPath: String { @@ -470,14 +470,14 @@ struct SocketControlSettings { if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) { return taggedDebugPath } - if bundleIdentifier == "com.cmuxterm.app.nightly" { + if bundleIdentifier == "com.jmux.app.nightly" { return "/tmp/cmux-nightly.sock" } if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild { - return "/tmp/cmux-debug.sock" + return "/tmp/jmux-debug.sock" } if isStagingBundleIdentifier(bundleIdentifier) { - return "/tmp/cmux-staging.sock" + return "/tmp/jmux-staging.sock" } return resolvedStableDefaultSocketPath( currentUserID: currentUserID, @@ -529,11 +529,11 @@ struct SocketControlSettings { static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } - return bundleIdentifier == "com.cmuxterm.app.debug" - || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") + return bundleIdentifier == "com.jmux.app.debug" + || bundleIdentifier.hasPrefix("com.jmux.app.debug.") } - /// Tagged DEV builds have bundle IDs like `com.cmuxterm.app.debug.`. + /// Tagged DEV builds have bundle IDs like `com.jmux.app.debug.`. static func isTaggedDevBuild(bundleIdentifier: String? = Bundle.main.bundleIdentifier) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") @@ -550,7 +550,7 @@ struct SocketControlSettings { .replacingOccurrences(of: ".", with: "-") .trimmingCharacters(in: CharacterSet(charactersIn: "-")) if !slug.isEmpty { - return "/tmp/cmux-debug-\(slug).sock" + return "/tmp/jmux-debug-\(slug).sock" } } @@ -567,13 +567,13 @@ struct SocketControlSettings { !tag.isEmpty else { return nil } - return "/tmp/cmux-debug-\(tag).sock" + return "/tmp/jmux-debug-\(tag).sock" } static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } - return bundleIdentifier == "com.cmuxterm.app.staging" - || bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.") + return bundleIdentifier == "com.jmux.app.staging" + || bundleIdentifier.hasPrefix("com.jmux.app.staging.") } static func stableSocketDirectoryURL(fileManager: FileManager = .default) -> URL? { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b7048fda3b..85cd33cf07 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3399,13 +3399,13 @@ class TabManager: ObservableObject { } private func windowTitle(for tab: Workspace?) -> String { - guard let tab else { return "cmux" } + guard let tab else { return "jmux" } let trimmedTitle = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedTitle.isEmpty { return trimmedTitle } let trimmedDirectory = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedDirectory.isEmpty ? "cmux" : trimmedDirectory + return trimmedDirectory.isEmpty ? "jmux" : trimmedDirectory } func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2bdc972a86..15acd9f134 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2084,6 +2084,20 @@ class TerminalController { case "workspace.remote.terminal_session_end": return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) + // Local daemon session management + case "session.local.status": + return v2Result(id: id, self.v2SessionLocalStatus(params: params)) + case "session.local.list": + return v2Result(id: id, self.v2SessionLocalList(params: params)) + case "session.local.new": + return v2Result(id: id, self.v2SessionLocalNew(params: params)) + case "session.local.attach": + return v2Result(id: id, self.v2SessionLocalAttach(params: params)) + case "session.local.detach": + return v2Result(id: id, self.v2SessionLocalDetach(params: params)) + case "session.local.close": + return v2Result(id: id, self.v2SessionLocalClose(params: params)) + // Settings case "settings.open": return v2Result(id: id, self.v2SettingsOpen(params: params)) @@ -4026,6 +4040,115 @@ class TerminalController { return result } + // MARK: - Local Daemon Session Commands + + // Socket command threading policy: session.local.* handlers run off-main. + // Parse/validate arguments off-main, call daemon RPC off-main, return directly. + // No v2MainSync needed — LocalDaemonManager static RPC methods are nonisolated. + + private func v2SessionLocalStatus(params _: [String: Any]) -> V2CallResult { + // Probe daemon readiness off-main via nonisolated static method. + let running = LocalDaemonManager.probeSync() + + if !running { + return .ok([ + "daemon_running": false, + "version": NSNull(), + ]) + } + + // Fetch version off-main. + let versionResponse = LocalDaemonManager.rpcSync(method: "hello") + let version = (versionResponse?["result"] as? [String: Any])?["version"] as? String + + return .ok([ + "daemon_running": true, + "version": version as Any, + ]) + } + + private func v2SessionLocalList(params _: [String: Any]) -> V2CallResult { + // List sessions off-main via nonisolated static method. + let sessions = LocalDaemonManager.listSessionsSync() + return .ok(["sessions": sessions]) + } + + private func v2SessionLocalNew(params: [String: Any]) -> V2CallResult { + let name = v2RawString(params, "name") + let shell = v2RawString(params, "shell") + let cols = v2StrictInt(params, "cols") + let rows = v2StrictInt(params, "rows") + + var rpcParams: [String: Any] = [:] + if let name { rpcParams["name"] = name } + if let shell { rpcParams["shell"] = shell } + if let cols { rpcParams["cols"] = cols } + if let rows { rpcParams["rows"] = rows } + + // RPC off-main via nonisolated static method. + let response = LocalDaemonManager.rpcSync(method: "session.new", params: rpcParams) + + guard let response, response["ok"] as? Bool == true else { + let message = (response?["error"] as? [String: Any])?["message"] as? String ?? "Failed to create session" + return .err(code: "daemon_error", message: message, data: nil) + } + + return .ok(response["result"] ?? [:]) + } + + private func v2SessionLocalAttach(params: [String: Any]) -> V2CallResult { + guard let sessionId = v2RawString(params, "session_id") else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) + } + + var rpcParams: [String: Any] = ["session_id": sessionId] + if let paneId = v2RawString(params, "pane_id") { + rpcParams["pane_id"] = paneId + } + + // RPC off-main via nonisolated static method. + let response = LocalDaemonManager.rpcSync(method: "session.attach", params: rpcParams) + + guard let response, response["ok"] as? Bool == true else { + let message = (response?["error"] as? [String: Any])?["message"] as? String ?? "Failed to attach to session" + return .err(code: "daemon_error", message: message, data: nil) + } + + return .ok(response["result"] ?? [:]) + } + + private func v2SessionLocalDetach(params: [String: Any]) -> V2CallResult { + guard let sessionId = v2RawString(params, "session_id") else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) + } + + // RPC off-main via nonisolated static method. + let response = LocalDaemonManager.rpcSync(method: "session.detach", params: ["session_id": sessionId]) + + guard let response, response["ok"] as? Bool == true else { + let message = (response?["error"] as? [String: Any])?["message"] as? String ?? "Failed to detach from session" + return .err(code: "daemon_error", message: message, data: nil) + } + + return .ok(response["result"] ?? [:]) + } + + private func v2SessionLocalClose(params: [String: Any]) -> V2CallResult { + guard let sessionId = v2RawString(params, "session_id") else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) + } + + // RPC off-main via nonisolated static method. + let response = LocalDaemonManager.rpcSync(method: "session.close", params: ["session_id": sessionId]) + + guard let response, response["ok"] as? Bool == true else { + let message = (response?["error"] as? [String: Any])?["message"] as? String ?? "Failed to close session" + return .err(code: "daemon_error", message: message, data: nil) + } + + return .ok(response["result"] ?? [:]) + } + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 9f3de14ab1..130a16bf16 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -10,7 +10,7 @@ import Bonsplit // freeze the UI. extension UNUserNotificationCenter { private static let removalQueue = DispatchQueue( - label: "com.cmuxterm.notification-removal", + label: "com.jmux.notification-removal", qos: .utility ) @@ -37,7 +37,7 @@ enum NotificationSoundSettings { static let defaultCustomFilePath = "" private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound" private static let customSoundPreparationQueue = DispatchQueue( - label: "com.cmuxterm.notification-sound-preparation", + label: "com.jmux.notification-sound-preparation", qos: .utility ) private static let pendingCustomSoundPreparationLock = NSLock() @@ -521,7 +521,7 @@ enum NotificationSoundSettings { } private static let customCommandQueue = DispatchQueue( - label: "com.cmuxterm.notification-custom-command", + label: "com.jmux.notification-custom-command", qos: .utility ) @@ -683,8 +683,8 @@ final class TerminalNotificationStore: ObservableObject { static let shared = TerminalNotificationStore() - static let categoryIdentifier = "com.cmuxterm.app.userNotification" - static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show" + static let categoryIdentifier = "com.jmux.app.userNotification" + static let actionShowIdentifier = "com.jmux.app.userNotification.show" private enum AuthorizationRequestOrigin: String { case notificationDelivery = "notification_delivery" case settingsButton = "settings_button" @@ -1118,7 +1118,7 @@ final class TerminalNotificationStore: ObservableObject { private func resolvedNotificationTitle(for notification: TerminalNotification) -> String { let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? "cmux" + ?? "jmux" return notification.title.isEmpty ? appName : notification.title } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1c85288617..9e7af6f970 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -305,7 +305,8 @@ extension Workspace { statusEntries: statusSnapshots, logEntries: logSnapshots, progress: progressSnapshot, - gitBranch: gitBranchSnapshot + gitBranch: gitBranchSnapshot, + daemonSessionID: daemonSessionID ) } @@ -354,6 +355,7 @@ extension Workspace { } progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + daemonSessionID = snapshot.daemonSessionID recomputeListeningPorts() @@ -1852,7 +1854,7 @@ enum RemoteLoopbackHTTPResponseRewriter { private final class WorkspaceRemoteDaemonProxyTunnel { private final class ProxySession { private static let maxHandshakeBytes = 64 * 1024 - private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + private static let remoteLoopbackProxyAliasHost = "jmux-loopback.localtest.me" private enum HandshakeProtocol { case undecided @@ -4171,7 +4173,7 @@ final class WorkspaceRemoteSessionController { create: true ) let cacheRoot = appSupportRoot - .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("jmux", isDirectory: true) .appendingPathComponent("remote-daemons", isDirectory: true) try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) return cacheRoot @@ -5582,6 +5584,136 @@ final class Workspace: Identifiable, ObservableObject { @Published var listeningPorts: [Int] = [] @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 var surfaceTTYNames: [UUID: String] = [:] + + /// Daemon session binding for persistent PTY (detach/reattach). + /// When set, the workspace's terminal is backed by a daemon-managed PTY + /// that survives workspace close and can be reattached on restore. + var daemonSessionBinding: DaemonSessionBinding? + + /// FIFO bridge that routes daemon PTY I/O through a ghostty terminal surface. + /// Created during reattach and torn down when the workspace closes. + private var daemonPTYBridge: DaemonPTYBridge? + + /// The daemon session ID associated with this workspace's terminal. + /// Persisted across app sessions to enable reattach after restart. + @Published var daemonSessionID: String? + + /// Attempt to reattach to a daemon session if we have a saved session ID. + /// Called after session restore on app startup once the daemon is confirmed running. + /// Async to avoid blocking the main thread with socket I/O. + /// + /// Data flow after successful reattach: + /// Output: daemon PTY → DaemonSessionBinding.readLoop → outputHandler + /// → DaemonPTYBridge.writeOutput → output FIFO → `cat` → ghostty renderer + /// Input: ghostty key events → surface child stdin → `cat >` → input FIFO + /// → DaemonPTYBridge.inputReadLoop → DaemonSessionBinding.sendInput → daemon PTY + func reattachDaemonSessionIfNeeded() async { + guard let sessionID = daemonSessionID else { return } + guard daemonSessionBinding == nil else { return } + + let manager = LocalDaemonManager.shared + guard manager.isRunning else { + // Daemon not available — clear stale session ID. + daemonSessionID = nil + return + } + + // Check if the session still exists in the daemon (off-main). + let sessions = await manager.listSessionsAsync() + let sessionExists = sessions.contains { ($0["session_id"] as? String) == sessionID } + + guard sessionExists else { + // Session gone (daemon restarted or session was killed). + daemonSessionID = nil + return + } + + // Use default terminal size; the surface will send a resize once it renders. + let cols = 80 + let rows = 24 + + // Create the binding first (without output handler yet — we set it after creating the bridge). + let bridge: DaemonPTYBridge + let binding: DaemonSessionBinding + + // Create binding with bridge's output handler wired up. + // The bridge and binding reference each other: the binding's output handler writes to the + // bridge, and the bridge's input reader calls binding.sendInput(). + var capturedBridge: DaemonPTYBridge? + binding = DaemonSessionBinding( + sessionID: sessionID, + sessionName: sessionID, + outputHandler: { data in + capturedBridge?.writeOutput(data) + } + ) + do { + bridge = try DaemonPTYBridge(sessionID: sessionID, binding: binding) + } catch { + #if DEBUG + dlog("Failed to create DaemonPTYBridge: \(error)") + #endif + daemonSessionID = nil + return + } + capturedBridge = bridge + + // Identify the target pane and existing terminal panels BEFORE closing anything. + // Closing all panels in a pane can cause bonsplit to remove the pane entirely. + guard let paneId = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first else { + daemonSessionID = nil + bridge.teardown() + return + } + + let existingTerminalPanelIds = panels.values + .compactMap { $0 as? TerminalPanel } + .map(\.id) + + // Create the daemon-bridged terminal panel first, then close the old panels. + // This spawns `/bin/sh -c 'cat > input_fifo & cat output_fifo'` instead of + // the user's default shell. + guard let daemonPanel = newTerminalSurface( + inPane: paneId, + focus: true, + daemonBridgeCommand: bridge.surfaceCommand + ) else { + daemonSessionID = nil + bridge.teardown() + return + } + + // Close the old session-restored terminal panels (which have local shells). + for panelId in existingTerminalPanelIds { + _ = closePanel(panelId, force: true) + } + + // Activate the bridge (opens FIFOs) off-main, then attach to the daemon. + // Order matters: activate() must complete before attach() so the bridge + // can receive pty.replay data immediately. activate() blocks until the + // surface's child process (`cat`) opens the FIFO endpoints. + let attachResult: Bool = await Task.detached { + bridge.activate() + do { + try binding.attach(cols: cols, rows: rows) + return true + } catch { + return false + } + }.value + + guard attachResult else { + daemonSessionID = nil + bridge.teardown() + // Close the daemon-bridged panel since we failed to attach. + _ = closePanel(daemonPanel.id, force: true) + return + } + + daemonSessionBinding = binding + daemonPTYBridge = bridge + } + private var remoteSessionController: WorkspaceRemoteSessionController? fileprivate var activeRemoteSessionControllerID: UUID? private var remoteLastErrorFingerprint: String? @@ -6546,7 +6678,8 @@ final class Workspace: Identifiable, ObservableObject { } func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { - Self.resolveCloseConfirmation( + guard CloseTabWarningSettings.isEnabled() else { return false } + return Self.resolveCloseConfirmation( shellActivityState: panelShellActivityStates[panelId], fallbackNeedsConfirmClose: fallbackNeedsConfirmClose ) @@ -7715,14 +7848,15 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, - startupEnvironment: [String: String] = [:] + startupEnvironment: [String: String] = [:], + daemonBridgeCommand: String? = nil ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + let resolvedCommand = daemonBridgeCommand ?? remoteTerminalStartupCommand() // Create new terminal panel let newPanel = TerminalPanel( @@ -7731,13 +7865,14 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, workingDirectory: workingDirectory, portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand, + initialCommand: resolvedCommand, additionalEnvironment: startupEnvironment ) configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - if remoteTerminalStartupCommand != nil { + let isRemoteTerminal = daemonBridgeCommand == nil && resolvedCommand != nil + if isRemoteTerminal { trackRemoteTerminalSurface(newPanel.id) } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -7753,7 +7888,7 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) - if remoteTerminalStartupCommand != nil { + if isRemoteTerminal { untrackRemoteTerminalSurface(newPanel.id) } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) @@ -8059,12 +8194,32 @@ final class Workspace: Identifiable, ObservableObject { /// Tear down all panels in this workspace, freeing their Ghostty surfaces. /// Called before the workspace is removed from TabManager to ensure child /// processes receive SIGHUP even if ARC deallocation is delayed. + /// + /// When a daemon session binding is active, terminal panels are detached + /// (PTY keeps running in the daemon) rather than killed. func teardownAllPanels() { + // If we have a daemon session, tear down the FIFO bridge and detach from + // the daemon. The daemon keeps the PTY alive for later reattach. + // Bridge teardown must happen before binding detach so the surface's child + // process (`cat`) sees EOF and exits cleanly before we close the socket. + daemonPTYBridge?.teardown() + daemonPTYBridge = nil + if let binding = daemonSessionBinding { + binding.detach() + daemonSessionBinding = nil + } + let panelEntries = Array(panels) for (panelId, panel) in panelEntries { panelSubscriptions.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - panel.close() + // When a daemon session is active, use detach-aware close for terminal panels + // so the PTY continues in the daemon. Non-terminal panels close normally. + if daemonSessionID != nil, let terminalPanel = panel as? TerminalPanel { + terminalPanel.detachFromDaemon() + } else { + panel.close() + } } panels.removeAll(keepingCapacity: false) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 2f57b3c5b6..92f2e7928e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2833,7 +2833,7 @@ private struct AboutPanelView: View { VStack(alignment: .center, spacing: 32) { VStack(alignment: .center, spacing: 8) { - Text(String(localized: "about.appName", defaultValue: "cmux")) + Text(String(localized: "about.appName", defaultValue: "jmux")) .bold() .font(.title) Text(String(localized: "about.description", defaultValue: "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")) @@ -3802,7 +3802,7 @@ enum AppIconMode: String, CaseIterable, Identifiable { enum AppIconSettings { static let modeKey = "appIconMode" static let defaultMode: AppIconMode = .automatic - private static let dockTileIconDidChangeNotification = Notification.Name("com.cmuxterm.appIconDidChange") + private static let dockTileIconDidChangeNotification = Notification.Name("com.jmux.appIconDidChange") struct Environment { let imageForMode: (AppIconMode) -> NSImage? @@ -3911,6 +3911,22 @@ enum QuitWarningSettings { } } +enum CloseTabWarningSettings { + static let warnBeforeCloseTabKey = "warnBeforeCloseTab" + static let defaultWarnBeforeCloseTab = false + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: warnBeforeCloseTabKey) == nil { + return defaultWarnBeforeCloseTab + } + return defaults.bool(forKey: warnBeforeCloseTabKey) + } + + static func setEnabled(_ isEnabled: Bool, defaults: UserDefaults = .standard) { + defaults.set(isEnabled, forKey: warnBeforeCloseTabKey) + } +} + enum CommandPaletteRenameSelectionSettings { static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" static let defaultSelectAllOnFocus = true @@ -4060,6 +4076,7 @@ struct SettingsView: View { @AppStorage(NotificationPaneFlashSettings.enabledKey) private var notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled @AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(CloseTabWarningSettings.warnBeforeCloseTabKey) private var warnBeforeCloseTab = CloseTabWarningSettings.defaultWarnBeforeCloseTab @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) @@ -4898,6 +4915,19 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.warnBeforeCloseTab", defaultValue: "Warn Before Closing Tab"), + subtitle: warnBeforeCloseTab + ? String(localized: "settings.app.warnBeforeCloseTab.subtitleOn", defaultValue: "Show a confirmation before closing a tab if it requires confirmation.") + : String(localized: "settings.app.warnBeforeCloseTab.subtitleOff", defaultValue: "Close tabs immediately without confirmation.") + ) { + Toggle("", isOn: $warnBeforeCloseTab) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), subtitle: warnBeforeQuitShortcut @@ -6012,6 +6042,7 @@ struct SettingsView: View { notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + warnBeforeCloseTab = CloseTabWarningSettings.defaultWarnBeforeCloseTab commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces ShortcutHintDebugSettings.resetVisibilityDefaults() diff --git a/cmuxTests/DaemonSessionTests.swift b/cmuxTests/DaemonSessionTests.swift new file mode 100644 index 0000000000..af6e59617d --- /dev/null +++ b/cmuxTests/DaemonSessionTests.swift @@ -0,0 +1,598 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +// MARK: - LocalDaemonManager Path Computation Tests + +final class LocalDaemonManagerPathTests: XCTestCase { + func testStateDirIsUnderHomeDotLocalState() { + let stateDir = LocalDaemonManager.stateDir + let home = NSHomeDirectory() + XCTAssertEqual(stateDir, home + "/.local/state/cmux") + } + + func testSocketPathIsInsideStateDir() { + let socketPath = LocalDaemonManager.socketPath + let stateDir = LocalDaemonManager.stateDir + XCTAssertTrue(socketPath.hasPrefix(stateDir + "/")) + XCTAssertTrue(socketPath.hasSuffix(".sock")) + } + + func testLegacySocketPathContainsUID() { + let legacyPath = LocalDaemonManager.legacySocketPath + let uid = getuid() + XCTAssertTrue(legacyPath.hasPrefix("/tmp/")) + XCTAssertTrue(legacyPath.contains("\(uid)")) + XCTAssertTrue(legacyPath.hasSuffix(".sock")) + } + + func testLaunchAgentPlistPathIsUnderHomeLibraryLaunchAgents() { + let plistPath = LocalDaemonManager.launchAgentPlistPath + let home = NSHomeDirectory() + XCTAssertTrue(plistPath.hasPrefix(home + "/Library/LaunchAgents/")) + XCTAssertTrue(plistPath.hasSuffix(".plist")) + } + + func testResolvedSocketPathFallsThroughToPrimaryWhenNeitherExists() { + // When neither primary nor legacy socket exists on disk, + // resolvedSocketPath should return the primary path. + // (In a test environment, neither daemon socket should exist.) + let resolved = LocalDaemonManager.resolvedSocketPath + let primary = LocalDaemonManager.socketPath + let legacy = LocalDaemonManager.legacySocketPath + + // If neither file exists, resolved should equal primary. + let fm = FileManager.default + if !fm.fileExists(atPath: primary) && !fm.fileExists(atPath: legacy) { + XCTAssertEqual(resolved, primary) + } else if fm.fileExists(atPath: primary) { + XCTAssertEqual(resolved, primary) + } else { + XCTAssertEqual(resolved, legacy) + } + } + + func testSocketPathAndLegacyPathAreDifferent() { + // Primary and legacy should never collide. + XCTAssertNotEqual(LocalDaemonManager.socketPath, LocalDaemonManager.legacySocketPath) + } +} + +// MARK: - LaunchAgentError Tests + +final class LaunchAgentErrorTests: XCTestCase { + func testErrorDescriptionReturnsAssociatedMessage() { + let cases: [(LaunchAgentError, String)] = [ + (.binaryNotFound("not found"), "not found"), + (.templateNotFound("no template"), "no template"), + (.writeFailed("write error"), "write error"), + (.bootstrapFailed("bootstrap error"), "bootstrap error"), + ] + + for (error, expected) in cases { + XCTAssertEqual(error.errorDescription, expected) + } + } +} + +// MARK: - DaemonSessionError Tests + +final class DaemonSessionErrorTests: XCTestCase { + func testAllCasesHaveNonEmptyDescriptions() { + let errors: [DaemonSessionError] = [ + .socketCreateFailed, + .socketPathTooLong, + .connectFailed(ECONNREFUSED), + .serializationFailed, + .writeFailed, + .readFailed, + .parseFailed, + .attachFailed("timeout"), + ] + + for error in errors { + let description = error.errorDescription + XCTAssertNotNil(description) + XCTAssertFalse(description?.isEmpty ?? true, "Error \(error) should have a non-empty description") + } + } + + func testConnectFailedIncludesErrno() { + let error = DaemonSessionError.connectFailed(61) + XCTAssertTrue(error.errorDescription?.contains("61") ?? false) + } + + func testAttachFailedIncludesMessage() { + let error = DaemonSessionError.attachFailed("session not found") + XCTAssertTrue(error.errorDescription?.contains("session not found") ?? false) + } +} + +// MARK: - DaemonBridgeError Tests + +final class DaemonBridgeErrorTests: XCTestCase { + func testMkfifoFailedIncludesPathAndErrno() { + let error = DaemonBridgeError.mkfifoFailed("/tmp/test.fifo", 13) + let description = error.errorDescription ?? "" + XCTAssertTrue(description.contains("/tmp/test.fifo")) + XCTAssertTrue(description.contains("13")) + } + + func testFifoDirectoryCreationFailedHasDescription() { + let error = DaemonBridgeError.fifoDirectoryCreationFailed + XCTAssertNotNil(error.errorDescription) + XCTAssertFalse(error.errorDescription?.isEmpty ?? true) + } +} + +// MARK: - DaemonSessionBinding Tests + +final class DaemonSessionBindingTests: XCTestCase { + func testInitStoresSessionIDAndName() { + let binding = DaemonSessionBinding( + sessionID: "abc-123", + sessionName: "my-session", + outputHandler: { _ in } + ) + + XCTAssertEqual(binding.sessionID, "abc-123") + XCTAssertEqual(binding.sessionName, "my-session") + } + + func testAllocateRPCIDIsMonotonicallyIncreasing() { + let binding = DaemonSessionBinding( + sessionID: "test", + sessionName: "test", + outputHandler: { _ in } + ) + + let id1 = binding.allocateRPCID() + let id2 = binding.allocateRPCID() + let id3 = binding.allocateRPCID() + + XCTAssertEqual(id1, 1) + XCTAssertEqual(id2, 2) + XCTAssertEqual(id3, 3) + } + + func testAllocateRPCIDStartsFromOneOnFreshBinding() { + let binding = DaemonSessionBinding( + sessionID: "fresh", + sessionName: "fresh", + outputHandler: { _ in } + ) + + XCTAssertEqual(binding.allocateRPCID(), 1) + } + + func testAllocateRPCIDIsThreadSafe() { + let binding = DaemonSessionBinding( + sessionID: "concurrent", + sessionName: "concurrent", + outputHandler: { _ in } + ) + + let iterations = 100 + let expectation = self.expectation(description: "all IDs allocated") + expectation.expectedFulfillmentCount = iterations + + var collectedIDs = [Int]() + let lock = NSLock() + + for _ in 0..")) + XCTAssertTrue(command.contains("cat ")) + XCTAssertTrue(command.contains("&")) + } + + func testSurfaceCommandShellEscapesSingleQuotes() throws { + let binding = DaemonSessionBinding( + sessionID: "escape-test", + sessionName: "test", + outputHandler: { _ in } + ) + + let bridge = try DaemonPTYBridge(sessionID: "escape-test", binding: binding) + defer { bridge.teardown() } + + // The shellEscape method should handle single quotes properly. + let escaped = bridge.shellEscape("it's a test") + XCTAssertEqual(escaped, "it'\\''s a test") + } + + func testShellEscapeNoOpForSafePaths() throws { + let binding = DaemonSessionBinding( + sessionID: "safe-path-test", + sessionName: "test", + outputHandler: { _ in } + ) + + let bridge = try DaemonPTYBridge(sessionID: "safe-path-test", binding: binding) + defer { bridge.teardown() } + + let safePath = "/tmp/cmux-501/bridge-abc123/out.fifo" + XCTAssertEqual(bridge.shellEscape(safePath), safePath) + } + + func testBridgeSessionIDIsTruncatedToTwelveCharacters() throws { + let binding = DaemonSessionBinding( + sessionID: "abcdefghijklmnopqrstuvwxyz", + sessionName: "test", + outputHandler: { _ in } + ) + + let bridge = try DaemonPTYBridge( + sessionID: "abcdefghijklmnopqrstuvwxyz", + binding: binding + ) + defer { bridge.teardown() } + + // The FIFO directory should contain the first 12 characters of the sanitized ID. + let dirPath = (bridge.outputFIFOPath as NSString).deletingLastPathComponent + let dirName = (dirPath as NSString).lastPathComponent + XCTAssertTrue(dirName.hasPrefix("bridge-abcdefghijkl-"), + "Directory name should use truncated session ID: \(dirName)") + // Should NOT contain characters beyond position 12. + XCTAssertFalse(dirName.contains("mnop"), + "Directory name should not contain characters past 12: \(dirName)") + } +} + +// MARK: - Session Persistence Daemon ID Tests + +final class SessionPersistenceDaemonTests: XCTestCase { + func testDaemonSessionIDRoundTripSerializesCorrectly() throws { + let workspace = SessionWorkspaceSnapshot( + processTitle: "Terminal", + customTitle: nil, + customColor: nil, + isPinned: false, + currentDirectory: "/tmp", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil, + daemonSessionID: "daemon-abc-123" + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(workspace) + + let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) + XCTAssertEqual(decoded.daemonSessionID, "daemon-abc-123") + } + + func testDaemonSessionIDNilRoundTrip() throws { + let workspace = SessionWorkspaceSnapshot( + processTitle: "Terminal", + customTitle: nil, + customColor: nil, + isPinned: false, + currentDirectory: "/tmp", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil, + daemonSessionID: nil + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(workspace) + + let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) + XCTAssertNil(decoded.daemonSessionID) + } + + func testDaemonSessionIDDecodesFromLegacyJSONWithoutField() throws { + // Simulate JSON from before daemonSessionID was added — the field should + // decode as nil without errors. + let json = """ + { + "processTitle": "Terminal", + "isPinned": false, + "currentDirectory": "/tmp", + "layout": { + "type": "pane", + "pane": { "panelIds": [], "selectedPanelId": null } + }, + "panels": [], + "statusEntries": [], + "logEntries": [] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: json) + XCTAssertNil(decoded.daemonSessionID) + XCTAssertEqual(decoded.processTitle, "Terminal") + } + + func testFullSnapshotRoundTripWithDaemonSessionID() throws { + let workspace = SessionWorkspaceSnapshot( + processTitle: "zsh", + customTitle: "Dev", + customColor: "#FF0000", + isPinned: true, + currentDirectory: "/Users/test", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil, + daemonSessionID: "sess-42" + ) + + let tabManager = SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ) + + let window = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 0, y: 0, width: 800, height: 600), + display: nil, + tabManager: tabManager, + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 200) + ) + + let snapshot = AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: [window] + ) + + // Write to temp file and reload. + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-daemon-session-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("session.json") + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: fileURL)) + + let loaded = SessionPersistenceStore.load(fileURL: fileURL) + XCTAssertNotNil(loaded) + XCTAssertEqual( + loaded?.windows.first?.tabManager.workspaces.first?.daemonSessionID, + "sess-42" + ) + XCTAssertEqual( + loaded?.windows.first?.tabManager.workspaces.first?.customTitle, + "Dev" + ) + } +} diff --git a/daemon/local/README.md b/daemon/local/README.md new file mode 100644 index 0000000000..ba972daa09 --- /dev/null +++ b/daemon/local/README.md @@ -0,0 +1,83 @@ +# cmux local daemon + +PTY-based detach/reattach daemon, similar to tmux. Manages local terminal sessions +that persist when clients disconnect. + +## Build + +```bash +go build -o cmuxd-local ./cmd/cmuxd-local/ +go build -o cmux-local ./cmd/cmux-local/ +``` + +## Usage + +Start the daemon: + +```bash +./cmuxd-local +``` + +The daemon listens on `~/.local/state/cmux/daemon-local.sock`. A backward-compatible +symlink is created at `/tmp/cmux-local-.sock` for older clients. + +Create a session and attach: + +```bash +./cmux-local new -s dev +``` + +Detach with `Ctrl-b d`. + +List sessions: + +```bash +./cmux-local ls +``` + +Reattach: + +```bash +./cmux-local attach -t dev +``` + +Kill a session: + +```bash +./cmux-local kill -t dev +``` + +## Protocol + +Uses newline-delimited JSON-RPC over a Unix domain socket. Same framing as the +remote daemon. + +### RPC methods + +| Method | Description | +|------------------|--------------------------------------| +| `hello` | Handshake, return capabilities | +| `ping` | Health check | +| `session.new` | Create a new PTY session | +| `session.list` | List all sessions | +| `session.attach` | Attach to a session (starts streaming) | +| `session.detach` | Detach from a session | +| `session.resize` | Resize a session's PTY | +| `session.close` | Kill a session | +| `pty.input` | Send keyboard input to a session | + +### Events (server push) + +| Event | Description | +|------------------|--------------------------------------| +| `pty.replay` | Ring buffer replay on attach | +| `pty.output` | Live PTY output | +| `session.exited` | Child process exited | + +## Architecture + +- `session.go` — Session and PTY lifecycle management via `github.com/creack/pty` +- `ringbuffer.go` — Fixed-size circular buffer (2MB) for output replay +- `rpc.go` — JSON-RPC framing, helpers shared by daemon and library consumers +- `cmd/cmuxd-local/` — Daemon server binary +- `cmd/cmux-local/` — CLI client binary diff --git a/daemon/local/cmd/cmux-local/main.go b/daemon/local/cmd/cmux-local/main.go new file mode 100644 index 0000000000..3eaa4ea17d --- /dev/null +++ b/daemon/local/cmd/cmux-local/main.go @@ -0,0 +1,523 @@ +package main + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + local "github.com/manaflow-ai/cmux/daemon/local" + "golang.org/x/term" +) + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + if len(args) == 0 { + usage() + return 2 + } + + switch args[0] { + case "new": + return cmdNew(args[1:]) + case "attach": + return cmdAttach(args[1:]) + case "ls", "list": + return cmdList(args[1:]) + case "kill": + return cmdKill(args[1:]) + case "help", "--help", "-h": + usage() + return 0 + default: + fmt.Fprintf(os.Stderr, "cmux-local: unknown command %q\n", args[0]) + usage() + return 2 + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "Usage: cmux-local [options]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Commands:") + fmt.Fprintln(os.Stderr, " new [-s name] [-- command args...] Create a new session and attach") + fmt.Fprintln(os.Stderr, " attach [-t name|id] Attach to an existing session") + fmt.Fprintln(os.Stderr, " ls List all sessions") + fmt.Fprintln(os.Stderr, " kill [-t name|id] Kill a session") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Detach hotkey: Ctrl-b d") +} + +// --- RPC types (mirrors the daemon's protocol) --- + +type rpcRequest struct { + ID any `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +type rpcResponse struct { + ID any `json:"id,omitempty"` + OK bool `json:"ok"` + Result map[string]any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type rpcEvent struct { + Event string `json:"event"` + SessionID string `json:"session_id,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + ReplayDone bool `json:"replay_done,omitempty"` + Error string `json:"error,omitempty"` +} + +// --- Socket helpers --- + +func socketPath() string { + if p := os.Getenv("CMUX_LOCAL_SOCKET"); p != "" { + return p + } + // Primary: private socket in ~/.local/state/cmux/ + primary := local.DefaultSocketPath() + if _, err := os.Stat(primary); err == nil { + return primary + } + // Fallback: legacy /tmp path (may be a symlink to the new location) + legacy := local.LegacySocketPath() + if _, err := os.Stat(legacy); err == nil { + return legacy + } + // Default to primary even if it doesn't exist yet (daemon may start later) + return primary +} + +func dialDaemon() (net.Conn, error) { + return net.DialTimeout("unix", socketPath(), 2*time.Second) +} + +var nextReqID int + +func sendRPC(conn net.Conn, method string, params map[string]any) error { + nextReqID++ + req := rpcRequest{ + ID: nextReqID, + Method: method, + Params: params, + } + data, err := json.Marshal(req) + if err != nil { + return err + } + _, err = conn.Write(append(data, '\n')) + return err +} + +// readOneJSON reads a single newline-delimited JSON object from the reader +// and unmarshals it into dst. +func readOneJSON(reader *bufio.Reader, dst any) error { + line, err := reader.ReadString('\n') + if err != nil { + return err + } + return json.Unmarshal([]byte(line), dst) +} + +// rpcCall does a single request-response round trip. +func rpcCall(method string, params map[string]any) (map[string]any, error) { + conn, err := dialDaemon() + if err != nil { + return nil, fmt.Errorf("cannot connect to daemon at %s: %w", socketPath(), err) + } + defer conn.Close() + + if err := sendRPC(conn, method, params); err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + reader := bufio.NewReader(conn) + var resp rpcResponse + if err := readOneJSON(reader, &resp); err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if !resp.OK { + if resp.Error != nil { + return nil, fmt.Errorf("server error [%s]: %s", resp.Error.Code, resp.Error.Message) + } + return nil, fmt.Errorf("server returned error") + } + return resp.Result, nil +} + +// --- Commands --- + +func cmdNew(args []string) int { + var name string + var shell string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-s", "--name": + if i+1 < len(args) { + name = args[i+1] + i++ + } + case "--shell": + if i+1 < len(args) { + shell = args[i+1] + i++ + } + case "--": + // Remaining args become the shell command + if i+1 < len(args) { + shell = strings.Join(args[i+1:], " ") + } + goto done + } + } +done: + + cols, rows := terminalSize() + + params := map[string]any{ + "cols": cols, + "rows": rows, + } + if name != "" { + params["name"] = name + } + if shell != "" { + params["shell"] = shell + } + + result, err := rpcCall("session.new", params) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux-local: %v\n", err) + return 1 + } + + sessionID, _ := result["session_id"].(string) + if sessionID == "" { + fmt.Fprintln(os.Stderr, "cmux-local: session created but no session_id returned") + return 1 + } + + fmt.Fprintf(os.Stderr, "[session %s created]\n", sessionID) + return attachToSession(sessionID, cols, rows) +} + +func cmdAttach(args []string) int { + var target string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-t", "--target": + if i+1 < len(args) { + target = args[i+1] + i++ + } + default: + if target == "" { + target = args[i] + } + } + } + + if target == "" { + fmt.Fprintln(os.Stderr, "cmux-local attach: requires -t ") + return 2 + } + + cols, rows := terminalSize() + return attachToSession(target, cols, rows) +} + +func cmdList(args []string) int { + result, err := rpcCall("session.list", nil) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux-local: %v\n", err) + return 1 + } + + sessionsRaw, ok := result["sessions"] + if !ok { + fmt.Println("No sessions.") + return 0 + } + + sessions, ok := sessionsRaw.([]any) + if !ok || len(sessions) == 0 { + fmt.Println("No sessions.") + return 0 + } + + for _, raw := range sessions { + s, ok := raw.(map[string]any) + if !ok { + continue + } + id, _ := s["session_id"].(string) + name, _ := s["name"].(string) + status, _ := s["status"].(string) + pid := jsonInt(s["pid"]) + attached := jsonInt(s["attached"]) + createdAt, _ := s["created_at"].(string) + windowCount := jsonInt(s["window_count"]) + paneCount := jsonInt(s["pane_count"]) + + attachStr := "detached" + if attached > 0 { + attachStr = fmt.Sprintf("attached (%d)", attached) + } + + fmt.Printf("%-12s %-16s %-10s pid=%-8d %dW/%dP %s %s\n", + id, name, status, pid, windowCount, paneCount, attachStr, createdAt) + } + return 0 +} + +func cmdKill(args []string) int { + var target string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-t", "--target": + if i+1 < len(args) { + target = args[i+1] + i++ + } + default: + if target == "" { + target = args[i] + } + } + } + + if target == "" { + fmt.Fprintln(os.Stderr, "cmux-local kill: requires -t ") + return 2 + } + + _, err := rpcCall("session.close", map[string]any{"session_id": target}) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux-local: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "[session %s killed]\n", target) + return 0 +} + +// --- Attach mode --- + +func attachToSession(sessionID string, cols, rows int) int { + conn, err := dialDaemon() + if err != nil { + fmt.Fprintf(os.Stderr, "cmux-local: cannot connect to daemon: %v\n", err) + return 1 + } + defer conn.Close() + + // Send session.attach + if err := sendRPC(conn, "session.attach", map[string]any{ + "session_id": sessionID, + "cols": cols, + "rows": rows, + }); err != nil { + fmt.Fprintf(os.Stderr, "cmux-local: failed to send attach: %v\n", err) + return 1 + } + + reader := bufio.NewReader(conn) + + // Read attach response + var resp rpcResponse + if err := readOneJSON(reader, &resp); err != nil { + fmt.Fprintf(os.Stderr, "cmux-local: failed to read attach response: %v\n", err) + return 1 + } + if !resp.OK { + if resp.Error != nil { + fmt.Fprintf(os.Stderr, "cmux-local: attach failed [%s]: %s\n", resp.Error.Code, resp.Error.Message) + } else { + fmt.Fprintln(os.Stderr, "cmux-local: attach failed") + } + return 1 + } + + // Resolve actual session_id from response (in case we attached by name) + if result := resp.Result; result != nil { + if sid, ok := result["session_id"].(string); ok { + sessionID = sid + } + } + + // Set terminal to raw mode + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux-local: failed to set raw mode: %v\n", err) + return 1 + } + defer term.Restore(int(os.Stdin.Fd()), oldState) + + // Mutex to serialize concurrent writes to conn (SIGWINCH + stdin goroutines). + var connMu sync.Mutex + + // Channel to signal detach or session exit (buffered for all senders). + doneCh := make(chan int, 3) + + // Read events from daemon (pty.replay, pty.output, session.exited) + go func() { + for { + line, err := reader.ReadString('\n') + if err != nil { + doneCh <- 1 + return + } + + // Try to parse as event + var event rpcEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + // Might be an RPC response (e.g., for pty.input); ignore + continue + } + + switch event.Event { + case "pty.replay", "pty.output": + if event.DataBase64 != "" { + data, err := base64.StdEncoding.DecodeString(event.DataBase64) + if err == nil { + os.Stdout.Write(data) + } + } + case "session.exited": + fmt.Fprintf(os.Stderr, "\r\n[session %s exited]\r\n", sessionID) + doneCh <- 0 + return + } + } + }() + + // Handle SIGWINCH + winchCh := make(chan os.Signal, 1) + signal.Notify(winchCh, syscall.SIGWINCH) + go func() { + for range winchCh { + c, r := terminalSize() + connMu.Lock() + _ = sendRPC(conn, "session.resize", map[string]any{ + "session_id": sessionID, + "cols": c, + "rows": r, + }) + connMu.Unlock() + } + }() + defer signal.Stop(winchCh) + + // Read stdin and send as pty.input; detect Ctrl-b d for detach + go func() { + buf := make([]byte, 4096) + var pendingCtrlB bool // true when previous read ended with Ctrl-b + + for { + n, err := os.Stdin.Read(buf) + if err != nil { + doneCh <- 1 + return + } + + data := buf[:n] + + // Build the output to send, handling the Ctrl-b d detach sequence. + // We scan byte-by-byte, deferring any trailing Ctrl-b. + var toSend []byte + hadPending := pendingCtrlB + pendingCtrlB = false + + for i := 0; i < len(data); i++ { + b := data[i] + if hadPending { + hadPending = false + if b == 'd' { + // Detach hotkey: Ctrl-b d + // Send any accumulated bytes first + connMu.Lock() + if len(toSend) > 0 { + _ = sendRPC(conn, "pty.input", map[string]any{ + "session_id": sessionID, + "data_base64": base64.StdEncoding.EncodeToString(toSend), + }) + } + _ = sendRPC(conn, "session.detach", map[string]any{ + "session_id": sessionID, + }) + connMu.Unlock() + doneCh <- 0 + return + } + // Not 'd' — flush the deferred Ctrl-b + toSend = append(toSend, 0x02) + } + + if b == 0x02 { + // Defer this Ctrl-b — it might be the start of Ctrl-b d + hadPending = true + continue + } + toSend = append(toSend, b) + } + + // If the buffer ended with a Ctrl-b, defer it to the next read + pendingCtrlB = hadPending + + if len(toSend) > 0 { + connMu.Lock() + _ = sendRPC(conn, "pty.input", map[string]any{ + "session_id": sessionID, + "data_base64": base64.StdEncoding.EncodeToString(toSend), + }) + connMu.Unlock() + } + } + }() + + code := <-doneCh + + // Restore terminal before printing + term.Restore(int(os.Stdin.Fd()), oldState) + + if code == 0 { + fmt.Fprintf(os.Stderr, "[detached from %s]\n", sessionID) + } + return code +} + +func terminalSize() (int, int) { + w, h, err := term.GetSize(int(os.Stdin.Fd())) + if err != nil { + return 80, 24 + } + return w, h +} + +func jsonInt(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + default: + return 0 + } +} diff --git a/daemon/local/cmd/cmuxd-local/main.go b/daemon/local/cmd/cmuxd-local/main.go new file mode 100644 index 0000000000..5a4d42abf5 --- /dev/null +++ b/daemon/local/cmd/cmuxd-local/main.go @@ -0,0 +1,695 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "syscall" + + local "github.com/manaflow-ai/cmux/daemon/local" +) + +var version = "dev" + +func main() { + os.Exit(run()) +} + +func run() int { + // Ensure the private state directory exists with 0700 permissions. + if _, err := local.EnsureStateDir(); err != nil { + fmt.Fprintf(os.Stderr, "cmuxd-local: %v\n", err) + return 1 + } + + socketPath := defaultSocketPath() + legacyPath := local.LegacySocketPath() + + // Clean up stale socket + if _, err := os.Stat(socketPath); err == nil { + // Try connecting — if it works, another daemon is running + conn, err := net.Dial("unix", socketPath) + if err == nil { + conn.Close() + fmt.Fprintf(os.Stderr, "cmuxd-local: another daemon is already running at %s\n", socketPath) + return 1 + } + // Stale socket, remove it + _ = os.Remove(socketPath) + } + + listener, err := net.Listen("unix", socketPath) + if err != nil { + fmt.Fprintf(os.Stderr, "cmuxd-local: failed to listen on %s: %v\n", socketPath, err) + return 1 + } + defer listener.Close() + defer os.Remove(socketPath) + + // Make socket accessible only to current user (owner read+write only) + _ = os.Chmod(socketPath, 0600) + + // Create a backward-compatible symlink at the old /tmp path so older + // clients can still connect during the transition period. + _ = os.Remove(legacyPath) // remove stale socket or symlink + if err := os.Symlink(socketPath, legacyPath); err != nil { + log.Printf("warning: could not create legacy symlink at %s: %v", legacyPath, err) + } else { + defer os.Remove(legacyPath) + } + + sm := local.NewSessionManager() + defer sm.CloseAll() + + // --- State persistence --- + statePath := local.DefaultStatePath() + persister := local.NewStatePersister(sm, statePath) + + // Attempt to restore sessions from a previous daemon run. + if savedState, err := local.LoadState(statePath); err != nil { + log.Printf("warning: could not load persisted state: %v", err) + } else if savedState != nil { + // Only restore if the previous daemon is no longer running. + if local.CheckStalePID(savedState) { + log.Printf("previous daemon (pid %d) is still running, skipping restore", savedState.PID) + } else { + result := sm.RestoreSessions(savedState) + if result.SessionCount > 0 { + log.Printf("restored %d session(s), %d window(s), %d pane(s) from %s", + result.SessionCount, result.WindowCount, result.PaneCount, statePath) + } + for _, e := range result.Errors { + log.Printf("restore warning: %s", e) + } + } + } + + persister.Start() + defer persister.Stop() + + log.Printf("cmuxd-local %s listening on %s", version, socketPath) + + // Handle shutdown signals by closing the listener, which breaks the accept + // loop and lets deferred cleanup (sm.CloseAll, listener.Close, os.Remove) run. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Println("shutting down...") + listener.Close() + }() + + for { + conn, err := listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return 0 + } + log.Printf("accept error: %v", err) + continue + } + go handleClient(conn, sm, persister) + } +} + +func defaultSocketPath() string { + return local.DefaultSocketPath() +} + +// clientState tracks per-connection state for a single client. +type clientState struct { + conn net.Conn + reader *bufio.Reader + writer *local.FrameWriter + sm *local.SessionManager + persister *local.StatePersister + attachmentID string + attachedSess *local.Session // non-nil while attached + attachedPane *local.Pane // non-nil while attached to a specific pane +} + +func handleClient(conn net.Conn, sm *local.SessionManager, persister *local.StatePersister) { + defer conn.Close() + + cs := &clientState{ + conn: conn, + reader: bufio.NewReaderSize(conn, 64*1024), + writer: local.NewFrameWriter(conn), + sm: sm, + persister: persister, + attachmentID: randomHex(8), + } + + for { + line, oversized, readErr := local.ReadRPCFrame(cs.reader) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + cs.cleanupAttachment() + return + } + log.Printf("client read error: %v", readErr) + cs.cleanupAttachment() + return + } + if oversized { + _ = cs.writer.WriteResponse(local.ErrorResponse(nil, "invalid_request", "request frame exceeds maximum size")) + continue + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + if len(line) == 0 { + continue + } + + var req local.RPCRequest + if err := json.Unmarshal(line, &req); err != nil { + _ = cs.writer.WriteResponse(local.ErrorResponse(nil, "invalid_request", "invalid JSON request")) + continue + } + + resp := cs.handleRequest(req) + if err := cs.writer.WriteResponse(resp); err != nil { + log.Printf("client write error: %v", err) + cs.cleanupAttachment() + return + } + } +} + +func (cs *clientState) cleanupAttachment() { + if cs.attachedPane != nil { + cs.attachedPane.Detach(cs.attachmentID) + cs.attachedPane = nil + } + if cs.attachedSess != nil { + cs.attachedSess = nil + } +} + +func (cs *clientState) handleRequest(req local.RPCRequest) local.RPCResponse { + if req.Method == "" { + return local.ErrorResponse(req.ID, "invalid_request", "method is required") + } + + switch req.Method { + case "hello": + return local.OKResponse(req.ID, map[string]any{ + "name": "cmuxd-local", + "version": version, + "capabilities": []string{ + "session.pty", + "session.detach", + "session.replay", + "window.management", + "pane.management", + }, + }) + + case "ping": + return local.OKResponse(req.ID, map[string]any{"pong": true}) + + case "session.new": + return cs.handleSessionNew(req) + case "session.list": + return cs.handleSessionList(req) + case "session.attach": + return cs.handleSessionAttach(req) + case "session.detach": + return cs.handleSessionDetach(req) + case "session.resize": + return cs.handleSessionResize(req) + case "session.close": + return cs.handleSessionClose(req) + + case "window.new": + return cs.handleWindowNew(req) + case "window.list": + return cs.handleWindowList(req) + case "window.close": + return cs.handleWindowClose(req) + + case "pane.split": + return cs.handlePaneSplit(req) + case "pane.close": + return cs.handlePaneClose(req) + case "pane.focus": + return cs.handlePaneFocus(req) + + case "pty.input": + return cs.handlePtyInput(req) + + default: + return local.ErrorResponse(req.ID, "method_not_found", fmt.Sprintf("unknown method %q", req.Method)) + } +} + +// --------------------------------------------------------------------------- +// Session methods +// --------------------------------------------------------------------------- + +func (cs *clientState) handleSessionNew(req local.RPCRequest) local.RPCResponse { + name, _ := local.GetStringParam(req.Params, "name") + shell, _ := local.GetStringParam(req.Params, "shell") + cols, _ := local.GetIntParam(req.Params, "cols") + rows, _ := local.GetIntParam(req.Params, "rows") + env, _ := local.GetStringMapParam(req.Params, "env") + + sess, err := cs.sm.Create(local.NewSessionParams{ + Name: name, + Shell: shell, + Cols: cols, + Rows: rows, + Env: env, + }) + if err != nil { + return local.ErrorResponse(req.ID, "create_failed", err.Error()) + } + + cs.persister.NotifyChange() + return local.OKResponse(req.ID, sess.Snapshot()) +} + +func (cs *clientState) handleSessionList(req local.RPCRequest) local.RPCResponse { + return local.OKResponse(req.ID, map[string]any{ + "sessions": cs.sm.List(), + }) +} + +func (cs *clientState) handleSessionAttach(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "session.attach requires session_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + // Determine which pane to attach to. + var pane *local.Pane + if paneID, ok := local.GetStringParam(req.Params, "pane_id"); ok && paneID != "" { + p, _, found := sess.FindPane(paneID) + if !found { + return local.ErrorResponse(req.ID, "not_found", "pane not found") + } + pane = p + } else { + pane = sess.ActivePane() + if pane == nil { + return local.ErrorResponse(req.ID, "not_found", "session has no active pane") + } + } + + // If cols/rows provided, resize the target pane. + if cols, ok := local.GetIntParam(req.Params, "cols"); ok && cols > 0 { + if rows, ok := local.GetIntParam(req.Params, "rows"); ok && rows > 0 { + _ = pane.Resize(cols, rows) + } + } + + // Detach from any previous attachment on this connection. + cs.cleanupAttachment() + + // Attach to the pane. + replay := pane.Attach(cs.attachmentID, cs.writer) + cs.attachedSess = sess + cs.attachedPane = pane + + // Build response with window_id and pane_id. + result := sess.Snapshot() + result["window_id"] = sess.GetActiveWindowID() + result["pane_id"] = pane.ID + + resp := local.OKResponse(req.ID, result) + + // Queue replay event. + go func() { + _ = cs.writer.WriteEvent(local.RPCEvent{ + Event: "pty.replay", + SessionID: sess.ID, + PaneID: pane.ID, + DataBase64: base64.StdEncoding.EncodeToString(replay), + ReplayDone: true, + }) + }() + + return resp +} + +func (cs *clientState) handleSessionDetach(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "session.detach requires session_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + sess.Detach(cs.attachmentID) + if cs.attachedSess == sess { + cs.attachedSess = nil + cs.attachedPane = nil + } + + return local.OKResponse(req.ID, map[string]any{ + "session_id": sess.ID, + "detached": true, + }) +} + +func (cs *clientState) handleSessionResize(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "session.resize requires session_id") + } + cols, ok := local.GetIntParam(req.Params, "cols") + if !ok || cols <= 0 { + return local.ErrorResponse(req.ID, "invalid_params", "session.resize requires cols > 0") + } + rows, ok := local.GetIntParam(req.Params, "rows") + if !ok || rows <= 0 { + return local.ErrorResponse(req.ID, "invalid_params", "session.resize requires rows > 0") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + // If pane_id specified, resize that pane; otherwise resize active pane. + if paneID, ok := local.GetStringParam(req.Params, "pane_id"); ok && paneID != "" { + p, _, found := sess.FindPane(paneID) + if !found { + return local.ErrorResponse(req.ID, "not_found", "pane not found") + } + if err := p.Resize(cols, rows); err != nil { + return local.ErrorResponse(req.ID, "resize_failed", err.Error()) + } + } else { + if err := sess.Resize(cols, rows); err != nil { + return local.ErrorResponse(req.ID, "resize_failed", err.Error()) + } + } + + return local.OKResponse(req.ID, sess.Snapshot()) +} + +func (cs *clientState) handleSessionClose(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "session.close requires session_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + // Detach us if we're attached to this session. + if cs.attachedSess == sess { + cs.attachedSess = nil + cs.attachedPane = nil + } + + removed := cs.sm.Remove(sess.ID) + if !removed { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + cs.persister.NotifyChange() + return local.OKResponse(req.ID, map[string]any{ + "session_id": sess.ID, + "closed": true, + }) +} + +// --------------------------------------------------------------------------- +// Window methods +// --------------------------------------------------------------------------- + +func (cs *clientState) handleWindowNew(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "window.new requires session_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + name, _ := local.GetStringParam(req.Params, "name") + shell, _ := local.GetStringParam(req.Params, "shell") + cols, _ := local.GetIntParam(req.Params, "cols") + rows, _ := local.GetIntParam(req.Params, "rows") + + w, _, err := sess.AddWindow(name, shell, cols, rows, nil) + if err != nil { + return local.ErrorResponse(req.ID, "create_failed", err.Error()) + } + + cs.persister.NotifyChange() + return local.OKResponse(req.ID, w.Snapshot()) +} + +func (cs *clientState) handleWindowList(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "window.list requires session_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + return local.OKResponse(req.ID, map[string]any{ + "session_id": sess.ID, + "windows": sess.ListWindows(), + }) +} + +func (cs *clientState) handleWindowClose(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "window.close requires session_id") + } + windowID, ok := local.GetStringParam(req.Params, "window_id") + if !ok || windowID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "window.close requires window_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + // If we're attached to a pane in this window, clean up. + if cs.attachedPane != nil && cs.attachedSess == sess { + if _, w, found := sess.FindPane(cs.attachedPane.ID); found && w.ID == windowID { + cs.attachedPane.Detach(cs.attachmentID) + cs.attachedPane = nil + cs.attachedSess = nil + } + } + + removed := sess.RemoveWindow(windowID) + if !removed { + return local.ErrorResponse(req.ID, "not_found", "window not found") + } + + cs.persister.NotifyChange() + return local.OKResponse(req.ID, map[string]any{ + "session_id": sess.ID, + "window_id": windowID, + "closed": true, + }) +} + +// --------------------------------------------------------------------------- +// Pane methods +// --------------------------------------------------------------------------- + +func (cs *clientState) handlePaneSplit(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pane.split requires session_id") + } + windowID, ok := local.GetStringParam(req.Params, "window_id") + if !ok || windowID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pane.split requires window_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + w, ok := sess.GetWindow(windowID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "window not found") + } + + shell, _ := local.GetStringParam(req.Params, "shell") + cols, _ := local.GetIntParam(req.Params, "cols") + rows, _ := local.GetIntParam(req.Params, "rows") + direction, _ := local.GetStringParam(req.Params, "direction") + + p, err := w.AddPane(sess.ID, shell, cols, rows, nil) + if err != nil { + return local.ErrorResponse(req.ID, "create_failed", err.Error()) + } + + cs.persister.NotifyChange() + + result := p.Snapshot() + result["window_id"] = w.ID + result["session_id"] = sess.ID + if direction != "" { + result["direction"] = direction + } + + return local.OKResponse(req.ID, result) +} + +func (cs *clientState) handlePaneClose(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pane.close requires session_id") + } + windowID, ok := local.GetStringParam(req.Params, "window_id") + if !ok || windowID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pane.close requires window_id") + } + paneID, ok := local.GetStringParam(req.Params, "pane_id") + if !ok || paneID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pane.close requires pane_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + w, ok := sess.GetWindow(windowID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "window not found") + } + + // If we're attached to this pane, clean up. + if cs.attachedPane != nil && cs.attachedPane.ID == paneID { + cs.attachedPane.Detach(cs.attachmentID) + cs.attachedPane = nil + cs.attachedSess = nil + } + + removed := w.RemovePane(paneID) + if !removed { + return local.ErrorResponse(req.ID, "not_found", "pane not found") + } + + cs.persister.NotifyChange() + return local.OKResponse(req.ID, map[string]any{ + "session_id": sess.ID, + "window_id": w.ID, + "pane_id": paneID, + "closed": true, + }) +} + +func (cs *clientState) handlePaneFocus(req local.RPCRequest) local.RPCResponse { + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pane.focus requires session_id") + } + paneID, ok := local.GetStringParam(req.Params, "pane_id") + if !ok || paneID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pane.focus requires pane_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + if !sess.SetActivePaneByID(paneID) { + return local.ErrorResponse(req.ID, "not_found", "pane not found") + } + + return local.OKResponse(req.ID, map[string]any{ + "session_id": sess.ID, + "pane_id": paneID, + "focused": true, + }) +} + +// --------------------------------------------------------------------------- +// PTY input +// --------------------------------------------------------------------------- + +func (cs *clientState) handlePtyInput(req local.RPCRequest) local.RPCResponse { + dataBase64, ok := local.GetStringParam(req.Params, "data_base64") + if !ok { + return local.ErrorResponse(req.ID, "invalid_params", "pty.input requires data_base64") + } + data, err := base64.StdEncoding.DecodeString(dataBase64) + if err != nil { + return local.ErrorResponse(req.ID, "invalid_params", "data_base64 must be valid base64") + } + + // If pane_id is provided, write directly to that pane. + if paneID, ok := local.GetStringParam(req.Params, "pane_id"); ok && paneID != "" { + sessionID, _ := local.GetStringParam(req.Params, "session_id") + if sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pty.input with pane_id requires session_id") + } + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + p, _, found := sess.FindPane(paneID) + if !found { + return local.ErrorResponse(req.ID, "not_found", "pane not found") + } + if err := p.WriteInput(data); err != nil { + return local.ErrorResponse(req.ID, "write_failed", err.Error()) + } + return local.OKResponse(req.ID, map[string]any{"written": len(data)}) + } + + // Fallback: use session_id, route to active pane. + sessionID, ok := local.GetStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return local.ErrorResponse(req.ID, "invalid_params", "pty.input requires session_id or pane_id") + } + + sess, ok := cs.sm.FindByNameOrID(sessionID) + if !ok { + return local.ErrorResponse(req.ID, "not_found", "session not found") + } + + if err := sess.WriteInput(data); err != nil { + return local.ErrorResponse(req.ID, "write_failed", err.Error()) + } + + return local.OKResponse(req.ID, map[string]any{"written": len(data)}) +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/daemon/local/com.cmux.daemon-local.plist b/daemon/local/com.cmux.daemon-local.plist new file mode 100644 index 0000000000..e7519a18dd --- /dev/null +++ b/daemon/local/com.cmux.daemon-local.plist @@ -0,0 +1,43 @@ + + + + + Label + com.cmux.daemon-local + + ProgramArguments + + __CMUXD_LOCAL_BINARY_PATH__ + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 5 + + StandardOutPath + __HOME__/Library/Logs/cmuxd-local.log + + StandardErrorPath + __HOME__/Library/Logs/cmuxd-local.log + + WorkingDirectory + __HOME__ + + ProcessType + Background + + SoftResourceLimits + + NumberOfFiles + 4096 + + + diff --git a/daemon/local/go.mod b/daemon/local/go.mod new file mode 100644 index 0000000000..f39f885601 --- /dev/null +++ b/daemon/local/go.mod @@ -0,0 +1,10 @@ +module github.com/manaflow-ai/cmux/daemon/local + +go 1.22 + +require ( + github.com/creack/pty v1.1.24 + golang.org/x/term v0.27.0 +) + +require golang.org/x/sys v0.28.0 // indirect diff --git a/daemon/local/go.sum b/daemon/local/go.sum new file mode 100644 index 0000000000..654c238ab9 --- /dev/null +++ b/daemon/local/go.sum @@ -0,0 +1,6 @@ +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= diff --git a/daemon/local/integration_test.go b/daemon/local/integration_test.go new file mode 100644 index 0000000000..edbefcde6a --- /dev/null +++ b/daemon/local/integration_test.go @@ -0,0 +1,712 @@ +package local + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// newTestManager creates a SessionManager with a helper to create sessions using /bin/sh. +func newTestManager(t *testing.T) *SessionManager { + t.Helper() + sm := NewSessionManager() + t.Cleanup(func() { sm.CloseAll() }) + return sm +} + +func createTestSession(t *testing.T, sm *SessionManager, name string) *Session { + t.Helper() + sess, err := sm.Create(NewSessionParams{ + Name: name, + Shell: "/bin/sh", + Cols: 80, + Rows: 24, + }) + if err != nil { + t.Fatalf("failed to create session %q: %v", name, err) + } + return sess +} + +// waitForOutput polls a pane's ring buffer until it contains the target string or times out. +func waitForOutput(t *testing.T, p *Pane, target string, timeout time.Duration) string { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + data := p.ring.Bytes() + if strings.Contains(string(data), target) { + return string(data) + } + time.Sleep(20 * time.Millisecond) + } + data := p.ring.Bytes() + t.Fatalf("timed out waiting for %q in ring buffer (got %d bytes: %q)", target, len(data), string(data)) + return "" +} + +// ------------------------------------------------------------------------- +// 1. Session lifecycle +// ------------------------------------------------------------------------- + +func TestSessionLifecycle_CreateListClose(t *testing.T) { + sm := newTestManager(t) + + sess := createTestSession(t, sm, "test-session") + + // Verify it appears in list. + list := sm.List() + if len(list) != 1 { + t.Fatalf("expected 1 session, got %d", len(list)) + } + if list[0]["name"] != "test-session" { + t.Fatalf("expected session name %q, got %q", "test-session", list[0]["name"]) + } + + // Close it. + if !sm.Remove(sess.ID) { + t.Fatal("Remove returned false for existing session") + } + + // Verify gone. + list = sm.List() + if len(list) != 0 { + t.Fatalf("expected 0 sessions after remove, got %d", len(list)) + } +} + +func TestSessionLifecycle_CreateMultiple(t *testing.T) { + sm := newTestManager(t) + + s1 := createTestSession(t, sm, "alpha") + s2 := createTestSession(t, sm, "beta") + s3 := createTestSession(t, sm, "gamma") + + list := sm.List() + if len(list) != 3 { + t.Fatalf("expected 3 sessions, got %d", len(list)) + } + + // Verify we can find each by name. + for _, name := range []string{"alpha", "beta", "gamma"} { + found, ok := sm.GetByName(name) + if !ok { + t.Fatalf("session %q not found by name", name) + } + if found.Name != name { + t.Fatalf("expected name %q, got %q", name, found.Name) + } + } + + // IDs are distinct. + if s1.ID == s2.ID || s2.ID == s3.ID { + t.Fatal("session IDs are not unique") + } +} + +func TestSessionLifecycle_CloseNonExistent(t *testing.T) { + sm := newTestManager(t) + + if sm.Remove("no-such-session") { + t.Fatal("Remove returned true for non-existent session") + } +} + +// ------------------------------------------------------------------------- +// 2. Ring buffer and replay +// ------------------------------------------------------------------------- + +func TestRingBuffer_WriteAndReplay(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "replay-test") + + pane := sess.ActivePane() + if pane == nil { + t.Fatal("no active pane") + } + + // Write a command to the PTY and wait for output. + if err := pane.WriteInput([]byte("echo HELLO_REPLAY\n")); err != nil { + t.Fatalf("WriteInput: %v", err) + } + + output := waitForOutput(t, pane, "HELLO_REPLAY", 5*time.Second) + if !strings.Contains(output, "HELLO_REPLAY") { + t.Fatalf("ring buffer does not contain expected output, got: %q", output) + } + + // Attach a new client and verify replay contains the output. + var buf bytes.Buffer + fw := NewFrameWriter(&buf) + replay := pane.Attach("test-client", fw) + defer pane.Detach("test-client") + + if !strings.Contains(string(replay), "HELLO_REPLAY") { + t.Fatalf("replay does not contain expected output, got %d bytes: %q", len(replay), string(replay)) + } +} + +func TestRingBuffer_DoesNotExceedSize(t *testing.T) { + rb := NewRingBuffer(ringBufferSize) + + // Write 3MB of data into a 2MB ring buffer. + chunk := bytes.Repeat([]byte("A"), 1024*1024) + rb.Write(chunk) // 1MB + rb.Write(chunk) // 2MB + rb.Write(chunk) // 3MB total, but buffer should cap at 2MB + + data := rb.Bytes() + if len(data) != ringBufferSize { + t.Fatalf("expected ring buffer len %d, got %d", ringBufferSize, len(data)) + } +} + +// ------------------------------------------------------------------------- +// 3. Attach / detach +// ------------------------------------------------------------------------- + +func TestAttachDetach_SessionSurvivesDetach(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "attach-test") + + pane := sess.ActivePane() + if pane == nil { + t.Fatal("no active pane") + } + + var buf bytes.Buffer + fw := NewFrameWriter(&buf) + pane.Attach("client-A", fw) + + if pane.AttachedCount() != 1 { + t.Fatalf("expected 1 attached, got %d", pane.AttachedCount()) + } + + pane.Detach("client-A") + + if pane.AttachedCount() != 0 { + t.Fatalf("expected 0 attached after detach, got %d", pane.AttachedCount()) + } + + // Session should still be alive. + if sess.Status != SessionRunning { + t.Fatal("session died after client detach") + } +} + +func TestAttachDetach_MultipleClients(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "multi-attach") + + pane := sess.ActivePane() + if pane == nil { + t.Fatal("no active pane") + } + + var bufA, bufB bytes.Buffer + fwA := NewFrameWriter(&bufA) + fwB := NewFrameWriter(&bufB) + + pane.Attach("client-A", fwA) + pane.Attach("client-B", fwB) + + if pane.AttachedCount() != 2 { + t.Fatalf("expected 2 attached, got %d", pane.AttachedCount()) + } + + // Write something and verify both clients receive output events. + if err := pane.WriteInput([]byte("echo MULTI_OUT\n")); err != nil { + t.Fatalf("WriteInput: %v", err) + } + + waitForOutput(t, pane, "MULTI_OUT", 5*time.Second) + + // Give a moment for fan-out writes to buffers. + time.Sleep(100 * time.Millisecond) + + if bufA.Len() == 0 { + t.Fatal("client A received no output") + } + if bufB.Len() == 0 { + t.Fatal("client B received no output") + } +} + +func TestAttachDetach_DetachAllSessionPersists(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "detach-all") + + pane := sess.ActivePane() + if pane == nil { + t.Fatal("no active pane") + } + + var buf1, buf2 bytes.Buffer + pane.Attach("c1", NewFrameWriter(&buf1)) + pane.Attach("c2", NewFrameWriter(&buf2)) + + pane.Detach("c1") + pane.Detach("c2") + + if pane.AttachedCount() != 0 { + t.Fatalf("expected 0 attached, got %d", pane.AttachedCount()) + } + + // Session and pane should still be running. + if sess.Status != SessionRunning { + t.Fatal("session died after all clients detached") + } + pane.mu.Lock() + status := pane.Status + pane.mu.Unlock() + if status != SessionRunning { + t.Fatal("pane died after all clients detached") + } +} + +// ------------------------------------------------------------------------- +// 4. Window / pane management +// ------------------------------------------------------------------------- + +func TestWindowPaneManagement_AddWindow(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "win-test") + + // Should start with 1 window, 1 pane. + if sess.WindowCount() != 1 { + t.Fatalf("expected 1 window, got %d", sess.WindowCount()) + } + if sess.TotalPaneCount() != 1 { + t.Fatalf("expected 1 pane, got %d", sess.TotalPaneCount()) + } + + // Add a second window. + w2, p2, err := sess.AddWindow("second", "/bin/sh", 80, 24, nil) + if err != nil { + t.Fatalf("AddWindow: %v", err) + } + if w2 == nil || p2 == nil { + t.Fatal("AddWindow returned nil window or pane") + } + + if sess.WindowCount() != 2 { + t.Fatalf("expected 2 windows, got %d", sess.WindowCount()) + } + + // ListWindows should return 2 snapshots. + winList := sess.ListWindows() + if len(winList) != 2 { + t.Fatalf("expected 2 window snapshots, got %d", len(winList)) + } +} + +func TestWindowPaneManagement_SplitPane(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "split-test") + + w := sess.ActiveWindow() + if w == nil { + t.Fatal("no active window") + } + + if w.PaneCount() != 1 { + t.Fatalf("expected 1 pane, got %d", w.PaneCount()) + } + + // "Split" = add another pane to the same window. + p2, err := w.AddPane(sess.ID, "/bin/sh", 80, 24, nil) + if err != nil { + t.Fatalf("AddPane: %v", err) + } + if p2 == nil { + t.Fatal("AddPane returned nil") + } + + if w.PaneCount() != 2 { + t.Fatalf("expected 2 panes after split, got %d", w.PaneCount()) + } +} + +func TestWindowPaneManagement_ClosePane(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "close-pane-test") + + w := sess.ActiveWindow() + if w == nil { + t.Fatal("no active window") + } + + // Add a second pane. + p2, err := w.AddPane(sess.ID, "/bin/sh", 80, 24, nil) + if err != nil { + t.Fatalf("AddPane: %v", err) + } + + if w.PaneCount() != 2 { + t.Fatalf("expected 2 panes, got %d", w.PaneCount()) + } + + // Remove the second pane. + if !w.RemovePane(p2.ID) { + t.Fatal("RemovePane returned false") + } + + if w.PaneCount() != 1 { + t.Fatalf("expected 1 pane after removal, got %d", w.PaneCount()) + } + + // Verify the removed pane ID is gone. + if _, ok := w.GetPane(p2.ID); ok { + t.Fatal("removed pane still found in window") + } +} + +// ------------------------------------------------------------------------- +// 5. Persistence +// ------------------------------------------------------------------------- + +func TestPersistence_SnapshotSaveLoadRoundtrip(t *testing.T) { + sm := newTestManager(t) + + createTestSession(t, sm, "persist-A") + createTestSession(t, sm, "persist-B") + + // Take snapshot. + snapshot := sm.PersistSnapshot() + if len(snapshot.Sessions) != 2 { + t.Fatalf("expected 2 sessions in snapshot, got %d", len(snapshot.Sessions)) + } + if snapshot.Version != persistenceVersion { + t.Fatalf("expected version %d, got %d", persistenceVersion, snapshot.Version) + } + + // Save to temp file. + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "state.json") + + if err := SaveState(snapshot, statePath); err != nil { + t.Fatalf("SaveState: %v", err) + } + + // Verify file exists. + if _, err := os.Stat(statePath); err != nil { + t.Fatalf("state file not found: %v", err) + } + + // Load it back. + loaded, err := LoadState(statePath) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if loaded == nil { + t.Fatal("LoadState returned nil") + } + + if len(loaded.Sessions) != 2 { + t.Fatalf("expected 2 sessions after load, got %d", len(loaded.Sessions)) + } + + // Verify session names survived the roundtrip. + names := make(map[string]bool) + for _, s := range loaded.Sessions { + names[s.Name] = true + } + if !names["persist-A"] || !names["persist-B"] { + t.Fatalf("session names not preserved: %v", names) + } +} + +func TestPersistence_DeadSessionsFiltered(t *testing.T) { + sm := newTestManager(t) + + sess := createTestSession(t, sm, "will-die") + + // Kill the session by closing it (marks it dead). + sess.Close() + + snapshot := sm.PersistSnapshot() + if len(snapshot.Sessions) != 0 { + t.Fatalf("expected 0 sessions in snapshot (dead filtered), got %d", len(snapshot.Sessions)) + } +} + +func TestPersistence_AtomicWrite(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "atomic-state.json") + + state := PersistentState{ + Version: persistenceVersion, + PID: os.Getpid(), + SavedAt: time.Now().UTC().Format(time.RFC3339), + Sessions: []PersistentSession{ + { + ID: "sess-1", + Name: "test", + Shell: "/bin/sh", + }, + }, + NextID: 2, + } + + if err := SaveState(state, statePath); err != nil { + t.Fatalf("SaveState: %v", err) + } + + // Verify no temp files left behind. + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Fatalf("temp file left behind: %s", e.Name()) + } + } + + // Verify final file is valid JSON. + data, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var check PersistentState + if err := json.Unmarshal(data, &check); err != nil { + t.Fatalf("saved file is not valid JSON: %v", err) + } + if check.Version != persistenceVersion { + t.Fatalf("expected version %d, got %d", persistenceVersion, check.Version) + } +} + +func TestPersistence_LoadNonExistent(t *testing.T) { + loaded, err := LoadState("/tmp/no-such-file-exists-12345.json") + if err != nil { + t.Fatalf("LoadState of nonexistent file should return nil error, got: %v", err) + } + if loaded != nil { + t.Fatal("LoadState of nonexistent file should return nil state") + } +} + +func TestPersistence_RestoreSessions(t *testing.T) { + // Create sessions and snapshot them. + sm1 := newTestManager(t) + createTestSession(t, sm1, "restore-A") + createTestSession(t, sm1, "restore-B") + snapshot := sm1.PersistSnapshot() + + // Save and reload. + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "restore-state.json") + if err := SaveState(snapshot, statePath); err != nil { + t.Fatalf("SaveState: %v", err) + } + loaded, err := LoadState(statePath) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + + // Restore into a fresh manager. + sm2 := NewSessionManager() + t.Cleanup(func() { sm2.CloseAll() }) + result := sm2.RestoreSessions(loaded) + + if result.SessionCount != 2 { + t.Fatalf("expected 2 restored sessions, got %d", result.SessionCount) + } + if len(result.Errors) > 0 { + t.Fatalf("restore errors: %v", result.Errors) + } + + // Verify names are preserved. + list := sm2.List() + if len(list) != 2 { + t.Fatalf("expected 2 sessions in restored manager, got %d", len(list)) + } + names := make(map[string]bool) + for _, s := range list { + names[s["name"].(string)] = true + } + if !names["restore-A"] || !names["restore-B"] { + t.Fatalf("restored session names not preserved: %v", names) + } +} + +// ------------------------------------------------------------------------- +// 6. Resize +// ------------------------------------------------------------------------- + +func TestResize_UpdatesDimensions(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "resize-test") + + pane := sess.ActivePane() + if pane == nil { + t.Fatal("no active pane") + } + + // Verify initial dimensions via snapshot. + snap := pane.Snapshot() + if snap["cols"] != 80 || snap["rows"] != 24 { + t.Fatalf("expected initial 80x24, got %vx%v", snap["cols"], snap["rows"]) + } + + // Resize. + if err := pane.Resize(120, 40); err != nil { + t.Fatalf("Resize: %v", err) + } + + // Verify new dimensions. + snap = pane.Snapshot() + if snap["cols"] != 120 || snap["rows"] != 40 { + t.Fatalf("expected 120x40 after resize, got %vx%v", snap["cols"], snap["rows"]) + } +} + +func TestResize_ViaSession(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "resize-sess") + + // Resize via session (delegates to active pane). + if err := sess.Resize(200, 50); err != nil { + t.Fatalf("session Resize: %v", err) + } + + pane := sess.ActivePane() + snap := pane.Snapshot() + if snap["cols"] != 200 || snap["rows"] != 50 { + t.Fatalf("expected 200x50 after session resize, got %vx%v", snap["cols"], snap["rows"]) + } +} + +// ------------------------------------------------------------------------- +// Additional edge cases +// ------------------------------------------------------------------------- + +func TestSession_FindPane(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "find-pane") + + pane := sess.ActivePane() + if pane == nil { + t.Fatal("no active pane") + } + + // FindPane should locate the pane. + found, w, ok := sess.FindPane(pane.ID) + if !ok { + t.Fatal("FindPane did not find active pane") + } + if found.ID != pane.ID { + t.Fatalf("expected pane %s, got %s", pane.ID, found.ID) + } + if w == nil { + t.Fatal("FindPane returned nil window") + } + + // Non-existent pane. + _, _, ok = sess.FindPane("no-such-pane") + if ok { + t.Fatal("FindPane should not find non-existent pane") + } +} + +func TestSession_RemoveWindow(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "remove-win") + + // Add a second window. + w2, _, err := sess.AddWindow("extra", "/bin/sh", 80, 24, nil) + if err != nil { + t.Fatalf("AddWindow: %v", err) + } + + if sess.WindowCount() != 2 { + t.Fatalf("expected 2 windows, got %d", sess.WindowCount()) + } + + // Remove the second window. + if !sess.RemoveWindow(w2.ID) { + t.Fatal("RemoveWindow returned false") + } + + if sess.WindowCount() != 1 { + t.Fatalf("expected 1 window after removal, got %d", sess.WindowCount()) + } +} + +func TestSession_FindByNameOrID(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "lookup-test") + + // Find by name. + found, ok := sm.FindByNameOrID("lookup-test") + if !ok || found.ID != sess.ID { + t.Fatal("FindByNameOrID by name failed") + } + + // Find by ID. + found, ok = sm.FindByNameOrID(sess.ID) + if !ok || found.ID != sess.ID { + t.Fatal("FindByNameOrID by ID failed") + } + + // Not found. + _, ok = sm.FindByNameOrID("nonexistent") + if ok { + t.Fatal("FindByNameOrID should fail for nonexistent") + } +} + +func TestWindow_RemoveLastPane_MarksWindowDead(t *testing.T) { + sm := newTestManager(t) + sess := createTestSession(t, sm, "last-pane") + + w := sess.ActiveWindow() + if w == nil { + t.Fatal("no active window") + } + + pane := w.ActivePane() + if pane == nil { + t.Fatal("no active pane") + } + + // Remove the only pane. + w.RemovePane(pane.ID) + + if w.PaneCount() != 0 { + t.Fatalf("expected 0 panes, got %d", w.PaneCount()) + } + if w.Status != SessionDead { + t.Fatalf("expected window status dead, got %s", w.Status) + } +} + +func TestRingBuffer_EmptyBytes(t *testing.T) { + rb := NewRingBuffer(1024) + data := rb.Bytes() + if len(data) != 0 { + t.Fatalf("expected empty ring buffer, got %d bytes", len(data)) + } +} + +func TestRingBuffer_Len(t *testing.T) { + rb := NewRingBuffer(1024) + if rb.Len() != 0 { + t.Fatalf("expected 0, got %d", rb.Len()) + } + + rb.Write([]byte("hello")) + if rb.Len() != 5 { + t.Fatalf("expected 5, got %d", rb.Len()) + } + + // Write more than capacity. + rb.Write(bytes.Repeat([]byte("x"), 2000)) + if rb.Len() != 1024 { + t.Fatalf("expected 1024 (capped), got %d", rb.Len()) + } +} diff --git a/daemon/local/persistence.go b/daemon/local/persistence.go new file mode 100644 index 0000000000..327679e720 --- /dev/null +++ b/daemon/local/persistence.go @@ -0,0 +1,555 @@ +package local + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// --------------------------------------------------------------------------- +// Persistent state types — serializable snapshots of session structure. +// --------------------------------------------------------------------------- + +// PersistentPane captures the metadata needed to recreate a pane. +type PersistentPane struct { + ID string `json:"id"` + Shell string `json:"shell"` + Cols int `json:"cols"` + Rows int `json:"rows"` + CreatedAt string `json:"created_at"` +} + +// PersistentWindow captures window metadata and its panes. +type PersistentWindow struct { + ID string `json:"id"` + Name string `json:"name"` + ActivePaneID string `json:"active_pane_id"` + CreatedAt string `json:"created_at"` + Panes []PersistentPane `json:"panes"` + NextID uint64 `json:"next_id"` +} + +// PersistentSession captures session metadata and its windows. +type PersistentSession struct { + ID string `json:"id"` + Name string `json:"name"` + Shell string `json:"shell"` + ActiveWindowID string `json:"active_window_id"` + CreatedAt string `json:"created_at"` + Windows []PersistentWindow `json:"windows"` + NextID uint64 `json:"next_id"` +} + +// PersistentState is the top-level structure written to disk. +type PersistentState struct { + Version int `json:"version"` + PID int `json:"pid"` + SavedAt string `json:"saved_at"` + Sessions []PersistentSession `json:"sessions"` + NextID uint64 `json:"next_id"` +} + +const persistenceVersion = 1 + +// --------------------------------------------------------------------------- +// Snapshot helpers — extract serializable state from live objects. +// --------------------------------------------------------------------------- + +// PersistSnapshot builds a PersistentState from the current SessionManager. +func (sm *SessionManager) PersistSnapshot() PersistentState { + sm.mu.Lock() + defer sm.mu.Unlock() + + state := PersistentState{ + Version: persistenceVersion, + PID: os.Getpid(), + SavedAt: time.Now().UTC().Format(time.RFC3339), + NextID: sm.nextID, + } + + for _, sess := range sm.sessions { + ps := persistSession(sess) + if ps != nil { + state.Sessions = append(state.Sessions, *ps) + } + } + + return state +} + +func persistSession(s *Session) *PersistentSession { + s.mu.Lock() + defer s.mu.Unlock() + + if s.Status == SessionDead { + return nil + } + + ps := &PersistentSession{ + ID: s.ID, + Name: s.Name, + Shell: s.Shell, + ActiveWindowID: s.ActiveWindowID, + CreatedAt: s.CreatedAt.Format(time.RFC3339), + NextID: s.nextID, + } + + for _, w := range s.windows { + pw := persistWindow(w) + if pw != nil { + ps.Windows = append(ps.Windows, *pw) + } + } + + // Don't persist sessions with no live windows. + if len(ps.Windows) == 0 { + return nil + } + + return ps +} + +func persistWindow(w *Window) *PersistentWindow { + w.mu.Lock() + defer w.mu.Unlock() + + if w.Status == SessionDead { + return nil + } + + pw := &PersistentWindow{ + ID: w.ID, + Name: w.Name, + ActivePaneID: w.ActivePaneID, + CreatedAt: w.CreatedAt.Format(time.RFC3339), + NextID: w.nextID, + } + + for _, p := range w.panes { + p.mu.Lock() + if p.Status == SessionDead { + p.mu.Unlock() + continue + } + pp := PersistentPane{ + ID: p.ID, + Shell: p.Shell, + Cols: p.cols, + Rows: p.rows, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + } + p.mu.Unlock() + pw.Panes = append(pw.Panes, pp) + } + + // Don't persist windows with no live panes. + if len(pw.Panes) == 0 { + return nil + } + + return pw +} + +// --------------------------------------------------------------------------- +// Save / Load — atomic file I/O. +// --------------------------------------------------------------------------- + +// SaveState writes the persistent state to filePath atomically. +// It writes to a temporary file in the same directory, then renames. +func SaveState(state PersistentState, filePath string) error { + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("create state directory: %w", err) + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("marshal state: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".cmuxd-state-*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("sync temp file: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("close temp file: %w", err) + } + + if err := os.Rename(tmpPath, filePath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("rename temp to state file: %w", err) + } + + return nil +} + +// LoadState reads and deserializes the persistent state from filePath. +// Returns nil state and nil error if the file does not exist. +func LoadState(filePath string) (*PersistentState, error) { + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read state file: %w", err) + } + + var state PersistentState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("unmarshal state: %w", err) + } + + if state.Version != persistenceVersion { + return nil, fmt.Errorf("unsupported state version %d (expected %d)", state.Version, persistenceVersion) + } + + return &state, nil +} + +// RemoveState deletes the state file. It is not an error if the file does not exist. +func RemoveState(filePath string) error { + err := os.Remove(filePath) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// DefaultStateDir returns the private state directory for the daemon. +// Uses ~/.local/state/cmux/ (XDG-style), created with 0700 permissions. +func DefaultStateDir() string { + home, err := os.UserHomeDir() + if err != nil { + home = "/tmp" + } + return filepath.Join(home, ".local", "state", "cmux") +} + +// DefaultStatePath returns the default path for the persistent state file. +// Uses ~/.local/state/cmux/daemon-local.json (XDG-style). +func DefaultStatePath() string { + return filepath.Join(DefaultStateDir(), "daemon-local.json") +} + +// DefaultSocketPath returns the path for the daemon's Unix socket. +// Uses ~/.local/state/cmux/daemon-local.sock — a private directory (0700) +// that is not vulnerable to symlink attacks, unlike /tmp. +func DefaultSocketPath() string { + return filepath.Join(DefaultStateDir(), "daemon-local.sock") +} + +// LegacySocketPath returns the old /tmp-based socket path for backward +// compatibility. The daemon creates a symlink here pointing to the new +// socket so that older clients can still connect during the transition. +func LegacySocketPath() string { + return fmt.Sprintf("/tmp/cmux-local-%d.sock", os.Getuid()) +} + +// EnsureStateDir creates the state directory with 0700 permissions if it +// does not already exist. Returns the directory path. +func EnsureStateDir() (string, error) { + dir := DefaultStateDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("create state directory: %w", err) + } + // Ensure permissions are correct even if directory already existed. + if err := os.Chmod(dir, 0700); err != nil { + return "", fmt.Errorf("set state directory permissions: %w", err) + } + return dir, nil +} + +// --------------------------------------------------------------------------- +// Restore — recreate sessions from persisted state. +// --------------------------------------------------------------------------- + +// RestoreResult summarizes what was restored. +type RestoreResult struct { + SessionCount int + WindowCount int + PaneCount int + Errors []string +} + +// RestoreSessions recreates session/window/pane structure from persisted state. +// Each pane gets a fresh PTY (the original processes are gone). Restored sessions +// keep their original IDs and names so the GUI can match them. +func (sm *SessionManager) RestoreSessions(state *PersistentState) RestoreResult { + var result RestoreResult + if state == nil || len(state.Sessions) == 0 { + return result + } + + sm.mu.Lock() + // Advance the ID counter past any IDs in persisted state. + if state.NextID > sm.nextID { + sm.nextID = state.NextID + } + sm.mu.Unlock() + + for _, ps := range state.Sessions { + sess, err := sm.restoreSession(ps) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("session %s (%s): %s", ps.ID, ps.Name, err)) + continue + } + if sess == nil { + continue + } + result.SessionCount++ + result.WindowCount += sess.WindowCount() + result.PaneCount += sess.TotalPaneCount() + } + + return result +} + +func (sm *SessionManager) restoreSession(ps PersistentSession) (*Session, error) { + if len(ps.Windows) == 0 { + return nil, nil + } + + createdAt, err := time.Parse(time.RFC3339, ps.CreatedAt) + if err != nil { + createdAt = time.Now().UTC() + } + + sess := &Session{ + ID: ps.ID, + Name: ps.Name, + Shell: ps.Shell, + CreatedAt: createdAt, + Status: SessionRunning, + windows: make(map[string]*Window), + nextID: ps.NextID, + } + + for _, pw := range ps.Windows { + w, err := restoreWindow(pw, sess.ID) + if err != nil { + // Log but continue — partial restore is better than none. + continue + } + if w == nil { + continue + } + sess.windows[w.ID] = w + } + + if len(sess.windows) == 0 { + return nil, fmt.Errorf("no windows could be restored") + } + + // Restore active window. If the persisted active window wasn't restored, pick one. + if _, ok := sess.windows[ps.ActiveWindowID]; ok { + sess.ActiveWindowID = ps.ActiveWindowID + } else { + for id := range sess.windows { + sess.ActiveWindowID = id + break + } + } + + sm.mu.Lock() + sm.sessions[sess.ID] = sess + // Ensure nextID is past this session's numeric suffix. + if n := extractIDNumber(sess.ID, "sess-"); n >= sm.nextID { + sm.nextID = n + 1 + } + sm.mu.Unlock() + + return sess, nil +} + +func restoreWindow(pw PersistentWindow, sessionID string) (*Window, error) { + if len(pw.Panes) == 0 { + return nil, nil + } + + createdAt, err := time.Parse(time.RFC3339, pw.CreatedAt) + if err != nil { + createdAt = time.Now().UTC() + } + + w := &Window{ + ID: pw.ID, + Name: pw.Name, + CreatedAt: createdAt, + Status: SessionRunning, + panes: make(map[string]*Pane), + nextID: pw.NextID, + } + + for _, pp := range pw.Panes { + p, err := restorePane(pp, sessionID, w.ID) + if err != nil { + continue + } + w.panes[p.ID] = p + } + + if len(w.panes) == 0 { + return nil, fmt.Errorf("no panes could be restored") + } + + // Restore active pane. + if _, ok := w.panes[pw.ActivePaneID]; ok { + w.ActivePaneID = pw.ActivePaneID + } else { + for id := range w.panes { + w.ActivePaneID = id + break + } + } + + return w, nil +} + +func restorePane(pp PersistentPane, sessionID, windowID string) (*Pane, error) { + // Create a fresh PTY with the same shell and dimensions. + p, err := newPane(pp.ID, sessionID, windowID, pp.Shell, pp.Cols, pp.Rows, nil) + if err != nil { + return nil, fmt.Errorf("restore pane %s: %w", pp.ID, err) + } + + // Preserve the original creation time. + if t, err := time.Parse(time.RFC3339, pp.CreatedAt); err == nil { + p.CreatedAt = t + } + + return p, nil +} + +// extractIDNumber parses the numeric suffix from an ID like "sess-3" or "sess-3.win-2". +func extractIDNumber(id, prefix string) uint64 { + // Handle compound IDs like "sess-3.win-2" — we want the "sess-3" part. + if idx := strings.Index(id, "."); idx >= 0 { + id = id[:idx] + } + after := strings.TrimPrefix(id, prefix) + if after == id { + return 0 + } + n, err := strconv.ParseUint(after, 10, 64) + if err != nil { + return 0 + } + return n +} + +// --------------------------------------------------------------------------- +// StatePersister — periodic and event-driven state saving. +// --------------------------------------------------------------------------- + +// StatePersister manages automatic state persistence for a SessionManager. +type StatePersister struct { + sm *SessionManager + filePath string + mu sync.Mutex + stopCh chan struct{} + stopped bool +} + +// NewStatePersister creates a persister that will save state for the given +// SessionManager to filePath. +func NewStatePersister(sm *SessionManager, filePath string) *StatePersister { + return &StatePersister{ + sm: sm, + filePath: filePath, + stopCh: make(chan struct{}), + } +} + +// Start begins periodic auto-saving every 30 seconds. +func (sp *StatePersister) Start() { + go sp.periodicSave() +} + +// Stop halts the periodic saver and performs a final save. +func (sp *StatePersister) Stop() { + sp.mu.Lock() + if sp.stopped { + sp.mu.Unlock() + return + } + sp.stopped = true + close(sp.stopCh) + sp.mu.Unlock() + + // Final save. + sp.Save() +} + +// Save writes the current state to disk. Safe to call concurrently. +func (sp *StatePersister) Save() { + state := sp.sm.PersistSnapshot() + + // If there are no sessions, remove the state file instead of writing empty state. + if len(state.Sessions) == 0 { + _ = RemoveState(sp.filePath) + return + } + + if err := SaveState(state, sp.filePath); err != nil { + // Log but don't fail — persistence is best-effort. + fmt.Fprintf(os.Stderr, "cmuxd-local: save state: %v\n", err) + } +} + +// NotifyChange should be called after any structural change (session/window/pane +// create or close). It triggers an asynchronous save. +func (sp *StatePersister) NotifyChange() { + go sp.Save() +} + +func (sp *StatePersister) periodicSave() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + sp.Save() + case <-sp.stopCh: + return + } + } +} + +// --------------------------------------------------------------------------- +// PID lock — prevent two daemons from using the same state file. +// --------------------------------------------------------------------------- + +// CheckStalePID checks whether the PID in the state file refers to a running +// process. Returns true if the previous daemon is still alive. +func CheckStalePID(state *PersistentState) bool { + if state == nil || state.PID == 0 { + return false + } + proc, err := os.FindProcess(state.PID) + if err != nil { + return false + } + // On Unix, FindProcess always succeeds. Send signal 0 to check liveness. + err = proc.Signal(syscall.Signal(0)) + return err == nil +} diff --git a/daemon/local/ringbuffer.go b/daemon/local/ringbuffer.go new file mode 100644 index 0000000000..baa1cd1625 --- /dev/null +++ b/daemon/local/ringbuffer.go @@ -0,0 +1,65 @@ +package local + +import "sync" + +// RingBuffer is a fixed-size circular buffer that stores the most recent bytes +// written to it. When full, old data is silently overwritten. +type RingBuffer struct { + mu sync.Mutex + buf []byte + size int + pos int // next write position + full bool +} + +// NewRingBuffer creates a ring buffer with the given capacity in bytes. +func NewRingBuffer(size int) *RingBuffer { + return &RingBuffer{ + buf: make([]byte, size), + size: size, + } +} + +// Write appends data to the ring buffer, overwriting the oldest data if necessary. +func (rb *RingBuffer) Write(data []byte) { + rb.mu.Lock() + defer rb.mu.Unlock() + + for len(data) > 0 { + n := copy(rb.buf[rb.pos:], data) + data = data[n:] + rb.pos += n + if rb.pos >= rb.size { + rb.pos = 0 + rb.full = true + } + } +} + +// Bytes returns a copy of all buffered data in chronological order. +func (rb *RingBuffer) Bytes() []byte { + rb.mu.Lock() + defer rb.mu.Unlock() + + if !rb.full { + out := make([]byte, rb.pos) + copy(out, rb.buf[:rb.pos]) + return out + } + + out := make([]byte, rb.size) + // Data from pos..end is older, 0..pos is newer + n := copy(out, rb.buf[rb.pos:]) + copy(out[n:], rb.buf[:rb.pos]) + return out +} + +// Len returns the number of bytes currently stored. +func (rb *RingBuffer) Len() int { + rb.mu.Lock() + defer rb.mu.Unlock() + if rb.full { + return rb.size + } + return rb.pos +} diff --git a/daemon/local/ringbuffer_test.go b/daemon/local/ringbuffer_test.go new file mode 100644 index 0000000000..6118116346 --- /dev/null +++ b/daemon/local/ringbuffer_test.go @@ -0,0 +1,295 @@ +package local + +import ( + "bytes" + "sync" + "testing" +) + +func TestRingBuffer_EmptyBuffer(t *testing.T) { + rb := NewRingBuffer(64) + got := rb.Bytes() + if len(got) != 0 { + t.Fatalf("expected empty slice from fresh buffer, got %d bytes: %q", len(got), got) + } + if rb.Len() != 0 { + t.Fatalf("expected Len()=0 on fresh buffer, got %d", rb.Len()) + } +} + +func TestRingBuffer_BasicWriteRead(t *testing.T) { + rb := NewRingBuffer(64) + data := []byte("hello world") + rb.Write(data) + + got := rb.Bytes() + if !bytes.Equal(got, data) { + t.Fatalf("expected %q, got %q", data, got) + } + if rb.Len() != len(data) { + t.Fatalf("expected Len()=%d, got %d", len(data), rb.Len()) + } +} + +func TestRingBuffer_WrapAround(t *testing.T) { + const size = 16 + rb := NewRingBuffer(size) + + // Write 24 bytes into a 16-byte buffer; first 8 bytes should be lost. + data := []byte("AAAAAAAA" + "BBBBBBBB" + "CCCCCCCC") + rb.Write(data) + + got := rb.Bytes() + expected := data[len(data)-size:] + if !bytes.Equal(got, expected) { + t.Fatalf("expected last %d bytes %q, got %q", size, expected, got) + } + if rb.Len() != size { + t.Fatalf("expected Len()=%d after wrap, got %d", size, rb.Len()) + } +} + +func TestRingBuffer_ExactSizeWrite(t *testing.T) { + const size = 32 + rb := NewRingBuffer(size) + + data := bytes.Repeat([]byte("X"), size) + rb.Write(data) + + got := rb.Bytes() + if !bytes.Equal(got, data) { + t.Fatalf("expected %q, got %q", data, got) + } + if rb.Len() != size { + t.Fatalf("expected Len()=%d, got %d", size, rb.Len()) + } +} + +func TestRingBuffer_MultipleSmallWrites(t *testing.T) { + const size = 16 + rb := NewRingBuffer(size) + + // Write 5 bytes at a time, 8 times = 40 bytes total into a 16-byte buffer. + var allData []byte + for i := 0; i < 8; i++ { + chunk := bytes.Repeat([]byte{byte('A' + i)}, 5) + rb.Write(chunk) + allData = append(allData, chunk...) + } + + got := rb.Bytes() + expected := allData[len(allData)-size:] + if !bytes.Equal(got, expected) { + t.Fatalf("expected %q, got %q", expected, got) + } + if rb.Len() != size { + t.Fatalf("expected Len()=%d, got %d", size, rb.Len()) + } +} + +func TestRingBuffer_SingleByteWrites(t *testing.T) { + const size = 8 + rb := NewRingBuffer(size) + + // Write 20 single bytes (0..19); last 8 should survive: 12..19 + for i := 0; i < 20; i++ { + rb.Write([]byte{byte(i)}) + } + + got := rb.Bytes() + if len(got) != size { + t.Fatalf("expected %d bytes, got %d", size, len(got)) + } + for i := 0; i < size; i++ { + expected := byte(12 + i) + if got[i] != expected { + t.Fatalf("byte %d: expected %d, got %d", i, expected, got[i]) + } + } +} + +func TestRingBuffer_LargeSingleWrite(t *testing.T) { + const size = 32 + rb := NewRingBuffer(size) + + // Write 3x buffer size in one call. + data := make([]byte, size*3) + for i := range data { + data[i] = byte(i % 256) + } + rb.Write(data) + + got := rb.Bytes() + expected := data[len(data)-size:] + if !bytes.Equal(got, expected) { + t.Fatalf("expected last %d bytes, got mismatch.\nexpected: %v\ngot: %v", size, expected, got) + } + if rb.Len() != size { + t.Fatalf("expected Len()=%d, got %d", size, rb.Len()) + } +} + +func TestRingBuffer_LenBeforeAndAfterWrap(t *testing.T) { + const size = 16 + rb := NewRingBuffer(size) + + // Before any writes + if rb.Len() != 0 { + t.Fatalf("expected Len()=0, got %d", rb.Len()) + } + + // Partial fill + rb.Write([]byte("12345")) + if rb.Len() != 5 { + t.Fatalf("expected Len()=5, got %d", rb.Len()) + } + + // Fill to capacity + rb.Write(bytes.Repeat([]byte("X"), 11)) + if rb.Len() != size { + t.Fatalf("expected Len()=%d after filling, got %d", size, rb.Len()) + } + + // Overflow + rb.Write([]byte("more data")) + if rb.Len() != size { + t.Fatalf("expected Len()=%d after overflow, got %d", size, rb.Len()) + } +} + +func TestRingBuffer_ZeroLengthWrite(t *testing.T) { + const size = 16 + rb := NewRingBuffer(size) + + // Write some data first. + rb.Write([]byte("hello")) + before := rb.Bytes() + + // Write empty slice. + rb.Write([]byte{}) + after := rb.Bytes() + + if !bytes.Equal(before, after) { + t.Fatalf("zero-length write changed buffer: before=%q after=%q", before, after) + } + if rb.Len() != 5 { + t.Fatalf("expected Len()=5, got %d", rb.Len()) + } + + // Also test on fresh buffer. + rb2 := NewRingBuffer(size) + rb2.Write([]byte{}) + if rb2.Len() != 0 { + t.Fatalf("expected Len()=0 after empty write on fresh buffer, got %d", rb2.Len()) + } + if len(rb2.Bytes()) != 0 { + t.Fatalf("expected empty Bytes() after empty write on fresh buffer") + } +} + +func TestRingBuffer_ConcurrentAccess(t *testing.T) { + const size = 256 + const writers = 8 + const writesPerGoroutine = 1000 + + rb := NewRingBuffer(size) + var wg sync.WaitGroup + + // Spawn writers. + for w := 0; w < writers; w++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + chunk := bytes.Repeat([]byte{byte(id)}, 7) + for i := 0; i < writesPerGoroutine; i++ { + rb.Write(chunk) + } + }(w) + } + + // Spawn concurrent readers. + done := make(chan struct{}) + var readerWg sync.WaitGroup + for r := 0; r < 4; r++ { + readerWg.Add(1) + go func() { + defer readerWg.Done() + for { + select { + case <-done: + return + default: + got := rb.Bytes() + l := rb.Len() + // Len should never exceed buffer size. + if l > size { + t.Errorf("Len()=%d exceeds buffer size %d", l, size) + return + } + // Bytes length should never exceed buffer size. + if len(got) > size { + t.Errorf("Bytes() returned %d bytes, exceeds buffer size %d", len(got), size) + return + } + } + } + }() + } + + wg.Wait() + close(done) + readerWg.Wait() + + // After all writes, buffer should be full. + if rb.Len() != size { + t.Fatalf("expected Len()=%d after stress test, got %d", size, rb.Len()) + } + got := rb.Bytes() + if len(got) != size { + t.Fatalf("expected Bytes() to return %d bytes, got %d", size, len(got)) + } +} + +func TestRingBuffer_BytesReturnsCopy(t *testing.T) { + rb := NewRingBuffer(32) + rb.Write([]byte("original")) + + got := rb.Bytes() + // Mutate the returned slice. + for i := range got { + got[i] = 'Z' + } + + // The buffer should be unaffected. + after := rb.Bytes() + if !bytes.Equal(after, []byte("original")) { + t.Fatalf("Bytes() did not return a copy; buffer was mutated to %q", after) + } +} + +func TestRingBuffer_SequentialWrapVerification(t *testing.T) { + // Verify exact ordering across multiple wrap-arounds. + const size = 10 + rb := NewRingBuffer(size) + + // Write 7 bytes, then 8 bytes (total 15, wraps once). + rb.Write([]byte("abcdefg")) // pos=7, not full + rb.Write([]byte("hijklmno")) // wraps: first 3 fill pos 7-9, sets full, then 5 go to pos 0-4, pos=5 + + got := rb.Bytes() + // Total 15 bytes in 10-byte buffer: last 10 are "fghijklmno" + expected := []byte("fghijklmno") + if !bytes.Equal(got, expected) { + t.Fatalf("expected %q, got %q", expected, got) + } + + // Write 3 more bytes: "pqr" + rb.Write([]byte("pqr")) + got = rb.Bytes() + // Previous state: buf contains wrapping data ending at pos=5. + // After "pqr": pos moves 5->8, last 10 of all 18 bytes written = "ijklmnopqr" + expected = []byte("ijklmnopqr") + if !bytes.Equal(got, expected) { + t.Fatalf("expected %q after second wrap, got %q", expected, got) + } +} diff --git a/daemon/local/rpc.go b/daemon/local/rpc.go new file mode 100644 index 0000000000..491197215f --- /dev/null +++ b/daemon/local/rpc.go @@ -0,0 +1,229 @@ +package local + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "sync" +) + +// RPCRequest is an incoming JSON-RPC request from a client. +type RPCRequest struct { + ID any `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +// RPCError describes a structured error returned by an RPC method. +type RPCError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// RPCResponse is a JSON-RPC response sent back to the client. +type RPCResponse struct { + ID any `json:"id,omitempty"` + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +// RPCEvent is a server-pushed event (not tied to a request ID). +type RPCEvent struct { + Event string `json:"event"` + SessionID string `json:"session_id,omitempty"` + PaneID string `json:"pane_id,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + ReplayDone bool `json:"replay_done,omitempty"` + Error string `json:"error,omitempty"` +} + +// FrameWriter writes newline-delimited JSON frames to a writer, thread-safe. +type FrameWriter struct { + mu sync.Mutex + writer *bufio.Writer +} + +// NewFrameWriter wraps a writer with buffered, mutex-protected JSON framing. +func NewFrameWriter(w io.Writer) *FrameWriter { + return &FrameWriter{ + writer: bufio.NewWriter(w), + } +} + +// WriteResponse marshals and writes a response frame. +func (fw *FrameWriter) WriteResponse(resp RPCResponse) error { + return fw.writeJSONFrame(resp) +} + +// WriteEvent marshals and writes an event frame. +func (fw *FrameWriter) WriteEvent(event RPCEvent) error { + return fw.writeJSONFrame(event) +} + +func (fw *FrameWriter) writeJSONFrame(payload any) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + fw.mu.Lock() + defer fw.mu.Unlock() + if _, err := fw.writer.Write(data); err != nil { + return err + } + if err := fw.writer.WriteByte('\n'); err != nil { + return err + } + return fw.writer.Flush() +} + +const maxRPCFrameBytes = 4 * 1024 * 1024 + +// ReadRPCFrame reads a single newline-terminated JSON frame. +// Returns the raw bytes, whether the frame was oversized, and any read error. +func ReadRPCFrame(reader *bufio.Reader) ([]byte, bool, error) { + frame := make([]byte, 0, 1024) + for { + chunk, err := reader.ReadSlice('\n') + if len(chunk) > 0 { + if len(frame)+len(chunk) > maxRPCFrameBytes { + if errors.Is(err, bufio.ErrBufferFull) { + if drainErr := discardUntilNewline(reader); drainErr != nil && !errors.Is(drainErr, io.EOF) { + return nil, false, drainErr + } + } + return nil, true, nil + } + frame = append(frame, chunk...) + } + + if err == nil { + return frame, false, nil + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) { + if len(frame) == 0 { + return nil, false, io.EOF + } + return frame, false, nil + } + return nil, false, err + } +} + +func discardUntilNewline(reader *bufio.Reader) error { + for { + _, err := reader.ReadSlice('\n') + if err == nil || errors.Is(err, io.EOF) { + return err + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + return err + } +} + +// ErrorResponse builds an error RPCResponse for the given request ID. +func ErrorResponse(id any, code, message string) RPCResponse { + return RPCResponse{ + ID: id, + OK: false, + Error: &RPCError{ + Code: code, + Message: message, + }, + } +} + +// OKResponse builds a success RPCResponse for the given request ID. +func OKResponse(id any, result any) RPCResponse { + return RPCResponse{ + ID: id, + OK: true, + Result: result, + } +} + +// GetStringParam extracts a string from an RPC params map. +func GetStringParam(params map[string]any, key string) (string, bool) { + if params == nil { + return "", false + } + raw, ok := params[key] + if !ok || raw == nil { + return "", false + } + value, ok := raw.(string) + return value, ok +} + +// GetIntParam extracts an integer from an RPC params map, +// handling the various numeric types JSON can decode into. +func GetIntParam(params map[string]any, key string) (int, bool) { + if params == nil { + return 0, false + } + raw, ok := params[key] + if !ok || raw == nil { + return 0, false + } + switch value := raw.(type) { + case int: + return value, true + case float64: + if math.Trunc(value) != value { + return 0, false + } + return int(value), true + case json.Number: + n, err := value.Int64() + if err != nil { + return 0, false + } + return int(n), true + default: + return 0, false + } +} + +// GetMapParam extracts a map[string]any from an RPC params map. +func GetMapParam(params map[string]any, key string) (map[string]any, bool) { + if params == nil { + return nil, false + } + raw, ok := params[key] + if !ok || raw == nil { + return nil, false + } + value, ok := raw.(map[string]any) + return value, ok +} + +// GetStringMapParam extracts a map[string]string from an RPC params map. +// JSON decodes maps as map[string]any, so this converts values to strings. +func GetStringMapParam(params map[string]any, key string) (map[string]string, bool) { + raw, ok := GetMapParam(params, key) + if !ok { + return nil, false + } + result := make(map[string]string, len(raw)) + for k, v := range raw { + s, ok := v.(string) + if !ok { + return nil, false + } + result[k] = s + } + return result, true +} + +// FormatError formats an error code and message for CLI display. +func FormatError(code, message string) string { + return fmt.Sprintf("[%s] %s", code, message) +} diff --git a/daemon/local/session.go b/daemon/local/session.go new file mode 100644 index 0000000000..bfce8e4fe1 --- /dev/null +++ b/daemon/local/session.go @@ -0,0 +1,867 @@ +package local + +import ( + "encoding/base64" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + "time" + + "github.com/creack/pty" +) + +// Status types for sessions, windows, and panes. +type SessionStatus string + +const ( + SessionRunning SessionStatus = "running" + SessionDead SessionStatus = "dead" +) + +const ringBufferSize = 2 * 1024 * 1024 // 2MB + +// --------------------------------------------------------------------------- +// Pane — each pane owns a PTY, ring buffer, and output fan-out. +// --------------------------------------------------------------------------- + +// Pane represents a single PTY within a window. +type Pane struct { + ID string + Shell string + CreatedAt time.Time + Status SessionStatus + Pid int + ExitCode int + + cols int + rows int + + cmd *exec.Cmd + ptmx *os.File + ring *RingBuffer + + mu sync.Mutex + clients map[string]*FrameWriter // attachment_id -> writer + + // IDs of parent session and window, used in events. + sessionID string + windowID string + + done chan struct{} +} + +// newPane spawns a new PTY pane. +func newPane(id, sessionID, windowID, shell string, cols, rows int, env map[string]string) (*Pane, error) { + if shell == "" { + shell = os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + } + if cols <= 0 { + cols = 80 + } + if rows <= 0 { + rows = 24 + } + + cmd := exec.Command(shell) + cmd.Env = buildEnv(env) + + winSize := &pty.Winsize{ + Cols: uint16(cols), + Rows: uint16(rows), + } + ptmx, err := pty.StartWithSize(cmd, winSize) + if err != nil { + return nil, fmt.Errorf("failed to start pty: %w", err) + } + + p := &Pane{ + ID: id, + Shell: shell, + CreatedAt: time.Now().UTC(), + Status: SessionRunning, + Pid: cmd.Process.Pid, + cols: cols, + rows: rows, + cmd: cmd, + ptmx: ptmx, + ring: NewRingBuffer(ringBufferSize), + clients: make(map[string]*FrameWriter), + sessionID: sessionID, + windowID: windowID, + done: make(chan struct{}), + } + + go p.readLoop() + go p.waitLoop() + + return p, nil +} + +// readLoop reads PTY output, stores it in the ring buffer, and fans out to attached clients. +func (p *Pane) readLoop() { + buf := make([]byte, 32768) + for { + n, err := p.ptmx.Read(buf) + if n > 0 { + data := make([]byte, n) + copy(data, buf[:n]) + + p.ring.Write(data) + + // Collect clients under lock, then write outside lock to avoid + // blocking all pane operations if a client connection is slow. + p.mu.Lock() + clients := make([]*FrameWriter, 0, len(p.clients)) + for _, fw := range p.clients { + clients = append(clients, fw) + } + p.mu.Unlock() + + encoded := base64.StdEncoding.EncodeToString(data) + for _, fw := range clients { + _ = fw.WriteEvent(RPCEvent{ + Event: "pty.output", + SessionID: p.sessionID, + PaneID: p.ID, + DataBase64: encoded, + }) + } + } + if err != nil { + return + } + } +} + +// waitLoop waits for the child process to exit and updates pane status. +func (p *Pane) waitLoop() { + err := p.cmd.Wait() + p.mu.Lock() + p.Status = SessionDead + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + p.ExitCode = exitErr.ExitCode() + } else { + p.ExitCode = -1 + } + } + for _, fw := range p.clients { + _ = fw.WriteEvent(RPCEvent{ + Event: "pane.exited", + SessionID: p.sessionID, + PaneID: p.ID, + }) + // Also emit session.exited for backward compatibility with older clients. + _ = fw.WriteEvent(RPCEvent{ + Event: "session.exited", + SessionID: p.sessionID, + }) + } + p.mu.Unlock() + close(p.done) +} + +// WriteInput sends raw bytes to the PTY. +func (p *Pane) WriteInput(data []byte) error { + _, err := p.ptmx.Write(data) + return err +} + +// Resize changes the PTY window size. +func (p *Pane) Resize(cols, rows int) error { + p.mu.Lock() + defer p.mu.Unlock() + p.cols = cols + p.rows = rows + return pty.Setsize(p.ptmx, &pty.Winsize{ + Cols: uint16(cols), + Rows: uint16(rows), + }) +} + +// Attach registers a client writer. Returns current ring buffer contents for replay. +// The snapshot and registration are done under the same lock so no PTY output +// is lost between the snapshot and the client becoming visible to readLoop. +func (p *Pane) Attach(attachmentID string, fw *FrameWriter) []byte { + p.mu.Lock() + defer p.mu.Unlock() + replay := p.ring.Bytes() // ring.Bytes() acquires ring.mu internally (leaf lock) + p.clients[attachmentID] = fw + return replay +} + +// Detach removes a client from the pane. +func (p *Pane) Detach(attachmentID string) { + p.mu.Lock() + delete(p.clients, attachmentID) + p.mu.Unlock() +} + +// AttachedCount returns how many clients are currently attached. +func (p *Pane) AttachedCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.clients) +} + +// Close kills the child process and closes the PTY. +func (p *Pane) Close() { + if p.cmd.Process != nil { + _ = p.cmd.Process.Signal(syscall.SIGHUP) + go func() { + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + select { + case <-p.done: + case <-timer.C: + _ = p.cmd.Process.Kill() + } + }() + } + _ = p.ptmx.Close() +} + +// Snapshot returns a JSON-serializable map of pane state. +func (p *Pane) Snapshot() map[string]any { + p.mu.Lock() + defer p.mu.Unlock() + + return map[string]any{ + "pane_id": p.ID, + "shell": p.Shell, + "pid": p.Pid, + "status": string(p.Status), + "exit_code": p.ExitCode, + "created_at": p.CreatedAt.Format(time.RFC3339), + "cols": p.cols, + "rows": p.rows, + "attached": len(p.clients), + } +} + +// --------------------------------------------------------------------------- +// Window — logical grouping of panes. +// --------------------------------------------------------------------------- + +// Window represents a logical grouping of panes within a session. +type Window struct { + ID string + Name string + CreatedAt time.Time + Status SessionStatus + ActivePaneID string + + mu sync.Mutex + panes map[string]*Pane + nextID uint64 +} + +// newWindow creates an empty window. +func newWindow(id, name string) *Window { + if name == "" { + name = id + } + return &Window{ + ID: id, + Name: name, + CreatedAt: time.Now().UTC(), + Status: SessionRunning, + panes: make(map[string]*Pane), + nextID: 1, + } +} + +// AddPane creates a new pane in this window. +func (w *Window) AddPane(sessionID, shell string, cols, rows int, env map[string]string) (*Pane, error) { + w.mu.Lock() + paneID := fmt.Sprintf("%s.pane-%d", w.ID, w.nextID) + w.nextID++ + w.mu.Unlock() + + p, err := newPane(paneID, sessionID, w.ID, shell, cols, rows, env) + if err != nil { + return nil, err + } + + w.mu.Lock() + w.panes[p.ID] = p + if w.ActivePaneID == "" { + w.ActivePaneID = p.ID + } + w.mu.Unlock() + + return p, nil +} + +// GetPane returns a pane by ID. +func (w *Window) GetPane(paneID string) (*Pane, bool) { + w.mu.Lock() + defer w.mu.Unlock() + p, ok := w.panes[paneID] + return p, ok +} + +// ActivePane returns the active pane, or nil. +func (w *Window) ActivePane() *Pane { + w.mu.Lock() + defer w.mu.Unlock() + return w.panes[w.ActivePaneID] +} + +// RemovePane removes a pane from the window and closes it. +func (w *Window) RemovePane(paneID string) bool { + w.mu.Lock() + p, ok := w.panes[paneID] + if ok { + delete(w.panes, paneID) + // If the active pane was removed, pick another. + if w.ActivePaneID == paneID { + w.ActivePaneID = "" + for id, pp := range w.panes { + if pp.Status == SessionRunning { + w.ActivePaneID = id + break + } + } + // If no running pane, just pick any. + if w.ActivePaneID == "" { + for id := range w.panes { + w.ActivePaneID = id + break + } + } + } + // If no panes left, mark window dead. + if len(w.panes) == 0 { + w.Status = SessionDead + } + } + w.mu.Unlock() + if ok { + p.Close() + } + return ok +} + +// Close closes all panes in the window. +func (w *Window) Close() { + w.mu.Lock() + panes := make([]*Pane, 0, len(w.panes)) + for _, p := range w.panes { + panes = append(panes, p) + } + w.panes = make(map[string]*Pane) + w.Status = SessionDead + w.mu.Unlock() + + for _, p := range panes { + p.Close() + } +} + +// UpdateStatus checks all panes and marks window dead if all panes are dead. +func (w *Window) UpdateStatus() { + w.mu.Lock() + defer w.mu.Unlock() + if len(w.panes) == 0 { + w.Status = SessionDead + return + } + allDead := true + for _, p := range w.panes { + p.mu.Lock() + running := p.Status == SessionRunning + p.mu.Unlock() + if running { + allDead = false + break + } + } + if allDead { + w.Status = SessionDead + } +} + +// PaneCount returns the number of panes. +func (w *Window) PaneCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.panes) +} + +// Snapshot returns a JSON-serializable map of window state. +func (w *Window) Snapshot() map[string]any { + w.mu.Lock() + defer w.mu.Unlock() + + panes := make([]map[string]any, 0, len(w.panes)) + for _, p := range w.panes { + panes = append(panes, p.Snapshot()) + } + + return map[string]any{ + "window_id": w.ID, + "name": w.Name, + "status": string(w.Status), + "created_at": w.CreatedAt.Format(time.RFC3339), + "active_pane_id": w.ActivePaneID, + "pane_count": len(w.panes), + "panes": panes, + } +} + +// --------------------------------------------------------------------------- +// Session — contains windows. +// --------------------------------------------------------------------------- + +// Session represents a terminal session containing one or more windows. +type Session struct { + ID string + Name string + Shell string + CreatedAt time.Time + Status SessionStatus + ActiveWindowID string + + mu sync.Mutex + windows map[string]*Window + nextID uint64 +} + +// AddWindow creates a new window in this session with one initial pane. +func (s *Session) AddWindow(name, shell string, cols, rows int, env map[string]string) (*Window, *Pane, error) { + s.mu.Lock() + winID := fmt.Sprintf("%s.win-%d", s.ID, s.nextID) + s.nextID++ + s.mu.Unlock() + + w := newWindow(winID, name) + + p, err := w.AddPane(s.ID, shell, cols, rows, env) + if err != nil { + return nil, nil, err + } + + s.mu.Lock() + s.windows[w.ID] = w + if s.ActiveWindowID == "" { + s.ActiveWindowID = w.ID + } + s.mu.Unlock() + + return w, p, nil +} + +// GetWindow returns a window by ID. +func (s *Session) GetWindow(windowID string) (*Window, bool) { + s.mu.Lock() + defer s.mu.Unlock() + w, ok := s.windows[windowID] + return w, ok +} + +// ActiveWindow returns the active window, or nil. +func (s *Session) ActiveWindow() *Window { + s.mu.Lock() + defer s.mu.Unlock() + return s.windows[s.ActiveWindowID] +} + +// GetActiveWindowID returns the active window ID. Thread-safe. +func (s *Session) GetActiveWindowID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.ActiveWindowID +} + +// ActivePane returns the active pane of the active window, or nil. +func (s *Session) ActivePane() *Pane { + w := s.ActiveWindow() + if w == nil { + return nil + } + return w.ActivePane() +} + +// FindPane searches all windows for a pane by ID. +func (s *Session) FindPane(paneID string) (*Pane, *Window, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, w := range s.windows { + if p, ok := w.GetPane(paneID); ok { + return p, w, true + } + } + return nil, nil, false +} + +// SetActivePaneByID sets the active window and pane to match the given pane ID. +func (s *Session) SetActivePaneByID(paneID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, w := range s.windows { + if _, ok := w.GetPane(paneID); ok { + s.ActiveWindowID = w.ID + w.mu.Lock() + w.ActivePaneID = paneID + w.mu.Unlock() + return true + } + } + return false +} + +// RemoveWindow removes a window and closes all its panes. +func (s *Session) RemoveWindow(windowID string) bool { + s.mu.Lock() + w, ok := s.windows[windowID] + if ok { + delete(s.windows, windowID) + if s.ActiveWindowID == windowID { + s.ActiveWindowID = "" + for id, ww := range s.windows { + if ww.Status == SessionRunning { + s.ActiveWindowID = id + break + } + } + if s.ActiveWindowID == "" { + for id := range s.windows { + s.ActiveWindowID = id + break + } + } + } + if len(s.windows) == 0 { + s.Status = SessionDead + } + } + s.mu.Unlock() + if ok { + w.Close() + } + return ok +} + +// ListWindows returns snapshots of all windows. +func (s *Session) ListWindows() []map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]map[string]any, 0, len(s.windows)) + for _, w := range s.windows { + result = append(result, w.Snapshot()) + } + return result +} + +// WindowCount returns the number of windows. +func (s *Session) WindowCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.windows) +} + +// TotalPaneCount returns the total number of panes across all windows. +func (s *Session) TotalPaneCount() int { + s.mu.Lock() + defer s.mu.Unlock() + total := 0 + for _, w := range s.windows { + total += w.PaneCount() + } + return total +} + +// Backward-compatible methods that delegate to the active pane. + +// WriteInput sends input to the active pane. +func (s *Session) WriteInput(data []byte) error { + p := s.ActivePane() + if p == nil { + return fmt.Errorf("no active pane") + } + return p.WriteInput(data) +} + +// Resize changes the active pane's PTY window size. +func (s *Session) Resize(cols, rows int) error { + p := s.ActivePane() + if p == nil { + return fmt.Errorf("no active pane") + } + return p.Resize(cols, rows) +} + +// Attach attaches to the active pane. Returns replay data. +func (s *Session) Attach(attachmentID string, fw *FrameWriter) []byte { + p := s.ActivePane() + if p == nil { + return nil + } + return p.Attach(attachmentID, fw) +} + +// Detach detaches from all panes in this session for the given attachment. +func (s *Session) Detach(attachmentID string) { + s.mu.Lock() + defer s.mu.Unlock() + for _, w := range s.windows { + w.mu.Lock() + for _, p := range w.panes { + p.Detach(attachmentID) + } + w.mu.Unlock() + } +} + +// AttachedCount returns the number of clients attached to the active pane. +func (s *Session) AttachedCount() int { + p := s.ActivePane() + if p == nil { + return 0 + } + return p.AttachedCount() +} + +// Close kills all panes in all windows. +func (s *Session) Close() { + s.mu.Lock() + windows := make([]*Window, 0, len(s.windows)) + for _, w := range s.windows { + windows = append(windows, w) + } + s.windows = make(map[string]*Window) + s.Status = SessionDead + s.mu.Unlock() + + for _, w := range windows { + w.Close() + } +} + +// Snapshot returns a JSON-serializable map of session state. +func (s *Session) Snapshot() map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + + // Compute total attached across all panes. + totalAttached := 0 + attachmentSet := make(map[string]struct{}) + for _, w := range s.windows { + w.mu.Lock() + for _, p := range w.panes { + p.mu.Lock() + for id := range p.clients { + attachmentSet[id] = struct{}{} + } + p.mu.Unlock() + } + w.mu.Unlock() + } + totalAttached = len(attachmentSet) + + attachments := make([]string, 0, len(attachmentSet)) + for id := range attachmentSet { + attachments = append(attachments, id) + } + + // Get first pane's PID for backward compat. + pid := 0 + var exitCode int + if w, ok := s.windows[s.ActiveWindowID]; ok { + if p := w.ActivePane(); p != nil { + pid = p.Pid + exitCode = p.ExitCode + } + } + + // Compute counts. + windowCount := len(s.windows) + paneCount := 0 + for _, w := range s.windows { + paneCount += w.PaneCount() + } + + // Get active pane ID. + activePaneID := "" + if w, ok := s.windows[s.ActiveWindowID]; ok { + activePaneID = w.ActivePaneID + } + + return map[string]any{ + "session_id": s.ID, + "name": s.Name, + "shell": s.Shell, + "pid": pid, + "status": string(s.Status), + "exit_code": exitCode, + "created_at": s.CreatedAt.Format(time.RFC3339), + "attached": totalAttached, + "attachments": attachments, + "window_count": windowCount, + "pane_count": paneCount, + "active_window_id": s.ActiveWindowID, + "active_pane_id": activePaneID, + } +} + +// --------------------------------------------------------------------------- +// SessionManager +// --------------------------------------------------------------------------- + +// SessionManager owns all sessions and provides thread-safe access. +type SessionManager struct { + mu sync.Mutex + sessions map[string]*Session + nextID uint64 +} + +// NewSessionManager creates a new manager. +func NewSessionManager() *SessionManager { + return &SessionManager{ + sessions: make(map[string]*Session), + nextID: 1, + } +} + +// NewSessionParams are the parameters for creating a new session. +type NewSessionParams struct { + Name string + Shell string + Cols int + Rows int + Env map[string]string +} + +// Create spawns a new session with one window containing one pane. +func (sm *SessionManager) Create(params NewSessionParams) (*Session, error) { + shell := params.Shell + if shell == "" { + shell = os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + } + + sm.mu.Lock() + id := fmt.Sprintf("sess-%d", sm.nextID) + sm.nextID++ + sm.mu.Unlock() + + name := params.Name + if name == "" { + name = id + } + + sess := &Session{ + ID: id, + Name: name, + Shell: shell, + CreatedAt: time.Now().UTC(), + Status: SessionRunning, + windows: make(map[string]*Window), + nextID: 1, + } + + _, _, err := sess.AddWindow("", shell, params.Cols, params.Rows, params.Env) + if err != nil { + return nil, err + } + + sm.mu.Lock() + sm.sessions[id] = sess + sm.mu.Unlock() + + return sess, nil +} + +func buildEnv(extra map[string]string) []string { + env := os.Environ() + hasterm := false + for _, e := range env { + if len(e) > 5 && e[:5] == "TERM=" { + hasterm = true + break + } + } + if !hasterm { + env = append(env, "TERM=xterm-256color") + } + for k, v := range extra { + env = append(env, k+"="+v) + } + return env +} + +// Get returns a session by ID. Thread-safe. +func (sm *SessionManager) Get(id string) (*Session, bool) { + sm.mu.Lock() + defer sm.mu.Unlock() + s, ok := sm.sessions[id] + return s, ok +} + +// GetByName returns the first session matching the given name. Thread-safe. +func (sm *SessionManager) GetByName(name string) (*Session, bool) { + sm.mu.Lock() + defer sm.mu.Unlock() + for _, s := range sm.sessions { + if s.Name == name { + return s, true + } + } + return nil, false +} + +// FindByNameOrID finds a session by name first, then by ID. +func (sm *SessionManager) FindByNameOrID(target string) (*Session, bool) { + if s, ok := sm.GetByName(target); ok { + return s, true + } + return sm.Get(target) +} + +// List returns snapshots of all sessions. +func (sm *SessionManager) List() []map[string]any { + sm.mu.Lock() + defer sm.mu.Unlock() + + result := make([]map[string]any, 0, len(sm.sessions)) + for _, s := range sm.sessions { + result = append(result, s.Snapshot()) + } + return result +} + +// Remove removes a session from the manager and closes it. +func (sm *SessionManager) Remove(id string) bool { + sm.mu.Lock() + s, ok := sm.sessions[id] + if ok { + delete(sm.sessions, id) + } + sm.mu.Unlock() + if ok { + s.Close() + } + return ok +} + +// CloseAll closes all sessions. Called on daemon shutdown. +func (sm *SessionManager) CloseAll() { + sm.mu.Lock() + sessions := make([]*Session, 0, len(sm.sessions)) + for id, s := range sm.sessions { + sessions = append(sessions, s) + delete(sm.sessions, id) + } + sm.mu.Unlock() + for _, s := range sessions { + s.Close() + } +} diff --git a/daemon/local/test_e2e.sh b/daemon/local/test_e2e.sh new file mode 100755 index 0000000000..3053c493c8 --- /dev/null +++ b/daemon/local/test_e2e.sh @@ -0,0 +1,597 @@ +#!/usr/bin/env bash +# +# End-to-end test for the cmuxd-local daemon and cmux-local CLI. +# +# Exercises the full daemon lifecycle via JSON-RPC over Unix socket: +# - Session create / list / close +# - Persistence across daemon restart +# - Multiple sessions +# - Resize +# +# Usage: bash daemon/local/test_e2e.sh + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +PASS_COUNT=0 +FAIL_COUNT=0 +DAEMON_PID="" +TMPDIR_TEST="" + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + echo " PASS: $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + echo " FAIL: $1" + echo " $2" +} + +cleanup() { + if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then + kill "$DAEMON_PID" 2>/dev/null || true + wait "$DAEMON_PID" 2>/dev/null || true + fi + if [ -n "$TMPDIR_TEST" ] && [ -d "$TMPDIR_TEST" ]; then + rm -rf "$TMPDIR_TEST" + fi + # Clean up test socket and state file + rm -f "$SOCKET_PATH" "$STATE_PATH" 2>/dev/null || true +} + +trap cleanup EXIT + +# Send an RPC request over the Unix socket and return the response. +# Uses a short-lived connection per request (like the CLI's rpcCall). +# All JSON construction and socket I/O is done in Python to avoid +# shell quoting issues with nested JSON. +rpc() { + local method="$1" + local params="${2+$2}" + if [ -z "$params" ]; then params="{}"; fi + local id="${3:-1}" + # Write params to a temp file to avoid all shell quoting issues + local params_file + params_file="$(mktemp)" + printf '%s' "$params" > "$params_file" + python3 - "$SOCKET_PATH" "$method" "$id" "$params_file" << 'PYEOF' +import socket, json, sys, os + +socket_path = sys.argv[1] +method = sys.argv[2] +req_id = int(sys.argv[3]) +params_file = sys.argv[4] + +with open(params_file) as f: + params_str = f.read() +os.unlink(params_file) + +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.settimeout(5) +try: + sock.connect(socket_path) +except Exception as e: + print(json.dumps({"ok": False, "error": {"code": "connect_failed", "message": str(e)}})) + sys.exit(0) + +try: + params = json.loads(params_str) if params_str.strip() else {} +except json.JSONDecodeError: + params = {} +request = json.dumps({"id": req_id, "method": method, "params": params}) +sock.sendall((request + "\n").encode()) + +buf = b"" +while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + buf += chunk + if b"\n" in buf: + break + +sock.close() + +lines = buf.decode().strip().split("\n") +if lines and lines[0]: + print(lines[0]) +else: + print(json.dumps({"ok": False, "error": {"code": "no_response", "message": "empty response"}})) +PYEOF +} + +# Extract a field from JSON using python3 (portable, no jq dependency). +# Passes JSON via stdin to avoid shell quoting issues. +json_field() { + local json="$1" + local field="$2" + echo "$json" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +keys = sys.argv[1].split('.') +val = data +for k in keys: + if isinstance(val, dict): + val = val.get(k) + else: + val = None + break +if val is None: + sys.exit(1) +print(val) +" "$field" 2>/dev/null +} + +# Count sessions from a session.list response. +count_sessions() { + local json="$1" + echo "$json" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +sessions = data.get('result', {}).get('sessions', []) +print(len(sessions)) +" +} + +# Extract session_id from session.new or session.list result. +get_session_id() { + local json="$1" + echo "$json" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +print(data['result']['session_id']) +" +} + +# Get a field from a specific session in a list response by name. +get_session_field_by_name() { + local json="$1" + local name="$2" + local field="$3" + echo "$json" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +name = sys.argv[1] +field = sys.argv[2] +sessions = data.get('result', {}).get('sessions', []) +for s in sessions: + if s.get('name') == name: + print(s.get(field, '')) + break +" "$name" "$field" +} + +reset_state() { + rm -f "$SOCKET_PATH" "$STATE_PATH" 2>/dev/null || true +} + +wait_for_socket() { + local max_wait=5 + local waited=0 + while [ ! -S "$SOCKET_PATH" ] && [ $waited -lt $max_wait ]; do + sleep 0.2 + waited=$((waited + 1)) + done + if [ ! -S "$SOCKET_PATH" ]; then + echo "ERROR: daemon socket did not appear at $SOCKET_PATH within ${max_wait}s" + return 1 + fi +} + +start_daemon() { + "$DAEMON_BIN" & + DAEMON_PID=$! + wait_for_socket +} + +stop_daemon() { + if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then + kill "$DAEMON_PID" 2>/dev/null || true + wait "$DAEMON_PID" 2>/dev/null || true + fi + DAEMON_PID="" + # Give the OS a moment to release the socket + sleep 0.3 +} + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +echo "=== cmuxd-local E2E Test Suite ===" +echo "" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +TMPDIR_TEST="$(mktemp -d /tmp/cmux-e2e-XXXXXX)" +BIN_DIR="$TMPDIR_TEST/bin" +mkdir -p "$BIN_DIR" + +# Use a test-specific state directory so we don't disturb the user's real state. +export XDG_STATE_HOME="$TMPDIR_TEST/state" +TEST_STATE_DIR="$TMPDIR_TEST/state/cmux" +mkdir -p "$TEST_STATE_DIR" + +# These need to match what the Go code uses. The Go code uses +# ~/.local/state/cmux/ (hardcoded via os.UserHomeDir). To isolate, +# we override HOME so the daemon writes to our temp dir. +export HOME="$TMPDIR_TEST/home" +mkdir -p "$HOME/.local/state/cmux" + +SOCKET_PATH="$HOME/.local/state/cmux/daemon-local.sock" +STATE_PATH="$HOME/.local/state/cmux/daemon-local.json" + +echo "Building binaries..." +(cd "$SCRIPT_DIR" && go build -o "$BIN_DIR/cmuxd-local" ./cmd/cmuxd-local) || { + echo "FATAL: failed to build cmuxd-local" + exit 1 +} +(cd "$SCRIPT_DIR" && go build -o "$BIN_DIR/cmux-local" ./cmd/cmux-local) || { + echo "FATAL: failed to build cmux-local" + exit 1 +} + +DAEMON_BIN="$BIN_DIR/cmuxd-local" +CLI_BIN="$BIN_DIR/cmux-local" + +echo "Binaries built at $BIN_DIR" +echo "Socket: $SOCKET_PATH" +echo "State: $STATE_PATH" +echo "" + +# --------------------------------------------------------------------------- +# Test 1: Session lifecycle (create, list, close) +# --------------------------------------------------------------------------- + +echo "--- Test 1: Session Lifecycle ---" + +reset_state +start_daemon + +# Verify daemon is reachable with a ping +RESP=$(rpc "ping") +OK=$(json_field "$RESP" "ok" || echo "false") +if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then + pass "daemon responds to ping" +else + fail "daemon ping" "response: $RESP" +fi + +# Create a session +RESP=$(rpc "session.new" '{"name":"test1","cols":80,"rows":24}') +OK=$(json_field "$RESP" "ok" || echo "false") +if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then + SESSION1_ID=$(get_session_id "$RESP") + pass "session.new created session $SESSION1_ID" +else + fail "session.new" "response: $RESP" + SESSION1_ID="" +fi + +# List sessions - should have 1 +RESP=$(rpc "session.list") +COUNT=$(count_sessions "$RESP") +if [ "$COUNT" = "1" ]; then + pass "session.list shows 1 session" +else + fail "session.list count" "expected 1, got $COUNT" +fi + +# Close the session +if [ -n "$SESSION1_ID" ]; then + RESP=$(rpc "session.close" "{\"session_id\":\"$SESSION1_ID\"}") + OK=$(json_field "$RESP" "ok" || echo "false") + if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then + pass "session.close succeeded" + else + fail "session.close" "response: $RESP" + fi +fi + +# List again - should be 0 +RESP=$(rpc "session.list") +COUNT=$(count_sessions "$RESP") +if [ "$COUNT" = "0" ]; then + pass "session.list shows 0 after close" +else + fail "session.list after close" "expected 0, got $COUNT" +fi + +stop_daemon +echo "" + +# --------------------------------------------------------------------------- +# Test 2: Persistence across daemon restart +# --------------------------------------------------------------------------- + +echo "--- Test 2: Persistence ---" + +reset_state +start_daemon + +# Create a session +RESP=$(rpc "session.new" '{"name":"persist-me","cols":80,"rows":24}') +PERSIST_ID=$(get_session_id "$RESP") +pass "created session $PERSIST_ID for persistence test" + +# Wait a moment for async persistence +sleep 0.5 + +# Verify state file exists +if [ -f "$STATE_PATH" ]; then + pass "state file exists at $STATE_PATH" +else + fail "state file" "not found at $STATE_PATH" +fi + +# Kill daemon (SIGTERM triggers graceful shutdown with final save) +stop_daemon + +# Verify state file still exists after daemon exit +if [ -f "$STATE_PATH" ]; then + pass "state file persists after daemon shutdown" +else + fail "state file after shutdown" "not found" +fi + +# Remove the stale socket if it exists (daemon cleanup may have removed it) +rm -f "$SOCKET_PATH" 2>/dev/null || true + +# Restart daemon - it should restore the session +start_daemon + +RESP=$(rpc "session.list") +COUNT=$(count_sessions "$RESP") +if [ "$COUNT" = "1" ]; then + pass "session restored after daemon restart (1 session)" +else + fail "session restore" "expected 1 session, got $COUNT. Response: $RESP" +fi + +# Verify the restored session has the right name +RESTORED_NAME=$(get_session_field_by_name "$RESP" "persist-me" "name") +if [ "$RESTORED_NAME" = "persist-me" ]; then + pass "restored session has correct name 'persist-me'" +else + fail "restored session name" "expected 'persist-me', got '$RESTORED_NAME'" +fi + +# Clean up the session +RESP=$(rpc "session.close" "{\"session_id\":\"$PERSIST_ID\"}") +stop_daemon +echo "" + +# --------------------------------------------------------------------------- +# Test 3: Multiple sessions +# --------------------------------------------------------------------------- + +echo "--- Test 3: Multiple Sessions ---" + +reset_state +start_daemon + +# Create 3 sessions +RESP=$(rpc "session.new" '{"name":"alpha","cols":80,"rows":24}' 1) +ALPHA_ID=$(get_session_id "$RESP") +RESP=$(rpc "session.new" '{"name":"beta","cols":80,"rows":24}' 2) +BETA_ID=$(get_session_id "$RESP") +RESP=$(rpc "session.new" '{"name":"gamma","cols":80,"rows":24}' 3) +GAMMA_ID=$(get_session_id "$RESP") + +pass "created 3 sessions: alpha=$ALPHA_ID beta=$BETA_ID gamma=$GAMMA_ID" + +# List should show 3 +RESP=$(rpc "session.list" '{}' 4) +COUNT=$(count_sessions "$RESP") +if [ "$COUNT" = "3" ]; then + pass "session.list shows 3 sessions" +else + fail "session.list 3 sessions" "expected 3, got $COUNT" +fi + +# Close beta +RESP=$(rpc "session.close" "{\"session_id\":\"$BETA_ID\"}" 5) +OK=$(json_field "$RESP" "ok" || echo "false") +if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then + pass "closed beta session" +else + fail "close beta" "response: $RESP" +fi + +# List should show 2 +RESP=$(rpc "session.list" '{}' 6) +COUNT=$(count_sessions "$RESP") +if [ "$COUNT" = "2" ]; then + pass "session.list shows 2 after closing beta" +else + fail "session.list after close beta" "expected 2, got $COUNT" +fi + +# Verify alpha and gamma still exist but beta is gone +ALPHA_CHECK=$(get_session_field_by_name "$RESP" "alpha" "name") +GAMMA_CHECK=$(get_session_field_by_name "$RESP" "gamma" "name") +BETA_CHECK=$(get_session_field_by_name "$RESP" "beta" "name") +if [ "$ALPHA_CHECK" = "alpha" ] && [ "$GAMMA_CHECK" = "gamma" ] && [ -z "$BETA_CHECK" ]; then + pass "alpha and gamma remain, beta is gone" +else + fail "session check after close" "alpha=$ALPHA_CHECK gamma=$GAMMA_CHECK beta=$BETA_CHECK" +fi + +# Cleanup +rpc "session.close" "{\"session_id\":\"$ALPHA_ID\"}" 7 >/dev/null +rpc "session.close" "{\"session_id\":\"$GAMMA_ID\"}" 8 >/dev/null + +stop_daemon +echo "" + +# --------------------------------------------------------------------------- +# Test 4: Resize +# --------------------------------------------------------------------------- + +echo "--- Test 4: Resize ---" + +reset_state +start_daemon + +RESP=$(rpc "session.new" '{"name":"resize-test","cols":80,"rows":24}' 1) +RESIZE_ID=$(get_session_id "$RESP") +pass "created session $RESIZE_ID for resize test" + +# Resize via RPC +RESP=$(rpc "session.resize" "{\"session_id\":\"$RESIZE_ID\",\"cols\":120,\"rows\":40}" 3) +OK=$(json_field "$RESP" "ok" || echo "false") +if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then + pass "session.resize succeeded" +else + fail "session.resize" "response: $RESP" +fi + +# Verify the resize took effect via window.list which includes pane dimensions. +# First, get the active_window_id from the resize response. +WIN_ID=$(echo "$RESP" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +print(data.get('result', {}).get('active_window_id', '')) +" 2>/dev/null) + +RESP2=$(rpc "window.list" "{\"session_id\":\"$RESIZE_ID\"}" 4) +DIMS=$(echo "$RESP2" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +windows = data.get('result', {}).get('windows', []) +for w in windows: + for p in w.get('panes', []): + print(p.get('cols', 'N/A'), p.get('rows', 'N/A')) + break + break +" 2>/dev/null) + +NEW_COLS=$(echo "$DIMS" | awk '{print $1}') +NEW_ROWS=$(echo "$DIMS" | awk '{print $2}') + +if [ "$NEW_COLS" = "120" ]; then + pass "resize updated cols to 120" +else + fail "resize cols" "expected 120, got $NEW_COLS" +fi + +if [ "$NEW_ROWS" = "40" ]; then + pass "resize updated rows to 40" +else + fail "resize rows" "expected 40, got $NEW_ROWS" +fi + +rpc "session.close" "{\"session_id\":\"$RESIZE_ID\"}" 5 >/dev/null + +stop_daemon +echo "" + +# --------------------------------------------------------------------------- +# Test 5: CLI ls command +# --------------------------------------------------------------------------- + +echo "--- Test 5: CLI ls ---" + +reset_state +start_daemon + +# Create two sessions via RPC +rpc "session.new" '{"name":"cli-a","cols":80,"rows":24}' 1 >/dev/null +rpc "session.new" '{"name":"cli-b","cols":80,"rows":24}' 2 >/dev/null + +# Use the CLI binary to list (it reads the same socket) +export CMUX_LOCAL_SOCKET="$SOCKET_PATH" +CLI_OUT=$("$CLI_BIN" ls 2>&1 || true) + +# Verify both sessions appear in CLI output +if echo "$CLI_OUT" | grep -q "cli-a" && echo "$CLI_OUT" | grep -q "cli-b"; then + pass "cmux-local ls shows both sessions" +else + fail "cmux-local ls" "output: $CLI_OUT" +fi + +stop_daemon +echo "" + +# --------------------------------------------------------------------------- +# Test 6: Error handling +# --------------------------------------------------------------------------- + +echo "--- Test 6: Error Handling ---" + +reset_state +start_daemon + +# Close a non-existent session +RESP=$(rpc "session.close" '{"session_id":"no-such-id"}' 1) +OK=$(json_field "$RESP" "ok" || echo "false") +ERR_CODE=$(json_field "$RESP" "error.code" || echo "unknown") +if [ "$OK" = "False" ] || [ "$OK" = "false" ]; then + pass "closing non-existent session returns error ($ERR_CODE)" +else + fail "close non-existent" "expected error, got ok. Response: $RESP" +fi + +# Call unknown method +RESP=$(rpc "no.such.method" '{}' 2) +OK=$(json_field "$RESP" "ok" || echo "false") +if [ "$OK" = "False" ] || [ "$OK" = "false" ]; then + pass "unknown method returns error" +else + fail "unknown method" "expected error. Response: $RESP" +fi + +# Resize with invalid params +RESP=$(rpc "session.resize" '{"session_id":"fake","cols":-1,"rows":0}' 3) +OK=$(json_field "$RESP" "ok" || echo "false") +if [ "$OK" = "False" ] || [ "$OK" = "false" ]; then + pass "resize with invalid params returns error" +else + fail "resize invalid params" "expected error. Response: $RESP" +fi + +stop_daemon +echo "" + +# --------------------------------------------------------------------------- +# Test 7: hello / capability check +# --------------------------------------------------------------------------- + +echo "--- Test 7: Hello ---" + +reset_state +start_daemon + +RESP=$(rpc "hello" '{}' 1) +OK=$(json_field "$RESP" "ok" || echo "false") +if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then + VERSION=$(json_field "$RESP" "result.version" || echo "unknown") + NAME=$(json_field "$RESP" "result.name" || echo "unknown") + pass "hello response: name=$NAME version=$VERSION" +else + fail "hello" "response: $RESP" +fi + +stop_daemon +echo "" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +echo "===========================================" +echo " Results: $PASS_COUNT passed, $FAIL_COUNT failed" +echo "===========================================" + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git "a/docs/20260404_Git\345\274\200\345\217\221\344\270\216\345\220\214\346\255\245\345\267\245\344\275\234\346\265\201.md" "b/docs/20260404_Git\345\274\200\345\217\221\344\270\216\345\220\214\346\255\245\345\267\245\344\275\234\346\265\201.md" new file mode 100644 index 0000000000..1ba7729c34 --- /dev/null +++ "b/docs/20260404_Git\345\274\200\345\217\221\344\270\216\345\220\214\346\255\245\345\267\245\344\275\234\346\265\201.md" @@ -0,0 +1,45 @@ +# Git 开发与代码同步工作流 + +本文档记录了在使用开源项目(如 cmux)的基础上,维护个人衍生版本(如 jmux)时的标准 Git 操作流程。 + +## 初始配置:关联远程仓库 + +当你已经 Fork 了官方项目,并 Clone 到了本地后,需要设置 Remote(远程关联仓库)。 + +1. **将默认关联的官方源仓库重命名为 `upstream`(上游):** + ```bash + git remote rename origin upstream + ``` + +2. **将你自己 Fork 出来的仓库地址添加为 `origin`:** + ```bash + git remote add origin git@github.com:Sean529/cmux.git + ``` + +## 日常修改与个人版本提交 + +平时你在本地对项目进行私有化定制(例如修改名字、编译配置、UI、代码逻辑等),只需要把它作为一次常规的变更提交到你自己的仓库中即可。 + +```bash +git add . +git commit -m "你的改动描述" +# 将代码推送到你自己的 GitHub 仓库 +git push +``` + +## 同步上游(官方)最新代码 + +当官方(`upstream`)发布了新功能或修复了 Bug,你需要更新本地代码时,只需拉取并合并上游分支。 + +1. **获取官方所有新状态:** + ```bash + git fetch upstream + ``` + +2. **将上游更新合并到自己当前所在的分支(比如 main 分支):** + ```bash + git merge upstream/main + ``` + +> [!NOTE] +> 如果官方修改的代码碰巧和你定制的代码在同一处,可能会产生代码冲突(Conflict)。此时只需正常解决冲突后即可保持进度更新,同时继续保留你的所有独家定制。 diff --git a/docs/cmux-shortcuts.html b/docs/cmux-shortcuts.html new file mode 100644 index 0000000000..6fabf0588c --- /dev/null +++ b/docs/cmux-shortcuts.html @@ -0,0 +1,868 @@ + + + + + +cmux 快捷键速查表 + + + + + + + +
+ +
+
+ + + + 快捷键速查表 +
+

cmux 键盘快捷键

+

所有快捷键一览,助你极速操控终端与浏览器面板。使用搜索或分类导航快速定位。

+
+ + +
+ +
+ + + + + +
+ + + + + +
+

cmux 快捷键速查表 · 基于源码自动生成 · 快捷键支持在设置中自定义

+
+
+ + + + diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000000..e29c219a4b --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,4 @@ +# Documentation For LLMs + +## Documentation Index +- [20260404_Git开发与同步工作流.md](docs/20260404_Git开发与同步工作流.md) diff --git a/scripts/reload.sh b/scripts/reload.sh index a2c086b322..d2f1c30984 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -APP_NAME="cmux DEV" -BUNDLE_ID="com.cmuxterm.app.debug" -BASE_APP_NAME="cmux DEV" +APP_NAME="jmux DEV" +BUNDLE_ID="com.jmux.app.debug" +BASE_APP_NAME="jmux DEV" DERIVED_DATA="" NAME_SET=0 BUNDLE_SET=0 @@ -12,7 +12,7 @@ TAG="" LAUNCH=0 CMUX_DEBUG_LOG="" CLI_PATH="" -LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux" +LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/jmux" LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path" AUTO_SKIP_ZIG_BUILD_REASON="" @@ -42,10 +42,10 @@ write_dev_cli_shim() { mkdir -p "$(dirname "$target")" cat > "$target" </dev/null || stat -c '%u' "\$CLI_PATH_FILE" 2>/dev/null || echo -1)" if [[ -r "\$CLI_PATH_FILE" ]] && [[ ! -L "\$CLI_PATH_FILE" ]] && [[ "\$CLI_PATH_OWNER" == "\$(id -u)" ]]; then CLI_PATH="\$(cat "\$CLI_PATH_FILE")" @@ -58,15 +58,15 @@ if [[ -x "$fallback_bin" ]]; then exec "$fallback_bin" "\$@" fi -echo "error: no reload-selected dev cmux CLI found. Run ./scripts/reload.sh --tag first." >&2 +echo "error: no reload-selected dev jmux CLI found. Run ./scripts/reload.sh --tag first." >&2 exit 1 EOF chmod +x "$target" } -select_cmux_shim_target() { - local app_cli_dir="/Applications/cmux.app/Contents/Resources/bin" - local marker="cmux dev shim (managed by scripts/reload.sh)" +select_jmux_shim_target() { + local app_cli_dir="/Applications/jmux.app/Contents/Resources/bin" + local marker="jmux dev shim (managed by scripts/reload.sh)" local target="" local path_entry="" local candidate="" @@ -81,7 +81,7 @@ select_cmux_shim_target() { break fi [[ -d "$path_entry" && -w "$path_entry" ]] || continue - candidate="$path_entry/cmux" + candidate="$path_entry/jmux" if [[ ! -e "$candidate" ]]; then target="$candidate" break @@ -100,7 +100,7 @@ select_cmux_shim_target() { # Fallback for PATH layouts where app CLI isn't listed or no earlier entries were writable. for path_entry in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do [[ -d "$path_entry" && -w "$path_entry" ]] || continue - candidate="$path_entry/cmux" + candidate="$path_entry/jmux" if [[ ! -e "$candidate" ]]; then echo "$candidate" return 0 @@ -118,7 +118,7 @@ write_last_socket_path() { local socket_path="$1" mkdir -p "$LAST_SOCKET_PATH_DIR" echo "$socket_path" > "$LAST_SOCKET_PATH_FILE" || true - echo "$socket_path" > /tmp/cmux-last-socket-path || true + echo "$socket_path" > /tmp/jmux-last-socket-path || true } usage() { @@ -159,7 +159,7 @@ sanitize_path() { tagged_derived_data_path() { local slug="$1" - echo "$HOME/Library/Developer/Xcode/DerivedData/cmux-${slug}" + echo "$HOME/Library/Developer/Xcode/DerivedData/jmux-${slug}" } print_tag_cleanup_reminder() { @@ -170,10 +170,10 @@ print_tag_cleanup_reminder() { local -a stale_tags=() while IFS= read -r -d '' path; do - if [[ "$path" == /tmp/cmux-* ]]; then - tag="${path#/tmp/cmux-}" - elif [[ "$path" == "$HOME/Library/Developer/Xcode/DerivedData/cmux-"* ]]; then - tag="${path#$HOME/Library/Developer/Xcode/DerivedData/cmux-}" + if [[ "$path" == /tmp/jmux-* ]]; then + tag="${path#/tmp/jmux-}" + elif [[ "$path" == "$HOME/Library/Developer/Xcode/DerivedData/jmux-"* ]]; then + tag="${path#$HOME/Library/Developer/Xcode/DerivedData/jmux-}" else continue fi @@ -190,8 +190,8 @@ print_tag_cleanup_reminder() { seen="${seen}${tag} " stale_tags+=("$tag") done < <( - find /tmp -maxdepth 1 -name 'cmux-*' -print0 2>/dev/null - find "$HOME/Library/Developer/Xcode/DerivedData" -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null + find /tmp -maxdepth 1 -name 'jmux-*' -print0 2>/dev/null + find "$HOME/Library/Developer/Xcode/DerivedData" -maxdepth 1 -type d -name 'jmux-*' -print0 2>/dev/null ) echo @@ -207,17 +207,17 @@ print_tag_cleanup_reminder() { done echo "Cleanup stale tags only:" for tag in "${stale_tags[@]}"; do - echo " pkill -f \"cmux DEV ${tag}.app/Contents/MacOS/cmux DEV\"" - echo " rm -rf \"$(tagged_derived_data_path "$tag")\" \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\"" - echo " rm -f \"/tmp/cmux-debug-${tag}.log\"" - echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${tag}.sock\"" + echo " pkill -f \"jmux DEV ${tag}.app/Contents/MacOS/jmux DEV\"" + echo " rm -rf \"$(tagged_derived_data_path "$tag")\" \"/tmp/jmux-${tag}\" \"/tmp/jmux-debug-${tag}.sock\"" + echo " rm -f \"/tmp/jmux-debug-${tag}.log\"" + echo " rm -f \"$HOME/Library/Application Support/jmux/cmuxd-dev-${tag}.sock\"" done fi echo "After you verify current tag, cleanup command:" - echo " pkill -f \"cmux DEV ${current_slug}.app/Contents/MacOS/cmux DEV\"" - echo " rm -rf \"$(tagged_derived_data_path "$current_slug")\" \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\"" - echo " rm -f \"/tmp/cmux-debug-${current_slug}.log\"" - echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${current_slug}.sock\"" + echo " pkill -f \"jmux DEV ${current_slug}.app/Contents/MacOS/jmux DEV\"" + echo " rm -rf \"$(tagged_derived_data_path "$current_slug")\" \"/tmp/jmux-${current_slug}\" \"/tmp/jmux-debug-${current_slug}.sock\"" + echo " rm -f \"/tmp/jmux-debug-${current_slug}.log\"" + echo " rm -f \"$HOME/Library/Application Support/jmux/cmuxd-dev-${current_slug}.sock\"" } while [[ $# -gt 0 ]]; do @@ -290,10 +290,10 @@ if [[ -n "$TAG" ]]; then TAG_ID="$(sanitize_bundle "$TAG")" TAG_SLUG="$(sanitize_path "$TAG")" if [[ "$NAME_SET" -eq 0 ]]; then - APP_NAME="cmux DEV ${TAG}" + APP_NAME="jmux DEV ${TAG}" fi if [[ "$BUNDLE_SET" -eq 0 ]]; then - BUNDLE_ID="com.cmuxterm.app.debug.${TAG_ID}" + BUNDLE_ID="com.jmux.app.debug.${TAG_ID}" fi if [[ "$DERIVED_SET" -eq 0 ]]; then DERIVED_DATA="$(tagged_derived_data_path "$TAG_SLUG")" @@ -302,7 +302,7 @@ fi XCODEBUILD_ARGS=( -project GhosttyTabs.xcodeproj - -scheme cmux + -scheme jmux -configuration Debug -destination 'platform=macOS' ) @@ -323,7 +323,7 @@ if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then fi XCODEBUILD_ARGS+=(build) -XCODE_LOG="/tmp/cmux-xcodebuild-${TAG_SLUG}.log" +XCODE_LOG="/tmp/jmux-xcodebuild-${TAG_SLUG}.log" set +e xcodebuild "${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$XCODE_LOG" | grep -E '(warning:|error:|fatal:|BUILD FAILED|BUILD SUCCEEDED|\*\* BUILD)' XCODE_PIPESTATUS=("${PIPESTATUS[@]}") @@ -376,7 +376,7 @@ if [[ -z "${APP_PATH}" || ! -d "${APP_PATH}" ]]; then fi if [[ -n "${TAG_SLUG:-}" ]]; then - TMP_COMPAT_DERIVED_LINK="/tmp/cmux-${TAG_SLUG}" + TMP_COMPAT_DERIVED_LINK="/tmp/jmux-${TAG_SLUG}" if [[ "$DERIVED_DATA" != "$TMP_COMPAT_DERIVED_LINK" ]]; then ABS_DERIVED_DATA="$(cd "$DERIVED_DATA" && pwd)" rm -rf "$TMP_COMPAT_DERIVED_LINK" @@ -397,12 +397,12 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFO_PLIST" if [[ -n "${TAG_SLUG:-}" ]]; then - APP_SUPPORT_DIR="$HOME/Library/Application Support/cmux" + APP_SUPPORT_DIR="$HOME/Library/Application Support/jmux" CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-dev-${TAG_SLUG}.sock" - CMUX_SOCKET="/tmp/cmux-debug-${TAG_SLUG}.sock" - CMUX_DEBUG_LOG="/tmp/cmux-debug-${TAG_SLUG}.log" + CMUX_SOCKET="/tmp/jmux-debug-${TAG_SLUG}.sock" + CMUX_DEBUG_LOG="/tmp/jmux-debug-${TAG_SLUG}.log" write_last_socket_path "$CMUX_SOCKET" - echo "$CMUX_DEBUG_LOG" > /tmp/cmux-last-debug-log-path || true + echo "$CMUX_DEBUG_LOG" > /tmp/jmux-last-debug-log-path || true /usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST" @@ -433,18 +433,18 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then APP_PATH="$TAG_APP_PATH" fi -CLI_PATH="$(dirname "$APP_PATH")/cmux" +CLI_PATH="$(dirname "$APP_PATH")/jmux" if [[ -x "$CLI_PATH" ]]; then - (umask 077; printf '%s\n' "$CLI_PATH" > /tmp/cmux-last-cli-path) || true - ln -sfn "$CLI_PATH" /tmp/cmux-cli || true + (umask 077; printf '%s\n' "$CLI_PATH" > /tmp/jmux-last-cli-path) || true + ln -sfn "$CLI_PATH" /tmp/jmux-cli || true # Stable shim that always follows the last reload-selected dev CLI. - DEV_CLI_SHIM="$HOME/.local/bin/cmux-dev" - write_dev_cli_shim "$DEV_CLI_SHIM" "/Applications/cmux.app/Contents/Resources/bin/cmux" + DEV_CLI_SHIM="$HOME/.local/bin/jmux-dev" + write_dev_cli_shim "$DEV_CLI_SHIM" "/Applications/jmux.app/Contents/Resources/bin/jmux" - CMUX_SHIM_TARGET="$(select_cmux_shim_target || true)" + CMUX_SHIM_TARGET="$(select_jmux_shim_target || true)" if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then - write_dev_cli_shim "$CMUX_SHIM_TARGET" "/Applications/cmux.app/Contents/Resources/bin/cmux" + write_dev_cli_shim "$CMUX_SHIM_TARGET" "/Applications/jmux.app/Contents/Resources/bin/jmux" fi fi @@ -473,9 +473,9 @@ if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty" chmod +x "$BIN_DIR/ghostty" fi -CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" +CLI_PATH="$APP_PATH/Contents/Resources/bin/jmux" if [[ -x "$CLI_PATH" ]]; then - echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true + echo "$CLI_PATH" > /tmp/jmux-last-cli-path || true fi if [[ "$LAUNCH" -eq 1 ]]; then @@ -523,8 +523,8 @@ if [[ "$LAUNCH" -eq 1 ]]; then elif [[ -n "${TAG_SLUG:-}" ]]; then "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=allowAll CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" else - echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true - echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true + echo "/tmp/jmux-debug.sock" > /tmp/jmux-last-socket-path || true + echo "/tmp/jmux-debug.log" > /tmp/jmux-last-debug-log-path || true "${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH" fi @@ -562,12 +562,12 @@ if [[ -x "${CLI_PATH:-}" ]]; then echo "CLI path:" echo " $CLI_PATH" echo "CLI helpers:" - echo " /tmp/cmux-cli ..." - echo " $HOME/.local/bin/cmux-dev ..." + echo " /tmp/jmux-cli ..." + echo " $HOME/.local/bin/jmux-dev ..." if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then echo " $CMUX_SHIM_TARGET ..." fi - echo "If your shell still resolves the old cmux, run: rehash" + echo "If your shell still resolves the old jmux, run: rehash" fi if [[ "$LAUNCH" -eq 0 ]]; then diff --git a/scripts/reloadp.sh b/scripts/reloadp.sh index 943c07454b..74fbe946c4 100755 --- a/scripts/reloadp.sh +++ b/scripts/reloadp.sh @@ -1,18 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -destination 'platform=macOS' build -pkill -x cmux || true +xcodebuild -project GhosttyTabs.xcodeproj -scheme jmux -configuration Release -destination 'platform=macOS' build +pkill -x jmux || true sleep 0.2 APP_PATH="$( - find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/cmux.app" -print0 \ + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/jmux.app" -print0 \ | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ | sort -nr \ | head -n 1 \ | cut -d' ' -f2- )" if [[ -z "${APP_PATH}" ]]; then - echo "cmux.app not found in DerivedData" >&2 + echo "jmux.app not found in DerivedData" >&2 exit 1 fi @@ -20,10 +20,10 @@ echo "Release app:" echo " ${APP_PATH}" # Dev shells (including CI/Codex) often force-disable paging by exporting these. -# Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less. +# Don't leak that into jmux, otherwise `git diff` won't page even with PAGER=less. env -u GIT_PAGER -u GH_PAGER open -g "$APP_PATH" -APP_PROCESS_PATH="${APP_PATH}/Contents/MacOS/cmux" +APP_PROCESS_PATH="${APP_PATH}/Contents/MacOS/jmux" ATTEMPT=0 MAX_ATTEMPTS=20 while [[ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]]; do diff --git a/scripts/setup.sh b/scripts/setup.sh index 7384ef626d..df87e6d2eb 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -58,7 +58,7 @@ else echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." ( cd ghostty - zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast + zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast -Dversion-string=1.3.2 ) # Stamp the build output with the SHA it was built from echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP"