@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.
- 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
|
@@ -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
|
|
99
|
+
let credential = keychain.preferredCredential(forServer: server),
|
|
100
100
|
let url = URL(string: server) {
|
|
101
|
-
state.connect(url: url, credential:
|
|
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.
|
|
68
|
+
Text(device.trust)
|
|
69
69
|
.font(.caption)
|
|
70
|
-
.foregroundStyle(device.
|
|
70
|
+
.foregroundStyle(device.trust == "trusted" ? .green : .orange)
|
|
71
71
|
}
|
|
72
72
|
Spacer()
|
|
73
73
|
if let lastSeen = device.lastSeen {
|
|
74
|
-
Text(
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,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
|
|
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)
|