@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,395 @@
1
+ import Foundation
2
+
3
+ /// Connection status for the remux server
4
+ public enum ConnectionStatus: Sendable, Equatable {
5
+ case disconnected
6
+ case connecting
7
+ case authenticating
8
+ case connected
9
+ case reconnecting(attempt: Int)
10
+ }
11
+
12
+ /// Credentials used to authenticate with the server
13
+ public enum RemuxCredential: Sendable {
14
+ case token(String)
15
+ case password(String)
16
+ case resumeToken(String)
17
+ }
18
+
19
+ /// Delegate for connection events
20
+ public protocol RemuxConnectionDelegate: AnyObject, Sendable {
21
+ func connectionDidChangeStatus(_ status: ConnectionStatus)
22
+ func connectionDidReceiveMessage(_ message: String)
23
+ func connectionDidReceiveData(_ data: Data)
24
+ func connectionDidAuthenticate(capabilities: ProtocolCapabilities)
25
+ func connectionDidFailAuth(reason: String)
26
+ }
27
+
28
+ /// WebSocket connection manager for a remux server.
29
+ /// Uses URLSessionWebSocketTask (no third-party dependencies).
30
+ /// Handles authentication, automatic reconnection with exponential backoff, and heartbeat.
31
+ public final class RemuxConnection: NSObject, @unchecked Sendable {
32
+
33
+ public let serverURL: URL
34
+ private let credential: RemuxCredential
35
+ private let cols: Int
36
+ private let rows: Int
37
+
38
+ // Internals guarded by a lock for Sendable compliance
39
+ private let lock = NSLock()
40
+ private var _task: URLSessionWebSocketTask?
41
+ private var _status: ConnectionStatus = .disconnected
42
+ private var _reconnectAttempt: Int = 0
43
+ private var _reconnectTimer: DispatchWorkItem?
44
+ private var _heartbeatMissed: Int = 0
45
+ private var _heartbeatTimer: Timer?
46
+ private var _pendingMessages: [String] = []
47
+
48
+ public weak var delegate: RemuxConnectionDelegate?
49
+
50
+ private static let maxReconnectAttempts = 20
51
+ private static let heartbeatTimeout: TimeInterval = 45
52
+ private static let authTimeout: TimeInterval = 10
53
+
54
+ private lazy var urlSession: URLSession = {
55
+ URLSession(configuration: .default, delegate: nil, delegateQueue: nil)
56
+ }()
57
+
58
+ public init(serverURL: URL, credential: RemuxCredential, cols: Int = 80, rows: Int = 24) {
59
+ self.serverURL = serverURL
60
+ self.credential = credential
61
+ self.cols = cols
62
+ self.rows = rows
63
+ super.init()
64
+ }
65
+
66
+ // MARK: - Public API
67
+
68
+ public var status: ConnectionStatus {
69
+ lock.lock()
70
+ defer { lock.unlock() }
71
+ return _status
72
+ }
73
+
74
+ /// Connect to the server. Initiates WebSocket connection and authentication.
75
+ public func connect() {
76
+ lock.lock()
77
+ _reconnectAttempt = 0
78
+ lock.unlock()
79
+ startConnection()
80
+ }
81
+
82
+ /// Disconnect and stop reconnection attempts.
83
+ public func disconnect() {
84
+ lock.lock()
85
+ _reconnectTimer?.cancel()
86
+ _reconnectTimer = nil
87
+ _reconnectAttempt = Self.maxReconnectAttempts // prevent reconnect
88
+ let task = _task
89
+ _task = nil
90
+ lock.unlock()
91
+
92
+ stopHeartbeatTimer()
93
+ task?.cancel(with: .goingAway, reason: nil)
94
+ setStatus(.disconnected)
95
+ }
96
+
97
+ /// Send a JSON-encodable message to the server.
98
+ public func send<T: Encodable>(message: T) {
99
+ guard let data = try? JSONEncoder().encode(message),
100
+ let string = String(data: data, encoding: .utf8) else { return }
101
+ sendString(string)
102
+ }
103
+
104
+ /// Send raw text message.
105
+ public func sendString(_ string: String) {
106
+ lock.lock()
107
+ let task = _task
108
+ lock.unlock()
109
+
110
+ guard let task else {
111
+ // Buffer messages during reconnection
112
+ lock.lock()
113
+ _pendingMessages.append(string)
114
+ lock.unlock()
115
+ return
116
+ }
117
+ task.send(.string(string)) { _ in }
118
+ }
119
+
120
+ /// Send raw binary data (e.g. user terminal input forwarded as-is).
121
+ public func sendData(_ data: Data) {
122
+ lock.lock()
123
+ let task = _task
124
+ lock.unlock()
125
+ task?.send(.data(data)) { _ in }
126
+ }
127
+
128
+ // MARK: - Connection lifecycle
129
+
130
+ private func startConnection() {
131
+ setStatus(.connecting)
132
+
133
+ // Build WebSocket URL: ws(s)://host:port/ws
134
+ guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
135
+ setStatus(.disconnected)
136
+ return
137
+ }
138
+ // Convert http(s) to ws(s)
139
+ if components.scheme == "http" { components.scheme = "ws" }
140
+ else if components.scheme == "https" { components.scheme = "wss" }
141
+ components.path = "/ws"
142
+ guard let wsURL = components.url else { return }
143
+
144
+ NSLog("[RemuxConnection] connecting to %@", wsURL.absoluteString)
145
+ let task = urlSession.webSocketTask(with: wsURL)
146
+ lock.lock()
147
+ _task = task
148
+ lock.unlock()
149
+
150
+ task.resume()
151
+ receiveLoop(task: task)
152
+ authenticate(task: task)
153
+ }
154
+
155
+ private func authenticate(task: URLSessionWebSocketTask) {
156
+ setStatus(.authenticating)
157
+
158
+ var authDict: [String: Any] = [
159
+ "type": "auth",
160
+ "cols": cols,
161
+ "rows": rows,
162
+ ]
163
+
164
+ // Declare capabilities
165
+ authDict["capabilities"] = [
166
+ "envelope": true,
167
+ "inspectV2": true,
168
+ "deviceTrust": true,
169
+ ]
170
+
171
+ switch credential {
172
+ case .token(let t):
173
+ authDict["token"] = t
174
+ case .password(let p):
175
+ authDict["token"] = ""
176
+ authDict["password"] = p
177
+ case .resumeToken(let rt):
178
+ authDict["token"] = rt
179
+ }
180
+
181
+ if let data = try? JSONSerialization.data(withJSONObject: authDict),
182
+ let string = String(data: data, encoding: .utf8) {
183
+ task.send(.string(string)) { _ in }
184
+ }
185
+
186
+ // Auth timeout
187
+ DispatchQueue.global().asyncAfter(deadline: .now() + Self.authTimeout) { [weak self] in
188
+ guard let self else { return }
189
+ self.lock.lock()
190
+ let currentStatus = self._status
191
+ self.lock.unlock()
192
+
193
+ if case .authenticating = currentStatus {
194
+ self.handleDisconnect()
195
+ }
196
+ }
197
+ }
198
+
199
+ // MARK: - Receive loop
200
+
201
+ private func receiveLoop(task: URLSessionWebSocketTask) {
202
+ task.receive { [weak self] result in
203
+ guard let self else { return }
204
+
205
+ switch result {
206
+ case .success(let message):
207
+ switch message {
208
+ case .string(let text):
209
+ self.handleTextMessage(text)
210
+ case .data(let data):
211
+ let delegate = self.delegate
212
+ DispatchQueue.main.async { delegate?.connectionDidReceiveData(data) }
213
+ @unknown default:
214
+ break
215
+ }
216
+ // Continue receiving
217
+ self.receiveLoop(task: task)
218
+
219
+ case .failure:
220
+ self.handleDisconnect()
221
+ }
222
+ }
223
+ }
224
+
225
+ private func handleTextMessage(_ text: String) {
226
+ // PTY data from remux server is sent as raw text (not JSON).
227
+ // JSON control messages start with '{'. If text doesn't start with '{',
228
+ // it's PTY terminal data — forward as binary data for rendering.
229
+ guard text.hasPrefix("{") else {
230
+ if let data = text.data(using: .utf8) {
231
+ let delegate = self.delegate
232
+ DispatchQueue.main.async { delegate?.connectionDidReceiveData(data) }
233
+ }
234
+ return
235
+ }
236
+
237
+ // Try JSON parse for control messages
238
+ guard let data = text.data(using: .utf8),
239
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
240
+ // Malformed JSON — treat as PTY data
241
+ if let data = text.data(using: .utf8) {
242
+ let delegate = self.delegate
243
+ DispatchQueue.main.async { delegate?.connectionDidReceiveData(data) }
244
+ }
245
+ return
246
+ }
247
+
248
+ // Handle both envelope and legacy format
249
+ let msgType: String?
250
+ if let v = json["v"] as? Int, v >= 1 {
251
+ msgType = json["type"] as? String
252
+ } else {
253
+ msgType = json["type"] as? String
254
+ }
255
+
256
+ switch msgType {
257
+ case "auth_ok":
258
+ handleAuthOk(json)
259
+ return
260
+ case "auth_error":
261
+ let reason = json["reason"] as? String
262
+ ?? (json["payload"] as? [String: Any])?["reason"] as? String
263
+ ?? "Unknown error"
264
+ let d = self.delegate
265
+ DispatchQueue.main.async { d?.connectionDidFailAuth(reason: reason) }
266
+ return
267
+ case "ping":
268
+ resetHeartbeatTimer()
269
+ sendString("{\"type\":\"pong\"}")
270
+ return
271
+ default:
272
+ break
273
+ }
274
+
275
+ let delegate = self.delegate
276
+ DispatchQueue.main.async { delegate?.connectionDidReceiveMessage(text) }
277
+ }
278
+
279
+ private func handleAuthOk(_ json: [String: Any]) {
280
+ // Parse capabilities from envelope or legacy format
281
+ let capsDict: [String: Any]?
282
+ if let payload = json["payload"] as? [String: Any] {
283
+ capsDict = payload["capabilities"] as? [String: Any]
284
+ } else {
285
+ capsDict = json["capabilities"] as? [String: Any]
286
+ }
287
+
288
+ let capabilities = ProtocolCapabilities(
289
+ envelope: capsDict?["envelope"] as? Bool ?? false,
290
+ inspectV2: capsDict?["inspectV2"] as? Bool ?? false,
291
+ deviceTrust: capsDict?["deviceTrust"] as? Bool ?? false
292
+ )
293
+
294
+ setStatus(.connected)
295
+
296
+ lock.lock()
297
+ _reconnectAttempt = 0
298
+ let pending = _pendingMessages
299
+ _pendingMessages.removeAll()
300
+ lock.unlock()
301
+
302
+ // Flush buffered messages
303
+ for msg in pending {
304
+ sendString(msg)
305
+ }
306
+
307
+ startHeartbeatTimer()
308
+ let delegate = self.delegate
309
+ DispatchQueue.main.async { delegate?.connectionDidAuthenticate(capabilities: capabilities) }
310
+ }
311
+
312
+ // MARK: - Reconnection (exponential backoff, ref: remodex reconnection strategy)
313
+
314
+ private func handleDisconnect() {
315
+ lock.lock()
316
+ let task = _task
317
+ _task = nil
318
+ let attempt = _reconnectAttempt
319
+ lock.unlock()
320
+
321
+ stopHeartbeatTimer()
322
+ task?.cancel(with: .goingAway, reason: nil)
323
+
324
+ guard attempt < Self.maxReconnectAttempts else {
325
+ setStatus(.disconnected)
326
+ return
327
+ }
328
+
329
+ let nextAttempt = attempt + 1
330
+ lock.lock()
331
+ _reconnectAttempt = nextAttempt
332
+ lock.unlock()
333
+
334
+ setStatus(.reconnecting(attempt: nextAttempt))
335
+
336
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
337
+ let delay = min(pow(2.0, Double(attempt)), 30.0)
338
+ let workItem = DispatchWorkItem { [weak self] in
339
+ self?.startConnection()
340
+ }
341
+
342
+ lock.lock()
343
+ _reconnectTimer = workItem
344
+ lock.unlock()
345
+
346
+ DispatchQueue.global().asyncAfter(deadline: .now() + delay, execute: workItem)
347
+ }
348
+
349
+ // MARK: - Heartbeat (E07-A-06)
350
+
351
+ private func startHeartbeatTimer() {
352
+ stopHeartbeatTimer()
353
+ lock.lock()
354
+ _heartbeatMissed = 0
355
+ lock.unlock()
356
+
357
+ DispatchQueue.main.async { [weak self] in
358
+ guard let self else { return }
359
+ let timer = Timer.scheduledTimer(withTimeInterval: Self.heartbeatTimeout, repeats: false) { [weak self] _ in
360
+ // No ping received within timeout — force reconnect
361
+ self?.handleDisconnect()
362
+ }
363
+ self.lock.lock()
364
+ self._heartbeatTimer = timer
365
+ self.lock.unlock()
366
+ }
367
+ }
368
+
369
+ private func resetHeartbeatTimer() {
370
+ // Called when we receive a ping — restart the timeout
371
+ startHeartbeatTimer()
372
+ }
373
+
374
+ private func stopHeartbeatTimer() {
375
+ lock.lock()
376
+ let timer = _heartbeatTimer
377
+ _heartbeatTimer = nil
378
+ lock.unlock()
379
+ DispatchQueue.main.async {
380
+ timer?.invalidate()
381
+ }
382
+ }
383
+
384
+ // MARK: - Status
385
+
386
+ private func setStatus(_ status: ConnectionStatus) {
387
+ lock.lock()
388
+ _status = status
389
+ lock.unlock()
390
+ let delegate = self.delegate
391
+ DispatchQueue.main.async {
392
+ delegate?.connectionDidChangeStatus(status)
393
+ }
394
+ }
395
+ }
@@ -0,0 +1,188 @@
1
+ import Foundation
2
+
3
+ /// Central state container for remux client.
4
+ /// Uses @Observable macro (ref: remodex's CodexService pattern).
5
+ /// All state updates happen on MainActor.
6
+ @Observable
7
+ @MainActor
8
+ public final class RemuxState {
9
+
10
+ // MARK: - Connection
11
+
12
+ public private(set) var connectionStatus: ConnectionStatus = .disconnected
13
+ public private(set) var serverURL: URL?
14
+ public private(set) var capabilities: ProtocolCapabilities?
15
+
16
+ // MARK: - Workspace
17
+
18
+ public private(set) var currentSession: String = ""
19
+ public private(set) var tabs: [WorkspaceTab] = []
20
+ public private(set) var activeTabIndex: Int = 0
21
+
22
+ // MARK: - Client role
23
+
24
+ public private(set) var clientRole: String = "active" // "active" or "observer"
25
+
26
+ // MARK: - Inspect
27
+
28
+ public private(set) var inspectSnapshot: InspectSnapshot?
29
+
30
+ // MARK: - Devices
31
+
32
+ public private(set) var devices: [DeviceInfo] = []
33
+
34
+ // MARK: - Connection reference
35
+
36
+ private var connection: RemuxConnection?
37
+ private let router = MessageRouter()
38
+
39
+ public init() {}
40
+
41
+ // MARK: - Connection management
42
+
43
+ public func connect(url: URL, credential: RemuxCredential) {
44
+ serverURL = url
45
+ let conn = RemuxConnection(serverURL: url, credential: credential)
46
+ conn.delegate = self
47
+ connection = conn
48
+ conn.connect()
49
+ }
50
+
51
+ public func disconnect() {
52
+ connection?.disconnect()
53
+ connection = nil
54
+ }
55
+
56
+ // MARK: - Actions
57
+
58
+ public func switchTab(id: String) {
59
+ sendJSON(["type": "attach_tab", "tabId": id])
60
+ }
61
+
62
+ public func createTab() {
63
+ sendJSON(["type": "new_tab"])
64
+ }
65
+
66
+ public func closeTab(id: String) {
67
+ sendJSON(["type": "close_tab", "tabId": id])
68
+ }
69
+
70
+ public func renameTab(id: String, name: String) {
71
+ sendJSON(["type": "rename_tab", "tabId": id, "name": name])
72
+ }
73
+
74
+ public func createSession(name: String) {
75
+ sendJSON(["type": "new_session", "name": name])
76
+ }
77
+
78
+ public func deleteSession(name: String) {
79
+ sendJSON(["type": "delete_session", "name": name])
80
+ }
81
+
82
+ public func requestInspect(tabIndex: Int? = nil, query: String? = nil) {
83
+ var dict: [String: Any] = ["type": "inspect"]
84
+ if let idx = tabIndex { dict["tabIndex"] = idx }
85
+ if let q = query { dict["query"] = q }
86
+ if let data = try? JSONSerialization.data(withJSONObject: dict),
87
+ let str = String(data: data, encoding: .utf8) {
88
+ connection?.sendString(str)
89
+ }
90
+ }
91
+
92
+ public func requestControl() {
93
+ sendJSON(["type": "request_control"])
94
+ }
95
+
96
+ public func releaseControl() {
97
+ sendJSON(["type": "release_control"])
98
+ }
99
+
100
+ public func sendTerminalInput(_ text: String) {
101
+ connection?.sendString(text)
102
+ }
103
+
104
+ /// Safe JSON message construction — prevents injection via string interpolation
105
+ public func sendJSON(_ dict: [String: Any]) {
106
+ guard let data = try? JSONSerialization.data(withJSONObject: dict),
107
+ let str = String(data: data, encoding: .utf8) else { return }
108
+ connection?.sendString(str)
109
+ }
110
+
111
+ public func sendTerminalData(_ data: Data) {
112
+ connection?.sendData(data)
113
+ }
114
+
115
+ // MARK: - Internal state updates
116
+
117
+ fileprivate func updateConnectionStatus(_ status: ConnectionStatus) {
118
+ connectionStatus = status
119
+ }
120
+
121
+ fileprivate func handleAuthenticated(capabilities: ProtocolCapabilities) {
122
+ self.capabilities = capabilities
123
+ connectionStatus = .connected
124
+ }
125
+
126
+ fileprivate func processMessage(_ text: String) {
127
+ guard let routed = router.route(text) else { return }
128
+
129
+ switch routed {
130
+ case .state(let ws):
131
+ currentSession = ws.session
132
+ tabs = ws.tabs
133
+ activeTabIndex = ws.activeTabIndex
134
+ case .inspectResult(let snapshot):
135
+ inspectSnapshot = snapshot
136
+ case .roleChanged(let role):
137
+ clientRole = role
138
+ case .deviceList(let list):
139
+ devices = list
140
+ case .pairResult:
141
+ // Handled by pairing UI flow
142
+ break
143
+ case .pushStatus:
144
+ break
145
+ case .error:
146
+ break
147
+ case .unknown:
148
+ break
149
+ }
150
+ }
151
+ }
152
+
153
+ // MARK: - RemuxConnectionDelegate
154
+
155
+ extension RemuxState: @preconcurrency RemuxConnectionDelegate {
156
+
157
+ public func connectionDidChangeStatus(_ status: ConnectionStatus) {
158
+ updateConnectionStatus(status)
159
+ }
160
+
161
+ public func connectionDidReceiveMessage(_ message: String) {
162
+ processMessage(message)
163
+ }
164
+
165
+ public func connectionDidReceiveData(_ data: Data) {
166
+ NotificationCenter.default.post(
167
+ name: .remuxTerminalData,
168
+ object: nil,
169
+ userInfo: ["data": data]
170
+ )
171
+ }
172
+
173
+ public func connectionDidAuthenticate(capabilities: ProtocolCapabilities) {
174
+ handleAuthenticated(capabilities: capabilities)
175
+ // Auto-attach to first tab to start receiving PTY data
176
+ connection?.sendString("{\"type\":\"attach_first\"}")
177
+ }
178
+
179
+ public func connectionDidFailAuth(reason: String) {
180
+ connectionStatus = .disconnected
181
+ }
182
+ }
183
+
184
+ // MARK: - Notifications
185
+
186
+ public extension Notification.Name {
187
+ static let remuxTerminalData = Notification.Name("remuxTerminalData")
188
+ }