@wangyaoshen/remux 0.3.8-dev.a8ceb0c → 0.3.9-dev.390cb29

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.
@@ -96,9 +96,9 @@ struct RootView: View {
96
96
  private func tryAutoConnect() {
97
97
  let servers = keychain.savedServers()
98
98
  if let server = servers.first,
99
- let token = keychain.loadResumeToken(forServer: server) ?? keychain.loadServerToken(forServer: server),
99
+ let credential = keychain.preferredCredential(forServer: server),
100
100
  let url = URL(string: server) {
101
- state.connect(url: url, credential: .token(token))
101
+ state.connect(url: url, credential: credential)
102
102
  }
103
103
  }
104
104
  }
@@ -65,13 +65,13 @@ struct MeView: View {
65
65
  VStack(alignment: .leading) {
66
66
  Text(device.name ?? device.id.prefix(8).description)
67
67
  .font(.subheadline)
68
- Text(device.trustLevel)
68
+ Text(device.trust)
69
69
  .font(.caption)
70
- .foregroundStyle(device.trustLevel == "trusted" ? .green : .orange)
70
+ .foregroundStyle(device.trust == "trusted" ? .green : .orange)
71
71
  }
72
72
  Spacer()
73
73
  if let lastSeen = device.lastSeen {
74
- Text(lastSeen.prefix(10).description)
74
+ Text(Self.lastSeenFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(lastSeen) / 1000)))
75
75
  .font(.caption2)
76
76
  .foregroundStyle(.tertiary)
77
77
  }
@@ -90,7 +90,7 @@ struct MeView: View {
90
90
  HStack {
91
91
  Text("Version")
92
92
  Spacer()
93
- Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.3.5")
93
+ Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.3.9")
94
94
  .foregroundStyle(.secondary)
95
95
  }
96
96
  }
@@ -112,6 +112,13 @@ struct MeView: View {
112
112
  default: "questionmark.circle"
113
113
  }
114
114
  }
115
+
116
+ private static let lastSeenFormatter: DateFormatter = {
117
+ let formatter = DateFormatter()
118
+ formatter.dateStyle = .short
119
+ formatter.timeStyle = .short
120
+ return formatter
121
+ }()
115
122
  }
116
123
 
117
124
  extension ConnectionStatus {
@@ -53,9 +53,10 @@ struct ConnectionView: View {
53
53
  serverURL = server
54
54
  if let savedToken = keychain.loadServerToken(forServer: server) {
55
55
  token = savedToken
56
- connect()
57
- } else if let resumeToken = keychain.loadResumeToken(forServer: server) {
58
- connectWithResumeToken(server: server, resumeToken: resumeToken)
56
+ }
57
+ if let credential = keychain.preferredCredential(forServer: server),
58
+ let url = URL(string: server) {
59
+ state.connect(url: url, credential: credential)
59
60
  }
60
61
  }
61
62
  .buttonStyle(.plain)
@@ -76,9 +77,4 @@ struct ConnectionView: View {
76
77
  try? keychain.saveServerToken(token, forServer: serverURL)
77
78
  state.connect(url: url, credential: .token(token))
78
79
  }
79
-
80
- private func connectWithResumeToken(server: String, resumeToken: String) {
81
- guard let url = URL(string: server) else { return }
82
- state.connect(url: url, credential: .resumeToken(resumeToken))
83
- }
84
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangyaoshen/remux",
3
- "version": "0.3.8-dev.a8ceb0c",
3
+ "version": "0.3.9-dev.390cb29",
4
4
  "description": "Remote terminal workspace — powered by ghostty-web",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,3 +1,5 @@
1
+ import Foundation
2
+
1
3
  public struct ProtocolCapabilities: Codable, Equatable, Sendable {
2
4
  public let envelope: Bool
3
5
  public let inspectV2: Bool
@@ -67,6 +69,58 @@ public struct WorkspaceState: Codable, Equatable, Sendable {
67
69
  public let activeTabIndex: Int
68
70
  }
69
71
 
72
+ public struct WorkspaceSessionSummary: Codable, Equatable, Sendable {
73
+ public let name: String
74
+ public let tabs: [WorkspaceSessionTab]
75
+ public let createdAt: Int
76
+ }
77
+
78
+ public struct WorkspaceSessionTab: Codable, Equatable, Sendable {
79
+ public let id: Int
80
+ public let title: String
81
+ public let ended: Bool
82
+ public let clients: Int
83
+ public let restored: Bool
84
+ }
85
+
86
+ public struct ConnectedClientInfo: Codable, Equatable, Sendable {
87
+ public let clientId: String
88
+ public let role: String
89
+ public let session: String?
90
+ public let tabId: Int?
91
+ }
92
+
93
+ public struct WorkspaceSnapshot: Codable, Equatable, Sendable {
94
+ public let sessions: [WorkspaceSessionSummary]
95
+ public let clients: [ConnectedClientInfo]
96
+
97
+ public init(sessions: [WorkspaceSessionSummary], clients: [ConnectedClientInfo]) {
98
+ self.sessions = sessions
99
+ self.clients = clients
100
+ }
101
+ }
102
+
103
+ public struct AttachedPayload: Codable, Equatable, Sendable {
104
+ public let tabId: Int
105
+ public let session: String
106
+ public let clientId: String
107
+ public let role: String
108
+ }
109
+
110
+ public struct ServerInspectMeta: Codable, Equatable, Sendable {
111
+ public let session: String
112
+ public let tabId: Int?
113
+ public let tabTitle: String
114
+ public let cols: Int
115
+ public let rows: Int
116
+ public let timestamp: Int
117
+ }
118
+
119
+ public struct ServerInspectResult: Codable, Equatable, Sendable {
120
+ public let text: String
121
+ public let meta: ServerInspectMeta
122
+ }
123
+
70
124
  public struct InspectHighlight: Codable, Equatable, Sendable {
71
125
  public let start: Int
72
126
  public let end: Int
@@ -177,6 +231,16 @@ public struct LegacyWorkspaceState: Codable, Equatable, Sendable {
177
231
  public let activeTabIndex: Int
178
232
  }
179
233
 
234
+ public struct CurrentWorkspaceStatePayload: Codable, Equatable, Sendable {
235
+ public let sessions: [WorkspaceSessionSummary]
236
+ public let clients: [ConnectedClientInfo]
237
+ }
238
+
239
+ public struct BootstrapPayload: Codable, Equatable, Sendable {
240
+ public let sessions: [WorkspaceSessionSummary]
241
+ public let clients: [ConnectedClientInfo]
242
+ }
243
+
180
244
  public struct LegacyInspectRequest: Codable, Equatable, Sendable {
181
245
  public let type: String
182
246
  public let scope: String
@@ -6,7 +6,8 @@ public struct MessageRouter: Sendable {
6
6
 
7
7
  /// Parsed message with type info extracted
8
8
  public enum RoutedMessage: Sendable {
9
- case state(WorkspaceState)
9
+ case workspaceSnapshot(WorkspaceSnapshot)
10
+ case attached(AttachedPayload)
10
11
  case inspectResult(InspectSnapshot)
11
12
  case roleChanged(String) // "active" or "observer"
12
13
  case deviceList([DeviceInfo])
@@ -42,13 +43,58 @@ public struct MessageRouter: Sendable {
42
43
  guard let type = msgType else { return nil }
43
44
 
44
45
  switch type {
46
+ case "bootstrap":
47
+ if let p = payload, let bootstrap = decode(BootstrapPayload.self, from: p) {
48
+ return .workspaceSnapshot(
49
+ WorkspaceSnapshot(sessions: bootstrap.sessions, clients: bootstrap.clients)
50
+ )
51
+ }
45
52
  case "state":
46
- if let p = payload, let state = decode(WorkspaceState.self, from: p) {
47
- return .state(state)
53
+ if let p = payload {
54
+ if let snapshot = decode(CurrentWorkspaceStatePayload.self, from: p) {
55
+ return .workspaceSnapshot(
56
+ WorkspaceSnapshot(sessions: snapshot.sessions, clients: snapshot.clients)
57
+ )
58
+ }
59
+ if let state = decode(WorkspaceState.self, from: p) {
60
+ let session = WorkspaceSessionSummary(
61
+ name: state.session,
62
+ tabs: state.tabs.map {
63
+ WorkspaceSessionTab(
64
+ id: $0.index,
65
+ title: $0.name,
66
+ ended: false,
67
+ clients: 0,
68
+ restored: false
69
+ )
70
+ },
71
+ createdAt: 0
72
+ )
73
+ return .workspaceSnapshot(
74
+ WorkspaceSnapshot(
75
+ sessions: [session],
76
+ clients: [ConnectedClientInfo(
77
+ clientId: "",
78
+ role: "active",
79
+ session: state.session,
80
+ tabId: state.activeTabIndex
81
+ )]
82
+ )
83
+ )
84
+ }
85
+ }
86
+ case "attached":
87
+ if let p = payload, let attached = decode(AttachedPayload.self, from: p) {
88
+ return .attached(attached)
48
89
  }
49
90
  case "inspect_result":
50
- if let p = payload, let snapshot = decode(InspectSnapshot.self, from: p) {
51
- return .inspectResult(snapshot)
91
+ if let p = payload {
92
+ if let snapshot = decode(InspectSnapshot.self, from: p) {
93
+ return .inspectResult(snapshot)
94
+ }
95
+ if let result = decode(ServerInspectResult.self, from: p) {
96
+ return .inspectResult(convertInspectResult(result))
97
+ }
52
98
  }
53
99
  case "role_changed":
54
100
  if let role = payload?["role"] as? String {
@@ -67,7 +113,9 @@ public struct MessageRouter: Sendable {
67
113
  return .pushStatus(status)
68
114
  }
69
115
  case "error":
70
- let message = payload?["message"] as? String ?? "Unknown error"
116
+ let message = payload?["reason"] as? String
117
+ ?? payload?["message"] as? String
118
+ ?? "Unknown error"
71
119
  return .error(message)
72
120
  default:
73
121
  return .unknown(type: type, raw: text)
@@ -76,6 +124,37 @@ public struct MessageRouter: Sendable {
76
124
  return .unknown(type: type, raw: text)
77
125
  }
78
126
 
127
+ private func convertInspectResult(_ result: ServerInspectResult) -> InspectSnapshot {
128
+ let timestamp = ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: TimeInterval(result.meta.timestamp) / 1000))
129
+ let lines = result.text.split(separator: "\n", omittingEmptySubsequences: false)
130
+ let items = lines.enumerated().map { offset, line in
131
+ InspectItem(
132
+ type: "output",
133
+ content: String(line),
134
+ lineNumber: offset + 1,
135
+ timestamp: timestamp,
136
+ paneId: result.meta.tabId.map(String.init),
137
+ highlights: nil
138
+ )
139
+ }
140
+
141
+ return InspectSnapshot(
142
+ descriptor: InspectDescriptor(
143
+ scope: "tab",
144
+ source: "server",
145
+ precision: "precise",
146
+ staleness: "fresh",
147
+ capturedAt: timestamp,
148
+ paneId: result.meta.tabId.map(String.init),
149
+ tabIndex: result.meta.tabId,
150
+ totalItems: items.count
151
+ ),
152
+ items: items,
153
+ cursor: nil,
154
+ truncated: false
155
+ )
156
+ }
157
+
79
158
  private func decode<T: Decodable>(_ type: T.Type, from dict: [String: Any]) -> T? {
80
159
  guard let data = try? JSONSerialization.data(withJSONObject: dict) else { return nil }
81
160
  return try? JSONDecoder().decode(type, from: data)
@@ -89,8 +168,8 @@ public struct DeviceInfo: Codable, Equatable, Sendable {
89
168
  public let fingerprint: String?
90
169
  public let name: String?
91
170
  public let platform: String?
92
- public let trustLevel: String
93
- public let lastSeen: String?
171
+ public let trust: String
172
+ public let lastSeen: Int?
94
173
  }
95
174
 
96
175
  public struct DeviceListPayload: Codable, Equatable, Sendable {
@@ -100,7 +179,7 @@ public struct DeviceListPayload: Codable, Equatable, Sendable {
100
179
  public struct PairResultPayload: Codable, Equatable, Sendable {
101
180
  public let success: Bool
102
181
  public let deviceId: String?
103
- public let error: String?
182
+ public let reason: String?
104
183
  }
105
184
 
106
185
  public struct PushStatusPayload: Codable, Equatable, Sendable {
@@ -10,7 +10,7 @@ public enum ConnectionStatus: Sendable, Equatable {
10
10
  }
11
11
 
12
12
  /// Credentials used to authenticate with the server
13
- public enum RemuxCredential: Sendable {
13
+ public enum RemuxCredential: Sendable, Equatable {
14
14
  case token(String)
15
15
  case password(String)
16
16
  case resumeToken(String)
@@ -30,6 +30,29 @@ public protocol RemuxConnectionDelegate: AnyObject, Sendable {
30
30
  /// Handles authentication, automatic reconnection with exponential backoff, and heartbeat.
31
31
  public final class RemuxConnection: NSObject, @unchecked Sendable {
32
32
 
33
+ enum IncomingTextDisposition: Equatable {
34
+ case terminal(String)
35
+ case control(String?)
36
+ }
37
+
38
+ static let legacyControlTypes: Set<String> = [
39
+ "auth_ok",
40
+ "auth_error",
41
+ "attached",
42
+ "bootstrap",
43
+ "device_list",
44
+ "error",
45
+ "inspect_result",
46
+ "pair_code",
47
+ "pair_result",
48
+ "ping",
49
+ "push_status",
50
+ "push_subscribed",
51
+ "role_changed",
52
+ "state",
53
+ "vapid_key",
54
+ ]
55
+
33
56
  public let serverURL: URL
34
57
  private let credential: RemuxCredential
35
58
  private let cols: Int
@@ -223,21 +246,19 @@ public final class RemuxConnection: NSObject, @unchecked Sendable {
223
246
  }
224
247
 
225
248
  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) {
249
+ switch Self.classifyIncomingText(text) {
250
+ case .terminal(let terminalText):
251
+ if let data = terminalText.data(using: .utf8) {
231
252
  let delegate = self.delegate
232
253
  DispatchQueue.main.async { delegate?.connectionDidReceiveData(data) }
233
254
  }
234
255
  return
256
+ case .control:
257
+ break
235
258
  }
236
259
 
237
- // Try JSON parse for control messages
238
260
  guard let data = text.data(using: .utf8),
239
261
  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
240
- // Malformed JSON — treat as PTY data
241
262
  if let data = text.data(using: .utf8) {
242
263
  let delegate = self.delegate
243
264
  DispatchQueue.main.async { delegate?.connectionDidReceiveData(data) }
@@ -392,4 +413,22 @@ public final class RemuxConnection: NSObject, @unchecked Sendable {
392
413
  delegate?.connectionDidChangeStatus(status)
393
414
  }
394
415
  }
416
+
417
+ static func classifyIncomingText(_ text: String) -> IncomingTextDisposition {
418
+ guard text.hasPrefix("{"),
419
+ let data = text.data(using: .utf8),
420
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
421
+ return .terminal(text)
422
+ }
423
+
424
+ if let version = json["v"] as? Int, version >= 1, json["type"] as? String != nil {
425
+ return .control(json["type"] as? String)
426
+ }
427
+
428
+ if let type = json["type"] as? String, legacyControlTypes.contains(type) {
429
+ return .control(type)
430
+ }
431
+
432
+ return .terminal(text)
433
+ }
395
434
  }
@@ -35,12 +35,16 @@ public final class RemuxState {
35
35
 
36
36
  private var connection: RemuxConnection?
37
37
  private let router = MessageRouter()
38
+ private var workspaceSnapshot = WorkspaceSnapshot(sessions: [], clients: [])
39
+ private var clientId: String?
40
+ private var currentTabId: Int?
38
41
 
39
42
  public init() {}
40
43
 
41
44
  // MARK: - Connection management
42
45
 
43
46
  public func connect(url: URL, credential: RemuxCredential) {
47
+ connection?.disconnect()
44
48
  serverURL = url
45
49
  let conn = RemuxConnection(serverURL: url, credential: credential)
46
50
  conn.delegate = self
@@ -56,19 +60,23 @@ public final class RemuxState {
56
60
  // MARK: - Actions
57
61
 
58
62
  public func switchTab(id: String) {
59
- sendJSON(["type": "attach_tab", "tabId": id])
63
+ sendJSON(["type": "attach_tab", "tabId": Int(id) ?? id])
60
64
  }
61
65
 
62
66
  public func createTab() {
63
- sendJSON(["type": "new_tab"])
67
+ var payload: [String: Any] = ["type": "new_tab"]
68
+ if !currentSession.isEmpty {
69
+ payload["session"] = currentSession
70
+ }
71
+ sendJSON(payload)
64
72
  }
65
73
 
66
74
  public func closeTab(id: String) {
67
- sendJSON(["type": "close_tab", "tabId": id])
75
+ sendJSON(["type": "close_tab", "tabId": Int(id) ?? id])
68
76
  }
69
77
 
70
78
  public func renameTab(id: String, name: String) {
71
- sendJSON(["type": "rename_tab", "tabId": id, "name": name])
79
+ sendJSON(["type": "rename_tab", "tabId": Int(id) ?? id, "title": name])
72
80
  }
73
81
 
74
82
  public func createSession(name: String) {
@@ -80,6 +88,9 @@ public final class RemuxState {
80
88
  }
81
89
 
82
90
  public func requestInspect(tabIndex: Int? = nil, query: String? = nil) {
91
+ if let tabId = tabIndex, tabId != currentTabId {
92
+ sendJSON(["type": "attach_tab", "tabId": tabId])
93
+ }
83
94
  var dict: [String: Any] = ["type": "inspect"]
84
95
  if let idx = tabIndex { dict["tabIndex"] = idx }
85
96
  if let q = query { dict["query"] = q }
@@ -127,10 +138,14 @@ public final class RemuxState {
127
138
  guard let routed = router.route(text) else { return }
128
139
 
129
140
  switch routed {
130
- case .state(let ws):
131
- currentSession = ws.session
132
- tabs = ws.tabs
133
- activeTabIndex = ws.activeTabIndex
141
+ case .workspaceSnapshot(let snapshot):
142
+ applyWorkspaceSnapshot(snapshot)
143
+ case .attached(let attached):
144
+ clientId = attached.clientId
145
+ currentSession = attached.session
146
+ currentTabId = attached.tabId
147
+ clientRole = attached.role
148
+ rebuildTabs()
134
149
  case .inspectResult(let snapshot):
135
150
  inspectSnapshot = snapshot
136
151
  case .roleChanged(let role):
@@ -148,6 +163,64 @@ public final class RemuxState {
148
163
  break
149
164
  }
150
165
  }
166
+
167
+ private func applyWorkspaceSnapshot(_ snapshot: WorkspaceSnapshot) {
168
+ workspaceSnapshot = snapshot
169
+
170
+ if let clientId,
171
+ let client = snapshot.clients.first(where: { $0.clientId == clientId }) {
172
+ currentSession = client.session ?? currentSession
173
+ currentTabId = client.tabId ?? currentTabId
174
+ clientRole = client.role
175
+ } else if currentSession.isEmpty, let firstSession = snapshot.sessions.first?.name {
176
+ currentSession = firstSession
177
+ }
178
+
179
+ rebuildTabs()
180
+ }
181
+
182
+ private func rebuildTabs() {
183
+ let sessionSummary =
184
+ workspaceSnapshot.sessions.first(where: { $0.name == currentSession })
185
+ ?? workspaceSnapshot.sessions.first
186
+
187
+ if currentSession.isEmpty, let sessionSummary {
188
+ currentSession = sessionSummary.name
189
+ }
190
+
191
+ guard let sessionSummary else {
192
+ tabs = []
193
+ activeTabIndex = 0
194
+ return
195
+ }
196
+
197
+ let activeTabId = currentTabId ?? sessionSummary.tabs.first?.id
198
+ currentTabId = activeTabId
199
+ activeTabIndex = activeTabId ?? 0
200
+ tabs = sessionSummary.tabs.map { tab in
201
+ let isActive = tab.id == activeTabId
202
+ return WorkspaceTab(
203
+ index: tab.id,
204
+ name: tab.title,
205
+ active: isActive,
206
+ isFullscreen: false,
207
+ hasBell: false,
208
+ panes: [
209
+ WorkspacePane(
210
+ id: String(tab.id),
211
+ focused: isActive,
212
+ title: tab.title,
213
+ command: nil,
214
+ cwd: nil,
215
+ rows: 24,
216
+ cols: 80,
217
+ x: 0,
218
+ y: 0
219
+ )
220
+ ]
221
+ )
222
+ }
223
+ }
151
224
  }
152
225
 
153
226
  // MARK: - RemuxConnectionDelegate
@@ -51,10 +51,29 @@ public struct KeychainStore: Sendable {
51
51
 
52
52
  /// Returns all server URLs that have stored credentials.
53
53
  public func savedServers() -> [String] {
54
+ let labels = ["resume_token", "server_token"]
55
+ var servers = Set<String>()
56
+ for label in labels {
57
+ servers.formUnion(savedServers(label: label))
58
+ }
59
+ return servers.sorted()
60
+ }
61
+
62
+ public func preferredCredential(forServer server: String) -> RemuxCredential? {
63
+ if let resumeToken = loadResumeToken(forServer: server) {
64
+ return .resumeToken(resumeToken)
65
+ }
66
+ if let token = loadServerToken(forServer: server) {
67
+ return .token(token)
68
+ }
69
+ return nil
70
+ }
71
+
72
+ private func savedServers(label: String) -> [String] {
54
73
  let query: [String: Any] = [
55
74
  kSecClass as String: kSecClassGenericPassword,
56
75
  kSecAttrService as String: Self.service,
57
- kSecAttrLabel as String: "resume_token",
76
+ kSecAttrLabel as String: label,
58
77
  kSecMatchLimit as String: kSecMatchLimitAll,
59
78
  kSecReturnAttributes as String: true,
60
79
  ]
@@ -72,3 +72,19 @@ struct ConnectionIntegrationTests {
72
72
  #expect(result.state == true)
73
73
  }
74
74
  }
75
+
76
+ @Suite("RemuxConnection")
77
+ struct RemuxConnectionTests {
78
+
79
+ @Test("Treat non-protocol JSON output as terminal data")
80
+ func classifyJsonTerminalOutput() {
81
+ let disposition = RemuxConnection.classifyIncomingText(#"{"message":"hello"}"#)
82
+ #expect(disposition == .terminal(#"{"message":"hello"}"#))
83
+ }
84
+
85
+ @Test("Treat enveloped messages as control")
86
+ func classifyEnvelopedControl() {
87
+ let disposition = RemuxConnection.classifyIncomingText(#"{"v":1,"type":"state","payload":{"sessions":[],"clients":[]}}"#)
88
+ #expect(disposition == .control("state"))
89
+ }
90
+ }
@@ -78,4 +78,30 @@ struct KeychainStoreTests {
78
78
  #expect(store.loadServerToken(forServer: testServer) == nil)
79
79
  #expect(store.loadDeviceId(forServer: testServer) == nil)
80
80
  }
81
+
82
+ @Test("Saved servers include token-only and resume-token-only entries", .enabled(if: keychainAvailable))
83
+ func savedServersIncludesAllCredentialTypes() throws {
84
+ let tokenOnly = "token-only-\(UUID().uuidString)"
85
+ let resumeOnly = "resume-only-\(UUID().uuidString)"
86
+ try store.saveServerToken("tok", forServer: tokenOnly)
87
+ try store.saveResumeToken("resume", forServer: resumeOnly)
88
+ defer {
89
+ store.deleteAll(forServer: tokenOnly)
90
+ store.deleteAll(forServer: resumeOnly)
91
+ }
92
+
93
+ let servers = store.savedServers()
94
+ #expect(servers.contains(tokenOnly))
95
+ #expect(servers.contains(resumeOnly))
96
+ }
97
+
98
+ @Test("Preferred credential uses resume token before server token", .enabled(if: keychainAvailable))
99
+ func preferredCredentialPrefersResumeToken() throws {
100
+ try store.saveServerToken("tok", forServer: testServer)
101
+ try store.saveResumeToken("resume", forServer: testServer)
102
+ defer { store.deleteAll(forServer: testServer) }
103
+
104
+ let credential = store.preferredCredential(forServer: testServer)
105
+ #expect(credential == .resumeToken("resume"))
106
+ }
81
107
  }
@@ -118,14 +118,16 @@ struct MessageRouterTests {
118
118
  func routeStateMessage() throws {
119
119
  let router = MessageRouter()
120
120
  let json = """
121
- {"v":1,"type":"state","domain":"runtime","emittedAt":"2026-04-01T00:00:00Z","source":"server","payload":{"session":"main","tabs":[],"activeTabIndex":0}}
121
+ {"v":1,"type":"state","domain":"runtime","emittedAt":"2026-04-01T00:00:00Z","source":"server","payload":{"sessions":[{"name":"main","tabs":[{"id":7,"title":"zsh","ended":false,"clients":1,"restored":false}],"createdAt":1712000000000}],"clients":[{"clientId":"c1","role":"active","session":"main","tabId":7}]}}
122
122
  """
123
123
  let result = router.route(json)
124
- guard case .state(let state) = result else {
125
- Issue.record("Expected .state, got \(String(describing: result))")
124
+ guard case .workspaceSnapshot(let snapshot) = result else {
125
+ Issue.record("Expected .workspaceSnapshot, got \(String(describing: result))")
126
126
  return
127
127
  }
128
- #expect(state.session == "main")
128
+ #expect(snapshot.sessions.count == 1)
129
+ #expect(snapshot.sessions[0].name == "main")
130
+ #expect(snapshot.sessions[0].tabs[0].id == 7)
129
131
  }
130
132
 
131
133
  @Test("Route legacy state message")
@@ -135,11 +137,12 @@ struct MessageRouterTests {
135
137
  {"type":"state","session":"dev","tabs":[],"activeTabIndex":0}
136
138
  """
137
139
  let result = router.route(json)
138
- guard case .state(let state) = result else {
139
- Issue.record("Expected .state, got \(String(describing: result))")
140
+ guard case .workspaceSnapshot(let snapshot) = result else {
141
+ Issue.record("Expected .workspaceSnapshot, got \(String(describing: result))")
140
142
  return
141
143
  }
142
- #expect(state.session == "dev")
144
+ #expect(snapshot.sessions.first?.name == "dev")
145
+ #expect(snapshot.sessions.first?.tabs.isEmpty == true)
143
146
  }
144
147
 
145
148
  @Test("Route role_changed message")
@@ -176,4 +179,35 @@ struct MessageRouterTests {
176
179
  let result = router.route("not json")
177
180
  #expect(result == nil)
178
181
  }
182
+
183
+ @Test("Route bootstrap message")
184
+ func routeBootstrapMessage() throws {
185
+ let router = MessageRouter()
186
+ let json = """
187
+ {"v":1,"type":"bootstrap","domain":"core","emittedAt":"2026-04-01T00:00:00Z","source":"server","payload":{"sessions":[{"name":"default","tabs":[{"id":1,"title":"shell","ended":false,"clients":1,"restored":false}],"createdAt":1712000000000}],"clients":[{"clientId":"c1","role":"active","session":"default","tabId":1}]}}
188
+ """
189
+ let result = router.route(json)
190
+ guard case .workspaceSnapshot(let snapshot) = result else {
191
+ Issue.record("Expected .workspaceSnapshot")
192
+ return
193
+ }
194
+ #expect(snapshot.sessions.first?.tabs.first?.title == "shell")
195
+ #expect(snapshot.clients.first?.clientId == "c1")
196
+ }
197
+
198
+ @Test("Route current inspect result")
199
+ func routeCurrentInspectResult() throws {
200
+ let router = MessageRouter()
201
+ let json = """
202
+ {"v":1,"type":"inspect_result","domain":"runtime","emittedAt":"2026-04-01T00:00:00Z","source":"server","payload":{"text":"first\\nsecond","meta":{"session":"default","tabId":7,"tabTitle":"shell","cols":80,"rows":24,"timestamp":1712000000000}}}
203
+ """
204
+ let result = router.route(json)
205
+ guard case .inspectResult(let snapshot) = result else {
206
+ Issue.record("Expected .inspectResult")
207
+ return
208
+ }
209
+ #expect(snapshot.items.count == 2)
210
+ #expect(snapshot.items[0].content == "first")
211
+ #expect(snapshot.descriptor.tabIndex == 7)
212
+ }
179
213
  }
@@ -20,12 +20,13 @@ struct RemuxStateTests {
20
20
  func processWorkspaceState() {
21
21
  let state = RemuxState()
22
22
  let json = """
23
- {"type":"state","session":"main","tabs":[{"index":0,"name":"zsh","active":true,"isFullscreen":false,"hasBell":false,"panes":[{"id":"p1","focused":true,"title":"zsh","command":null,"cwd":"/tmp","rows":24,"cols":80,"x":0,"y":0}]}],"activeTabIndex":0}
23
+ {"type":"state","sessions":[{"name":"main","tabs":[{"id":11,"title":"zsh","ended":false,"clients":1,"restored":false}],"createdAt":1712000000000}],"clients":[]}
24
24
  """
25
25
  state.connectionDidReceiveMessage(json)
26
26
  #expect(state.currentSession == "main")
27
27
  #expect(state.tabs.count == 1)
28
28
  #expect(state.tabs[0].name == "zsh")
29
+ #expect(state.tabs[0].panes[0].id == "11")
29
30
  }
30
31
 
31
32
  @Test("Process role_changed message")
@@ -44,7 +45,7 @@ struct RemuxStateTests {
44
45
  func processInspectResult() {
45
46
  let state = RemuxState()
46
47
  let json = """
47
- {"type":"inspect_result","descriptor":{"scope":"tab","source":"state_tracker","precision":"precise","staleness":"fresh","capturedAt":"2026-04-01T00:00:00Z","paneId":null,"tabIndex":0,"totalItems":1},"items":[{"type":"output","content":"hello","lineNumber":1,"timestamp":"2026-04-01T00:00:00Z","paneId":"p1","highlights":null}],"cursor":null,"truncated":false}
48
+ {"type":"inspect_result","text":"hello","meta":{"session":"main","tabId":11,"tabTitle":"zsh","cols":80,"rows":24,"timestamp":1712000000000}}
48
49
  """
49
50
  state.connectionDidReceiveMessage(json)
50
51
  #expect(state.inspectSnapshot != nil)
@@ -52,6 +53,23 @@ struct RemuxStateTests {
52
53
  #expect(state.inspectSnapshot?.items[0].content == "hello")
53
54
  }
54
55
 
56
+ @Test("Attached message updates current tab and role")
57
+ @MainActor
58
+ func processAttached() {
59
+ let state = RemuxState()
60
+ state.connectionDidReceiveMessage("""
61
+ {"type":"state","sessions":[{"name":"main","tabs":[{"id":11,"title":"zsh","ended":false,"clients":1,"restored":false}],"createdAt":1712000000000}],"clients":[]}
62
+ """)
63
+ state.connectionDidReceiveMessage("""
64
+ {"type":"attached","tabId":11,"session":"main","clientId":"c1","role":"observer"}
65
+ """)
66
+
67
+ #expect(state.currentSession == "main")
68
+ #expect(state.activeTabIndex == 11)
69
+ #expect(state.clientRole == "observer")
70
+ #expect(state.tabs.first?.active == true)
71
+ }
72
+
55
73
  @Test("ConnectionStatus is Equatable")
56
74
  func statusEquatable() {
57
75
  #expect(ConnectionStatus.connected == ConnectionStatus.connected)