@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.
- package/.github/workflows/publish.yml +191 -17
- package/apps/ios/Remux.xcodeproj/project.pbxproj +21 -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/RootView.swift +2 -2
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +11 -4
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +4 -8
- package/package.json +1 -1
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +64 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +88 -9
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +47 -8
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +81 -8
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +20 -1
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +16 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +26 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +41 -7
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +20 -2
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/upload-testflight.sh +100 -0
- 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
|
|
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
|
|
47
|
-
|
|
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
|
|
51
|
-
|
|
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?["
|
|
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
|
|
93
|
-
public let lastSeen:
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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, "
|
|
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 .
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
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":{"
|
|
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 .
|
|
125
|
-
Issue.record("Expected .
|
|
124
|
+
guard case .workspaceSnapshot(let snapshot) = result else {
|
|
125
|
+
Issue.record("Expected .workspaceSnapshot, got \(String(describing: result))")
|
|
126
126
|
return
|
|
127
127
|
}
|
|
128
|
-
#expect(
|
|
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 .
|
|
139
|
-
Issue.record("Expected .
|
|
140
|
+
guard case .workspaceSnapshot(let snapshot) = result else {
|
|
141
|
+
Issue.record("Expected .workspaceSnapshot, got \(String(describing: result))")
|
|
140
142
|
return
|
|
141
143
|
}
|
|
142
|
-
#expect(
|
|
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","
|
|
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","
|
|
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"
|