@wangyaoshen/remux 0.3.8-dev.29e114b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,151 @@
1
+ import Foundation
2
+ import AppKit
3
+
4
+ /// Detects SSH connection patterns in terminal text.
5
+ /// When detected: enables "Upload File" button in toolbar.
6
+ /// Upload uses a command sent to the terminal (scp local remote).
7
+ ///
8
+ /// Adapted from Warp SSH detection / Termius file transfer UX.
9
+ @MainActor
10
+ @Observable
11
+ final class SSHDetector {
12
+
13
+ /// Detected SSH connection info.
14
+ struct SSHConnection: Identifiable, Equatable, Sendable {
15
+ let id: UUID
16
+ let user: String
17
+ let host: String
18
+ let port: Int?
19
+ let detectedAt: Date
20
+
21
+ /// SCP destination prefix, e.g. "user@host:"
22
+ var scpPrefix: String {
23
+ if let port {
24
+ return "-P \(port) \(user)@\(host):"
25
+ }
26
+ return "\(user)@\(host):"
27
+ }
28
+
29
+ var displayString: String {
30
+ if let port {
31
+ return "\(user)@\(host):\(port)"
32
+ }
33
+ return "\(user)@\(host)"
34
+ }
35
+
36
+ init(user: String, host: String, port: Int? = nil) {
37
+ self.id = UUID()
38
+ self.user = user
39
+ self.host = host
40
+ self.port = port
41
+ self.detectedAt = Date()
42
+ }
43
+ }
44
+
45
+ /// Currently detected SSH connections.
46
+ private(set) var connections: [SSHConnection] = []
47
+
48
+ /// Whether any SSH connection is active.
49
+ var hasActiveConnection: Bool {
50
+ !connections.isEmpty
51
+ }
52
+
53
+ /// The most recent SSH connection.
54
+ var latestConnection: SSHConnection? {
55
+ connections.last
56
+ }
57
+
58
+ // MARK: - Detection patterns
59
+
60
+ /// SSH command patterns to detect.
61
+ /// Matches: ssh user@host, ssh -p port user@host, ssh host
62
+ private static let sshPatterns: [(pattern: String, userGroup: Int, hostGroup: Int, portGroup: Int?)] = [
63
+ // ssh -p PORT user@host
64
+ ("ssh\\s+-p\\s+(\\d+)\\s+(\\w+)@([\\w\\.-]+)", 2, 3, 1),
65
+ // ssh user@host -p PORT
66
+ ("ssh\\s+(\\w+)@([\\w\\.-]+)\\s+-p\\s+(\\d+)", 1, 2, 3),
67
+ // ssh user@host
68
+ ("ssh\\s+(\\w+)@([\\w\\.-]+)", 1, 2, nil),
69
+ ]
70
+
71
+ /// SCP command patterns.
72
+ private static let scpPatterns: [String] = [
73
+ "scp\\s+.*?(\\w+)@([\\w\\.-]+):",
74
+ "scp\\s+-P\\s+(\\d+)\\s+.*?(\\w+)@([\\w\\.-]+):",
75
+ ]
76
+
77
+ // MARK: - Parse terminal text
78
+
79
+ /// Parse terminal text for SSH connection patterns.
80
+ func parseTerminalOutput(_ text: String) {
81
+ for (pattern, userGroup, hostGroup, portGroup) in Self.sshPatterns {
82
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
83
+ let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
84
+
85
+ for match in matches {
86
+ guard let userRange = Range(match.range(at: userGroup), in: text),
87
+ let hostRange = Range(match.range(at: hostGroup), in: text) else {
88
+ continue
89
+ }
90
+
91
+ let user = String(text[userRange])
92
+ let host = String(text[hostRange])
93
+
94
+ var port: Int?
95
+ if let pg = portGroup, let portRange = Range(match.range(at: pg), in: text) {
96
+ port = Int(text[portRange])
97
+ }
98
+
99
+ addConnection(user: user, host: host, port: port)
100
+ }
101
+ }
102
+ }
103
+
104
+ /// Add a connection if not already tracked.
105
+ private func addConnection(user: String, host: String, port: Int?) {
106
+ let exists = connections.contains { c in
107
+ c.user == user && c.host == host && c.port == port
108
+ }
109
+ guard !exists else { return }
110
+
111
+ let conn = SSHConnection(user: user, host: host, port: port)
112
+ connections.append(conn)
113
+ NSLog("[remux] SSH detected: %@", conn.displayString)
114
+ }
115
+
116
+ /// Clear all detected connections.
117
+ func clearAll() {
118
+ connections.removeAll()
119
+ }
120
+
121
+ /// Remove a specific connection.
122
+ func remove(_ connection: SSHConnection) {
123
+ connections.removeAll { $0.id == connection.id }
124
+ }
125
+
126
+ // MARK: - File upload via SCP
127
+
128
+ /// Build an SCP command to upload a local file to the remote host.
129
+ /// Returns the command string to be typed into the terminal.
130
+ func buildUploadCommand(localPath: String, remotePath: String = "~", connection: SSHConnection? = nil) -> String? {
131
+ guard let conn = connection ?? latestConnection else { return nil }
132
+
133
+ let escapedLocal = localPath.replacingOccurrences(of: " ", with: "\\ ")
134
+
135
+ if let port = conn.port {
136
+ return "scp -P \(port) \(escapedLocal) \(conn.user)@\(conn.host):\(remotePath)"
137
+ }
138
+ return "scp \(escapedLocal) \(conn.user)@\(conn.host):\(remotePath)"
139
+ }
140
+
141
+ /// Show file picker and return selected file path.
142
+ func pickFileForUpload() -> URL? {
143
+ let panel = NSOpenPanel()
144
+ panel.allowsMultipleSelection = false
145
+ panel.canChooseDirectories = false
146
+ panel.canChooseFiles = true
147
+
148
+ guard panel.runModal() == .OK else { return nil }
149
+ return panel.url
150
+ }
151
+ }
@@ -0,0 +1,226 @@
1
+ import Foundation
2
+
3
+ // MARK: - Data Models
4
+
5
+ /// Snapshot of the entire app session for persistence.
6
+ struct AppSession: Codable, Sendable {
7
+ var version: Int = 1
8
+ var serverURL: String?
9
+ var windowFrame: CodableRect?
10
+ var splitLayout: SplitNodeSnapshot
11
+ var sidebarCollapsed: Bool
12
+
13
+ init(
14
+ serverURL: String? = nil,
15
+ windowFrame: CGRect? = nil,
16
+ splitLayout: SplitNodeSnapshot = .leaf(tabIndex: 0),
17
+ sidebarCollapsed: Bool = false
18
+ ) {
19
+ self.serverURL = serverURL
20
+ self.windowFrame = windowFrame.map { CodableRect(rect: $0) }
21
+ self.splitLayout = splitLayout
22
+ self.sidebarCollapsed = sidebarCollapsed
23
+ }
24
+
25
+ var windowCGRect: CGRect? {
26
+ windowFrame?.cgRect
27
+ }
28
+ }
29
+
30
+ /// Codable wrapper for CGRect since CGRect's Codable is not reliable across platforms.
31
+ struct CodableRect: Codable, Sendable {
32
+ var x: Double
33
+ var y: Double
34
+ var width: Double
35
+ var height: Double
36
+
37
+ init(rect: CGRect) {
38
+ self.x = rect.origin.x
39
+ self.y = rect.origin.y
40
+ self.width = rect.size.width
41
+ self.height = rect.size.height
42
+ }
43
+
44
+ var cgRect: CGRect {
45
+ CGRect(x: x, y: y, width: width, height: height)
46
+ }
47
+ }
48
+
49
+ /// Serializable snapshot of the split tree layout.
50
+ indirect enum SplitNodeSnapshot: Codable, Sendable, Hashable {
51
+ case leaf(tabIndex: Int)
52
+ case branch(orientation: String, ratio: Double, first: SplitNodeSnapshot, second: SplitNodeSnapshot)
53
+
54
+ // Custom Codable implementation for indirect enum
55
+ private enum CodingKeys: String, CodingKey {
56
+ case type, tabIndex, orientation, ratio, first, second
57
+ }
58
+
59
+ func encode(to encoder: Encoder) throws {
60
+ var container = encoder.container(keyedBy: CodingKeys.self)
61
+ switch self {
62
+ case .leaf(let tabIndex):
63
+ try container.encode("leaf", forKey: .type)
64
+ try container.encode(tabIndex, forKey: .tabIndex)
65
+ case .branch(let orientation, let ratio, let first, let second):
66
+ try container.encode("branch", forKey: .type)
67
+ try container.encode(orientation, forKey: .orientation)
68
+ try container.encode(ratio, forKey: .ratio)
69
+ try container.encode(first, forKey: .first)
70
+ try container.encode(second, forKey: .second)
71
+ }
72
+ }
73
+
74
+ init(from decoder: Decoder) throws {
75
+ let container = try decoder.container(keyedBy: CodingKeys.self)
76
+ let type = try container.decode(String.self, forKey: .type)
77
+ switch type {
78
+ case "leaf":
79
+ let tabIndex = try container.decode(Int.self, forKey: .tabIndex)
80
+ self = .leaf(tabIndex: tabIndex)
81
+ case "branch":
82
+ let orientation = try container.decode(String.self, forKey: .orientation)
83
+ let ratio = try container.decode(Double.self, forKey: .ratio)
84
+ let first = try container.decode(SplitNodeSnapshot.self, forKey: .first)
85
+ let second = try container.decode(SplitNodeSnapshot.self, forKey: .second)
86
+ self = .branch(orientation: orientation, ratio: ratio, first: first, second: second)
87
+ default:
88
+ throw DecodingError.dataCorruptedError(
89
+ forKey: .type,
90
+ in: container,
91
+ debugDescription: "Unknown SplitNodeSnapshot type: \(type)"
92
+ )
93
+ }
94
+ }
95
+ }
96
+
97
+ // MARK: - SplitNode <-> Snapshot Conversion
98
+
99
+ extension SplitNode {
100
+ /// Convert a live SplitNode tree to a serializable snapshot.
101
+ func toSnapshot() -> SplitNodeSnapshot {
102
+ switch self {
103
+ case .leaf(let data):
104
+ return .leaf(tabIndex: data.tabIndex)
105
+ case .branch(let data):
106
+ return .branch(
107
+ orientation: data.orientation.rawValue,
108
+ ratio: Double(data.ratio),
109
+ first: data.first.toSnapshot(),
110
+ second: data.second.toSnapshot()
111
+ )
112
+ }
113
+ }
114
+
115
+ /// Reconstruct a SplitNode tree from a snapshot.
116
+ static func fromSnapshot(_ snapshot: SplitNodeSnapshot) -> SplitNode {
117
+ switch snapshot {
118
+ case .leaf(let tabIndex):
119
+ return .leaf(LeafData(tabIndex: tabIndex))
120
+ case .branch(let orientation, let ratio, let first, let second):
121
+ let orient: Orientation = orientation == "horizontal" ? .horizontal : .vertical
122
+ return .branch(BranchData(
123
+ orientation: orient,
124
+ ratio: CGFloat(ratio),
125
+ first: fromSnapshot(first),
126
+ second: fromSnapshot(second)
127
+ ))
128
+ }
129
+ }
130
+ }
131
+
132
+ // MARK: - Persistence Manager
133
+
134
+ /// Manages reading and writing the app session to disk.
135
+ /// File location: ~/Library/Application Support/com.remux/session.json
136
+ final class SessionPersistence: @unchecked Sendable {
137
+
138
+ static let shared = SessionPersistence()
139
+
140
+ private let fileManager = FileManager.default
141
+ nonisolated(unsafe) private var autosaveTimer: Timer?
142
+ nonisolated(unsafe) private var lastSavedHash: Int = 0
143
+
144
+ private var sessionDirectory: URL {
145
+ let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
146
+ return appSupport.appendingPathComponent("com.remux", isDirectory: true)
147
+ }
148
+
149
+ private var sessionFilePath: URL {
150
+ sessionDirectory.appendingPathComponent("session.json")
151
+ }
152
+
153
+ private init() {}
154
+
155
+ // MARK: - Save
156
+
157
+ /// Save the app session to disk.
158
+ @MainActor
159
+ func save(_ session: AppSession) {
160
+ do {
161
+ // Ensure directory exists
162
+ try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true)
163
+
164
+ let encoder = JSONEncoder()
165
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
166
+ let data = try encoder.encode(session)
167
+
168
+ // Skip write if unchanged
169
+ let hash = data.hashValue
170
+ if hash == lastSavedHash { return }
171
+ lastSavedHash = hash
172
+
173
+ try data.write(to: sessionFilePath, options: .atomic)
174
+ NSLog("[remux] Session saved to %@", sessionFilePath.path)
175
+ } catch {
176
+ NSLog("[remux] Failed to save session: %@", error.localizedDescription)
177
+ }
178
+ }
179
+
180
+ // MARK: - Load
181
+
182
+ /// Load the app session from disk. Returns nil if no saved session exists.
183
+ @MainActor
184
+ func load() -> AppSession? {
185
+ guard fileManager.fileExists(atPath: sessionFilePath.path) else {
186
+ NSLog("[remux] No saved session found at %@", sessionFilePath.path)
187
+ return nil
188
+ }
189
+
190
+ do {
191
+ let data = try Data(contentsOf: sessionFilePath)
192
+ let decoder = JSONDecoder()
193
+ let session = try decoder.decode(AppSession.self, from: data)
194
+ lastSavedHash = data.hashValue
195
+ NSLog("[remux] Session loaded from %@", sessionFilePath.path)
196
+ return session
197
+ } catch {
198
+ NSLog("[remux] Failed to load session: %@", error.localizedDescription)
199
+ return nil
200
+ }
201
+ }
202
+
203
+ // MARK: - Autosave
204
+
205
+ /// Start the autosave timer. The closure is called every 8 seconds to
206
+ /// get the current session state.
207
+ @MainActor
208
+ func startAutosave(sessionProvider: @escaping @MainActor () -> AppSession) {
209
+ stopAutosave()
210
+ autosaveTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: true) { [weak self] _ in
211
+ Task { @MainActor in
212
+ guard let self else { return }
213
+ let session = sessionProvider()
214
+ self.save(session)
215
+ }
216
+ }
217
+ NSLog("[remux] Autosave started (8s interval)")
218
+ }
219
+
220
+ /// Stop the autosave timer.
221
+ @MainActor
222
+ func stopAutosave() {
223
+ autosaveTimer?.invalidate()
224
+ autosaveTimer = nil
225
+ }
226
+ }
@@ -0,0 +1,258 @@
1
+ import Foundation
2
+ import RemuxKit
3
+
4
+ /// Unix socket control API for scriptable automation.
5
+ /// Listens on ~/Library/Application Support/com.remux/remux.sock
6
+ /// Protocol: JSON-RPC — request: {"method": "...", "params": {...}, "id": 1}
7
+ /// response: {"result": {...}, "id": 1} or {"error": "...", "id": 1}
8
+ ///
9
+ /// Supported methods: list_tabs, create_tab, close_tab, write_input,
10
+ /// get_state, list_sessions, switch_tab
11
+ ///
12
+ /// Adapted from tmux control-mode / neovim --listen socket API patterns.
13
+ final class SocketController: @unchecked Sendable {
14
+
15
+ private let socketPath: String
16
+ private var serverFD: Int32 = -1
17
+ private var isRunning = false
18
+ private let queue = DispatchQueue(label: "remux.socket-controller")
19
+ private weak var state: RemuxState?
20
+
21
+ init(state: RemuxState) {
22
+ self.state = state
23
+
24
+ let appSupport = FileManager.default.urls(
25
+ for: .applicationSupportDirectory, in: .userDomainMask
26
+ ).first!
27
+ let dir = appSupport.appendingPathComponent("com.remux", isDirectory: true)
28
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
29
+ socketPath = dir.appendingPathComponent("remux.sock").path
30
+ }
31
+
32
+ /// Start listening on the Unix socket.
33
+ func start() {
34
+ guard !isRunning else { return }
35
+ isRunning = true
36
+
37
+ // Remove stale socket
38
+ unlink(socketPath)
39
+
40
+ serverFD = socket(AF_UNIX, SOCK_STREAM, 0)
41
+ guard serverFD >= 0 else {
42
+ NSLog("[remux] SocketController: failed to create socket")
43
+ isRunning = false
44
+ return
45
+ }
46
+
47
+ var addr = sockaddr_un()
48
+ addr.sun_family = sa_family_t(AF_UNIX)
49
+ let pathBytes = socketPath.utf8CString
50
+ pathBytes.withUnsafeBufferPointer { buf in
51
+ withUnsafeMutableBytes(of: &addr.sun_path) { rawPath in
52
+ let count = min(buf.count, rawPath.count)
53
+ rawPath.copyBytes(from: UnsafeRawBufferPointer(buf).prefix(count))
54
+ }
55
+ }
56
+
57
+ let bindResult = withUnsafePointer(to: &addr) { ptr in
58
+ ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
59
+ Darwin.bind(serverFD, sockPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
60
+ }
61
+ }
62
+
63
+ guard bindResult == 0 else {
64
+ NSLog("[remux] SocketController: bind failed: %d", errno)
65
+ close(serverFD)
66
+ serverFD = -1
67
+ isRunning = false
68
+ return
69
+ }
70
+
71
+ listen(serverFD, 1)
72
+ NSLog("[remux] SocketController: listening on %@", socketPath)
73
+
74
+ queue.async { [weak self] in
75
+ self?.acceptLoop()
76
+ }
77
+ }
78
+
79
+ /// Stop the socket controller and clean up.
80
+ func stop() {
81
+ isRunning = false
82
+ if serverFD >= 0 {
83
+ close(serverFD)
84
+ serverFD = -1
85
+ }
86
+ unlink(socketPath)
87
+ NSLog("[remux] SocketController: stopped")
88
+ }
89
+
90
+ // MARK: - Accept loop
91
+
92
+ private func acceptLoop() {
93
+ while isRunning {
94
+ let clientFD = accept(serverFD, nil, nil)
95
+ guard clientFD >= 0 else {
96
+ if isRunning { NSLog("[remux] SocketController: accept failed") }
97
+ break
98
+ }
99
+ handleClient(fd: clientFD)
100
+ close(clientFD)
101
+ }
102
+ }
103
+
104
+ // MARK: - Client handling
105
+
106
+ /// Thread-safe result container using a class marked @unchecked Sendable.
107
+ private final class RPCResultBox: @unchecked Sendable {
108
+ var resultJSON: String?
109
+ var errorMsg: String?
110
+ }
111
+
112
+ private func handleClient(fd: Int32) {
113
+ var buffer = [UInt8](repeating: 0, count: 65536)
114
+ let n = recv(fd, &buffer, buffer.count, 0)
115
+ guard n > 0 else { return }
116
+
117
+ let data = Data(buffer[0..<n])
118
+ guard let _ = String(data: data, encoding: .utf8),
119
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
120
+ sendResponse(fd: fd, error: "Invalid JSON", id: nil)
121
+ return
122
+ }
123
+
124
+ let method = json["method"] as? String ?? ""
125
+ let requestID = json["id"]
126
+
127
+ // Extract params as Sendable strings before crossing isolation boundary
128
+ let tabIDParam = (json["params"] as? [String: Any])?["tabId"] as? String
129
+ let textParam = (json["params"] as? [String: Any])?["text"] as? String
130
+ let indexParam = (json["params"] as? [String: Any])?["index"] as? Int
131
+
132
+ let sem = DispatchSemaphore(value: 0)
133
+ let box = RPCResultBox()
134
+
135
+ DispatchQueue.main.async { [weak self, box] in
136
+ defer { sem.signal() }
137
+ guard let self, let state = self.state else {
138
+ box.errorMsg = "State unavailable"
139
+ return
140
+ }
141
+
142
+ switch method {
143
+ case "list_tabs":
144
+ let tabs: [[String: Any]] = state.tabs.map { tab in
145
+ [
146
+ "index": tab.index,
147
+ "name": tab.name,
148
+ "active": tab.active,
149
+ "paneCount": tab.panes.count,
150
+ ]
151
+ }
152
+ let dict: [String: Any] = ["tabs": tabs]
153
+ if let d = try? JSONSerialization.data(withJSONObject: dict),
154
+ let s = String(data: d, encoding: .utf8) {
155
+ box.resultJSON = s
156
+ }
157
+
158
+ case "create_tab":
159
+ state.createTab()
160
+ box.resultJSON = "{\"ok\":true}"
161
+
162
+ case "close_tab":
163
+ if let tabID = tabIDParam {
164
+ state.closeTab(id: tabID)
165
+ box.resultJSON = "{\"ok\":true}"
166
+ } else {
167
+ box.errorMsg = "Missing tabId parameter"
168
+ }
169
+
170
+ case "write_input":
171
+ if let text = textParam {
172
+ state.sendTerminalInput(text)
173
+ box.resultJSON = "{\"ok\":true}"
174
+ } else {
175
+ box.errorMsg = "Missing text parameter"
176
+ }
177
+
178
+ case "get_state":
179
+ let dict: [String: Any] = [
180
+ "session": state.currentSession,
181
+ "activeTabIndex": state.activeTabIndex,
182
+ "tabCount": state.tabs.count,
183
+ "role": state.clientRole,
184
+ "connected": state.connectionStatus == .connected,
185
+ ]
186
+ if let d = try? JSONSerialization.data(withJSONObject: dict),
187
+ let s = String(data: d, encoding: .utf8) {
188
+ box.resultJSON = s
189
+ }
190
+
191
+ case "list_sessions":
192
+ let dict: [String: Any] = ["session": state.currentSession]
193
+ if let d = try? JSONSerialization.data(withJSONObject: dict),
194
+ let s = String(data: d, encoding: .utf8) {
195
+ box.resultJSON = s
196
+ }
197
+
198
+ case "switch_tab":
199
+ if let tabID = tabIDParam {
200
+ state.switchTab(id: tabID)
201
+ box.resultJSON = "{\"ok\":true}"
202
+ } else if let index = indexParam,
203
+ index < state.tabs.count,
204
+ let pane = state.tabs[index].panes.first {
205
+ state.switchTab(id: pane.id)
206
+ box.resultJSON = "{\"ok\":true}"
207
+ } else {
208
+ box.errorMsg = "Missing or invalid tabId/index parameter"
209
+ }
210
+
211
+ default:
212
+ box.errorMsg = "Unknown method: \(method)"
213
+ }
214
+ }
215
+
216
+ sem.wait()
217
+
218
+ if let error = box.errorMsg {
219
+ sendResponse(fd: fd, error: error, id: requestID)
220
+ } else {
221
+ sendResponseRaw(fd: fd, json: box.resultJSON ?? "{\"ok\":true}", id: requestID)
222
+ }
223
+ }
224
+
225
+ // MARK: - Response helpers
226
+
227
+ private func sendResponse(fd: Int32, error: String, id: Any?) {
228
+ var dict: [String: Any] = ["error": error]
229
+ if let id { dict["id"] = id }
230
+ guard let data = try? JSONSerialization.data(withJSONObject: dict),
231
+ var response = String(data: data, encoding: .utf8) else { return }
232
+ response += "\n"
233
+ response.withCString { ptr in
234
+ _ = send(fd, ptr, strlen(ptr), 0)
235
+ }
236
+ }
237
+
238
+ private func sendResponseRaw(fd: Int32, json: String, id: Any?) {
239
+ // Wrap the pre-serialized JSON result into a response envelope
240
+ var response: String
241
+ if let id {
242
+ let idStr: String
243
+ if let intID = id as? Int {
244
+ idStr = "\(intID)"
245
+ } else if let strID = id as? String {
246
+ idStr = "\"\(strID)\""
247
+ } else {
248
+ idStr = "null"
249
+ }
250
+ response = "{\"result\":\(json),\"id\":\(idStr)}\n"
251
+ } else {
252
+ response = "{\"result\":\(json)}\n"
253
+ }
254
+ response.withCString { ptr in
255
+ _ = send(fd, ptr, strlen(ptr), 0)
256
+ }
257
+ }
258
+ }