@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- 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
|
+
}
|