@wangyaoshen/remux 0.3.8-dev.bab6c95 → 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.
Files changed (33) hide show
  1. package/.github/workflows/publish.yml +191 -17
  2. package/apps/ios/Remux.xcodeproj/project.pbxproj +21 -0
  3. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  4. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  5. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  6. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  7. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  8. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  9. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  10. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  11. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  12. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  13. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  14. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  15. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  16. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  17. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  18. package/apps/ios/Sources/Remux/RootView.swift +2 -2
  19. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +11 -4
  20. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +4 -8
  21. package/package.json +1 -1
  22. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +64 -0
  23. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +88 -9
  24. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +47 -8
  25. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +81 -8
  26. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +20 -1
  27. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +16 -0
  28. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +26 -0
  29. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +41 -7
  30. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +20 -2
  31. package/scripts/setup-ci-secrets.sh +80 -0
  32. package/scripts/upload-testflight.sh +100 -0
  33. package/tests/server.test.js +1 -1
@@ -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)
@@ -0,0 +1,80 @@
1
+ #!/bin/bash
2
+ # Export signing certificates and configure GitHub Secrets for CI/CD.
3
+ # Must be run interactively (GUI session required for keychain export).
4
+ set -euo pipefail
5
+
6
+ REPO="yaoshenwang/remux"
7
+ EXPORT_PW="ci-remux-2024"
8
+ TMPDIR=$(mktemp -d)
9
+ trap "rm -rf $TMPDIR" EXIT
10
+
11
+ echo "=== Remux CI/CD Secrets Setup ==="
12
+ echo ""
13
+
14
+ # Step 1: Export certificates
15
+ echo "[1/4] 导出签名证书..."
16
+ echo " macOS 会弹出 Keychain 授权对话框,请点击「允许」"
17
+ echo ""
18
+
19
+ # Find cert hashes
20
+ DEV_HASH=$(security find-identity -v -p codesigning | grep "Apple Development" | head -1 | awk '{print $2}')
21
+ DIST_HASH=$(security find-identity -v -p codesigning | grep "Apple Distribution" | head -1 | awk '{print $2}')
22
+ DEVID_HASH=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | awk '{print $2}')
23
+
24
+ echo " Apple Development: $DEV_HASH"
25
+ echo " Apple Distribution: $DIST_HASH"
26
+ echo " Developer ID Application: $DEVID_HASH"
27
+ echo ""
28
+
29
+ # Export all identities as one .p12 (Keychain will prompt for each)
30
+ security export -k ~/Library/Keychains/login.keychain-db \
31
+ -t identities -f pkcs12 -P "$EXPORT_PW" \
32
+ -o "$TMPDIR/certs.p12"
33
+
34
+ echo "✓ 证书导出成功"
35
+
36
+ # Step 2: Base64 encode
37
+ echo ""
38
+ echo "[2/4] 编码证书和 API Key..."
39
+
40
+ CERTS_B64=$(base64 < "$TMPDIR/certs.p12")
41
+ P8_B64=$(base64 < ~/.private_keys/AuthKey_2D79888WND.p8)
42
+
43
+ echo "✓ 编码完成"
44
+
45
+ # Step 3: Set GitHub Secrets
46
+ echo ""
47
+ echo "[3/4] 设置 GitHub Secrets..."
48
+
49
+ gh secret set APPLE_CERTIFICATES_P12 --repo "$REPO" --body "$CERTS_B64"
50
+ echo " ✓ APPLE_CERTIFICATES_P12"
51
+
52
+ gh secret set APPLE_CERTIFICATES_PASSWORD --repo "$REPO" --body "$EXPORT_PW"
53
+ echo " ✓ APPLE_CERTIFICATES_PASSWORD"
54
+
55
+ gh secret set APP_STORE_CONNECT_API_KEY_ID --repo "$REPO" --body "2D79888WND"
56
+ echo " ✓ APP_STORE_CONNECT_API_KEY_ID"
57
+
58
+ gh secret set APP_STORE_CONNECT_ISSUER_ID --repo "$REPO" --body "871408b2-72c1-4989-9530-5b72d99f4f27"
59
+ echo " ✓ APP_STORE_CONNECT_ISSUER_ID"
60
+
61
+ gh secret set APP_STORE_CONNECT_API_KEY_P8 --repo "$REPO" --body "$P8_B64"
62
+ echo " ✓ APP_STORE_CONNECT_API_KEY_P8"
63
+
64
+ gh secret set APPLE_TEAM_ID --repo "$REPO" --body "LY8QD6TJN6"
65
+ echo " ✓ APPLE_TEAM_ID"
66
+
67
+ # Step 4: Verify
68
+ echo ""
69
+ echo "[4/4] 验证..."
70
+ gh secret list --repo "$REPO"
71
+
72
+ echo ""
73
+ echo "=== 全部完成 ✅ ==="
74
+ echo "已配置以下 Secrets:"
75
+ echo " APPLE_CERTIFICATES_P12 — 签名证书 (Development + Distribution + Developer ID)"
76
+ echo " APPLE_CERTIFICATES_PASSWORD — 证书导出密码"
77
+ echo " APP_STORE_CONNECT_API_KEY_ID — API Key ID"
78
+ echo " APP_STORE_CONNECT_ISSUER_ID — Issuer ID"
79
+ echo " APP_STORE_CONNECT_API_KEY_P8 — API Key .p8 (base64)"
80
+ echo " APPLE_TEAM_ID — Apple Team ID"