Skip to content

Commit 50ae246

Browse files
lawrencecchencmux-lawrenceclaude
authored
iOS composer: image attachments, attach button, autocorrect, padding (#6102)
* iOS composer: pending image attachments in store + send orchestration Add per-terminal pending-attachment state to the mobile shell store so picked images can be staged as drafts (keyed by terminal id, like the text draft) and sent on the next composer submit, reusing the existing terminal.paste_image transport. - New MobilePendingAttachment value type (data + lowercase format + stable id), host-testable (no UIKit). - Store add/remove/clear/read methods plus composerCanSend (text non-empty OR attachments present, so an images-only send is allowed). - submitComposer() sends staged images in pick order (awaited) then the text, then clears the staged set for the submitted terminal. - Unit tests for add/remove/clear, per-terminal keying, send gating, and clear-after-send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iOS composer UI: attach button, attachment chips, autocorrect, padding - Replace the chevron.down (hide-composer) button to the left of the field with a paperclip attach button that opens a PhotosUI photo picker (multi-select). Composer dismissal still lives on the accessory toolbar's compose toggle. - Picked images are encoded the same way the clipboard paste path encodes them (PNG, JPEG fallback over ~8MB) and staged as pending attachments. - Render staged attachments as a horizontal row of removable thumbnail chips above the text field (iMessage style). - Send is enabled when text is non-empty OR attachments are staged; send routes through store.submitComposer() (images first, then text) and re-measures the band height. - Composer now uses normal text assistance (autocorrect on, sentence-case) since it is natural language to an agent; the raw terminal input is unchanged. - Reduce the top padding above the field (was 8pt vertical, now 2pt top / 8pt bottom) so the composer sits tighter; band measurement still driven by content + padding. - Add NSPhotoLibraryUsageDescription to Info.plist and en/ja strings for the attach and remove-attachment labels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Refresh Swift file-length budget for this change The touched file is already well over any reasonable size; splitting it is a separate refactor and stored properties cannot move to an extension. Accept the incremental growth as known debt so the budget guard reflects reality. * Fix composer attachment send routing, failure handling, and memory Three P1 autoreview fixes for the iOS composer image attachments: 1. Capture target terminal once in submitComposer. The image and text sends now thread an explicit workspace + terminal id captured at submit time, so a terminal switch while an awaited image send is in flight can no longer reroute later images or the text to whatever is selected at that moment. Adds internal targeted variants: submitTerminalPasteImage(_:format:workspaceID:terminalID:) and submitComposerInput(workspaceID:terminalID:); the public selection-based overloads call them, preserving the clipboard-paste path. 2. Keep attachments on a failed send. The image send path now returns Bool (threaded through sendRemoteTerminalPasteImage). submitComposer removes each attachment only after its send is acknowledged; on a failure it stops, keeps the remaining and failed attachments staged, and does not submit the text, matching the text-keep-on-failure semantics. 3. Bound staging and stop per-render decodes. The picker is capped at 10 and a 32 MB total byte budget is enforced when staging. Each attachment gets a small downsampled thumbnail built once off the main thread (via ImageIO) and cached by id; the chip renders the cached thumbnail instead of decoding the full Data in the view body on every keystroke. Tests: ComposerPendingAttachmentTests updated for keep-on-failure; new ComposerSubmitRoutingTests drives submitComposer end to end over the real paste RPC frames (captured-terminal routing, mid-send switch, full and partial image-send failures). Budget TSV refreshed for the two grown files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix round-2 composer-attachment autoreview findings 1. Swift 6 Sendable: PreparedAttachment no longer carries a non-Sendable UIImage across the detached thumbnail-prep task. The off-main path keeps ImageIO downsampling but returns the thumbnail as PNG Data; the UIImage is built on the main actor when populating the chip cache. 2. Privacy: clear pendingAttachmentsByTerminalID in signOut(), alongside the text-draft wipe, so a previous account's staged photo bytes cannot resurface on a reused terminal id. 3. Re-entrancy: add isSubmittingComposer guard around the whole submitComposer() (images + text), so a double tap on Send cannot re-upload the still-staged attachments while the first RPC awaits. Failure still keeps attachments. 4. UI test: testComposerSurvivesRepeatedOpenCloseCycles closes the composer via the accessory compose toggle instead of the removed MobileComposerClose chevron; drop the now-unused bandClose constant. 5. Localize NSPhotoLibraryUsageDescription in InfoPlist.xcstrings (en + ja). Add host tests for sign-out clearing (finding 2) and double-submit guard (finding 3). Refresh the Swift file-length budget for the two touched files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix round-3 composer-attachment autoreview findings Two async/privacy bugs from the codex autoreview: 1. submitComposer captured the target terminal but re-read the live terminalInputText after the awaited image paste_image RPCs. A terminal switch (draft swap) or a field edit during those awaits could skip the composed text or paste a different terminal's draft. Snapshot the text at the very start of submitComposer (before any await) and thread it through submitComposerInput via a new capturedText param; the post-send reconcile already keys on that same sent text, so a mid-send edit is preserved (cleared only when the field still equals the snapshot). Text-only entry points keep nil (no prior await, no drift). Images-only sends snapshot empty text and no-op the text submit. 2. The photo picker started an unstructured Task that awaited load+encode then unconditionally re-staged bytes via addPendingAttachment. A sign-out in flight cleared pending attachments but the continuation re-added the previous user's photo under an explicit terminal id. Add a signInGeneration token bumped by signOut, captured before staging, and re-checked in a new guarded addPendingAttachment(...ifSessionGeneration:) store path that drops the result when the token moved or the target terminal no longer exists. Tests: ComposerSubmitRoutingTests gains text-snapshot-survives-edit and text-snapshot-survives-switch; ComposerPendingAttachmentTests gains guarded-add dropped-after-signout, dropped-when-terminal-gone, and succeeds-when-unchanged. Budget actuals refreshed for both touched files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden composer image-attach staging (round 3) Fix three autoreview findings in the iOS composer image-attachment staging and send path. Finding 1 (OOM): replace the full-raster pngData() encode with a bounded ImageIO path. prepare() now downsamples the picked item via CGImageSourceCreateThumbnailAtIndex (send payload capped at 2048 px longest edge, thumbnail at 168 px) and re-encodes the bounded CGImage through CGImageDestination, so a large HEIC/JPEG/panorama is never materialized as a full-resolution raster. The send payload tries PNG at the send size, then JPEG at decreasing quality, then smaller dimensions, all enforced under the 8 MB per-image cap. Returns Data only across the concurrency boundary (Sendable-safe). Finding 2 (caps bypass): enforce the count cap (10), total-byte budget (32 MB), and per-image cap (8 MB) atomically inside the store's addPendingAttachment, computed against the current staged set at mutation time on the MainActor. Two racing picker batches can no longer both append past the cap. The view now tracks the staging Task and cancels the prior one when a new batch starts, and treats the store as the authoritative cap (its own checks are only a pre-filter). Sign-in generation guard kept. Finding 3 (removed-but-uploaded): submitComposer re-checks each attachment is still staged for the captured terminal before uploading it, so a chip the user deletes mid-send is skipped instead of uploaded from the local snapshot. Failure-keeps-attachments and re-entrancy behavior unchanged. Tests: store-enforced count and byte caps (including racing adds against the same starting budget) and a mid-send removal skipping that attachment. Budget TSV bumped for the two touched files. * Harden composer image-attach memory: topology prune + file-backed picker load Finding 1: the store could retain orphaned photo Data for stale terminal ids. The base addPendingAttachment now validates the target id exists in the current topology (shared terminalExistsInTopology check, used by both add paths so no unchecked entry point remains), and the workspaces didSet prunes pendingAttachmentsByTerminalID for any terminal that disappears from topology so multi-MB staged bytes are released on a sync rather than held until sign-out. Sign-out clear is unchanged. Added tests: base-path add for a missing id is rejected; a topology update dropping a terminal prunes its attachments while a surviving (and a moved-elsewhere) terminal keeps its. Finding 2: the picker loaded the full original asset into memory before any cap. Replaced loadTransferable(Data) with a file-backed ImportedImageFile Transferable (FileRepresentation(contentType: .image)) that copies the import to a temp URL, so a huge ProRAW/DNG/panorama never enters memory as Data. The picker now size-gates the file on disk (60 MB raw bound, above the 8 MB per-image cap since HEIC decodes larger) before reading, downsamples straight from the URL via CGImageSourceCreateWithURL, and deletes the temp file after encoding. Per-image and total caps, atomic store enforcement, the session-generation guard, and the staging Task cancellation are all preserved. Only Data/String/URL cross concurrency boundaries. Refreshed the swift file-length budget for the two touched files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bound pending image bytes globally; make picker ImageIO cancellable Finding 1: pending attachment bytes were capped per terminal but unbounded across all terminals, so staging photos across many terminals/workspaces grew linearly with terminal count and could OOM. Add global all-terminals caps (maxPendingAttachmentTotalBytesAllTerminals = 64 MB, maxPendingAttachmentCountAllTerminals = 20) enforced atomically in addPendingAttachment after the per-terminal checks, as a hard reject. The sum across all keys is consistent because the add runs on @mainactor. Per-terminal caps stay. Host tests cover the multi-terminal case (global byte and count budgets bind while each terminal is under its own cap; per-terminal cap still binds under global headroom). Finding 2: the picker's ImageIO decode/encode ran in an unstructured Task.detached that did not inherit cancellation, so cancelling the staging task (re-pick, terminal switch, view disappear) left the decode running and fanning out temp files. Run prepare() in a structured background-priority child task group so cancellation propagates; check Task.isCancelled before launching and before the heavy CGImageSourceCreateWithURL and the thumbnail encode. Cancel the staging task on composer .onDisappear and on a terminalID change, in addition to the existing cancel-on-new-batch. Temp files are still cleaned on the cancellation path via the existing per-iteration defer. Only Sendable Data crosses concurrency boundaries. Bump swift-file-length-budget.tsv for the two touched files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iOS composer: abort submit on session/connection change mid-send submitComposer() snapshots attachments and text up front but awaits a paste_image RPC per image. sendRemoteTerminalPasteImage returns true even when a superseded connection answered, so a sign-out, account switch, Mac switch, or reconnect that landed during an image await let the loop keep going: the next staged image and then the captured text were sent through whatever remoteClient is now current, leaking the previous user's or previous Mac's unsent content into a different session. Capture signInGeneration (sign-out / account switch) and connectionGeneration (Mac switch / reconnect / disconnect) at the start of the run and re-check both before every image send and before the text send via isComposerSubmitIdentityCurrent. On mismatch, abort the whole submit (stop the loop, do not send the text) and leave attachments and text staged for a retry. Both generations already existed and are bumped on those edges. Add host-testable coverage in ComposerSubmitRoutingTests: a sign-out and a connection swap between the first and second image send each abort the submit so no further image and no text reach the new session, and the connection-swap case keeps the unsent attachment and text staged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: cmux-lawrence <cmux-lawrence@cmux-lawrences-Mac-mini.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 75525e6 commit 50ae246

11 files changed

Lines changed: 2276 additions & 97 deletions

File tree

.github/swift-file-length-budget.tsv

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
6074 Sources/TextBoxInput.swift
2121
5925 cmuxTests/TerminalAndGhosttyTests.swift
2222
5526 cmuxTests/BrowserConfigTests.swift
23-
5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
23+
5551 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
2424
4920 Sources/cmuxApp.swift
2525
4467 Sources/Panels/FilePreviewPanel.swift
2626
4400 cmuxTests/BrowserPanelTests.swift
@@ -212,3 +212,4 @@
212212
503 Sources/Settings/ConfigSource.swift
213213
502 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
214214
502 Sources/CmuxEventPublishing.swift
215+
744 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift

Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift

Lines changed: 462 additions & 24 deletions
Large diffs are not rendered by default.

Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift

Lines changed: 463 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
import CMUXMobileCore
2+
import CmuxMobileRPC
3+
import CmuxMobileShellModel
4+
import Foundation
5+
import Testing
6+
@testable import CmuxMobileShell
7+
8+
// Scripted host fixtures for the composer send-routing tests
9+
// (ComposerSubmitRoutingTests.swift): a connected store backed by a recording
10+
// router that captures which terminal each terminal.paste / terminal.paste_image
11+
// request targeted, and can be told to reject paste_image so the keep-on-failure
12+
// path is exercised over the real wire.
13+
14+
// MARK: - Runtime double
15+
16+
private struct RoutingTestRuntime: MobileSyncRuntime {
17+
var transportFactory: any CmxByteTransportFactory
18+
var stackAccessTokenProvider: @Sendable () async throws -> String = { "test-stack-token" }
19+
var stackAccessTokenForceRefresher: @Sendable () async throws -> String = { "test-stack-token" }
20+
var rpcRequestTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000
21+
var now: @Sendable () -> Date = { Date() }
22+
var supportedRouteKinds: [CmxAttachTransportKind] = [.debugLoopback]
23+
var pairingRequestTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000
24+
var supportsServerPushEvents: Bool = true
25+
var livenessProbeTimeoutNanoseconds: UInt64 = 200_000_000
26+
}
27+
28+
// MARK: - Recording host (router + transport)
29+
30+
/// Answers the connect-time handshake for a workspace with TWO terminals and
31+
/// records every terminal.paste / terminal.paste_image request's target
32+
/// surface_id (and the image format), in send order. Can be configured to reject
33+
/// the paste_image call so the composer's keep-on-failure path runs.
34+
actor RoutingHostRouter {
35+
struct PasteImageRecord: Sendable {
36+
var surfaceID: String
37+
var format: String
38+
}
39+
struct PasteRecord: Sendable {
40+
var surfaceID: String
41+
var text: String
42+
}
43+
44+
private(set) var pasteImages: [PasteImageRecord] = []
45+
private(set) var pastes: [PasteRecord] = []
46+
/// Reject the Nth (0-based) and later paste_image requests; `nil` accepts all.
47+
private var rejectPasteImageFromIndex: Int?
48+
private var holdFirstPasteImage = false
49+
private var firstPasteImageHeld = false
50+
private var firstPasteImageContinuation: CheckedContinuation<Void, Never>?
51+
private var firstPasteImageReachedWaiters: [CheckedContinuation<Void, Never>] = []
52+
53+
static let workspaceID = "ws-route"
54+
static let terminalA = "term-route-a"
55+
static let terminalB = "term-route-b"
56+
57+
/// Reject every terminal.paste_image with an error frame, modeling a host
58+
/// that cannot accept the image (the composer must keep the attachment).
59+
func setRejectPasteImage(_ reject: Bool) {
60+
rejectPasteImageFromIndex = reject ? 0 : nil
61+
}
62+
63+
/// Accept paste_image requests before `index` (0-based) and reject that one
64+
/// and all later ones, so a test can prove a partial failure clears only the
65+
/// acknowledged attachments.
66+
func rejectPasteImage(fromIndex index: Int) {
67+
rejectPasteImageFromIndex = index
68+
}
69+
70+
/// Park the FIRST paste_image response until ``releaseFirstPasteImage()``,
71+
/// so a test can switch the selected terminal while that send is in flight
72+
/// and prove the send still targets the captured terminal.
73+
func setHoldFirstPasteImage(_ hold: Bool) {
74+
holdFirstPasteImage = hold
75+
}
76+
77+
/// Resolve when the first paste_image request has arrived (and is parked).
78+
func awaitFirstPasteImageReached() async {
79+
if firstPasteImageHeld { return }
80+
await withCheckedContinuation { firstPasteImageReachedWaiters.append($0) }
81+
}
82+
83+
/// Release the parked first paste_image so its (success) response is sent.
84+
func releaseFirstPasteImage() {
85+
let continuation = firstPasteImageContinuation
86+
firstPasteImageContinuation = nil
87+
continuation?.resume()
88+
}
89+
90+
func recordedPasteImages() -> [PasteImageRecord] { pasteImages }
91+
func recordedPastes() -> [PasteRecord] { pastes }
92+
93+
/// Sendable extract of the request fields the router needs, pulled off the
94+
/// non-Sendable params dictionary before crossing the Task boundary.
95+
struct RequestInfo: Sendable {
96+
var method: String?
97+
var id: String?
98+
var surfaceID: String?
99+
var imageFormat: String?
100+
var text: String?
101+
}
102+
103+
func response(_ info: RequestInfo) async -> Data? {
104+
let method = info.method
105+
let id = info.id
106+
switch method {
107+
case "workspace.list", "mobile.workspace.list":
108+
return try? Self.resultFrame(id: id, result: [
109+
"workspaces": [
110+
[
111+
"id": Self.workspaceID,
112+
"title": "Routing Workspace",
113+
"current_directory": "/tmp/route",
114+
"is_selected": true,
115+
"terminals": [
116+
[
117+
"id": Self.terminalA,
118+
"title": "A",
119+
"current_directory": "/tmp/route",
120+
"is_ready": true,
121+
"is_focused": true,
122+
],
123+
[
124+
"id": Self.terminalB,
125+
"title": "B",
126+
"current_directory": "/tmp/route",
127+
"is_ready": true,
128+
"is_focused": false,
129+
],
130+
],
131+
],
132+
],
133+
])
134+
case "mobile.host.status":
135+
return try? Self.resultFrame(id: id, result: [
136+
"terminal_fidelity": "render_grid",
137+
"capabilities": ["events.v1", "terminal.render_grid.v1", "terminal.replay.v1"],
138+
])
139+
case "mobile.events.subscribe":
140+
return try? Self.resultFrame(id: id, result: [
141+
"stream_id": "test-stream",
142+
"topics": ["workspace.updated", "terminal.render_grid"],
143+
"already_subscribed": false,
144+
])
145+
case "terminal.paste_image":
146+
let surfaceID = info.surfaceID ?? ""
147+
let format = info.imageFormat ?? ""
148+
let index = pasteImages.count
149+
pasteImages.append(PasteImageRecord(surfaceID: surfaceID, format: format))
150+
if index == 0 && holdFirstPasteImage {
151+
firstPasteImageHeld = true
152+
let reachedWaiters = firstPasteImageReachedWaiters
153+
firstPasteImageReachedWaiters = []
154+
for waiter in reachedWaiters { waiter.resume() }
155+
await withCheckedContinuation { firstPasteImageContinuation = $0 }
156+
}
157+
if let rejectFrom = rejectPasteImageFromIndex, index >= rejectFrom {
158+
return try? Self.errorFrame(id: id, message: "paste_image rejected")
159+
}
160+
return try? Self.resultFrame(id: id, result: [:])
161+
case "terminal.paste":
162+
let surfaceID = info.surfaceID ?? ""
163+
let text = info.text ?? ""
164+
pastes.append(PasteRecord(surfaceID: surfaceID, text: text))
165+
return try? Self.resultFrame(id: id, result: [:])
166+
case "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport":
167+
return try? Self.resultFrame(id: id, result: [:])
168+
default:
169+
return try? Self.errorFrame(id: id, message: "Unexpected method \(method ?? "nil")")
170+
}
171+
}
172+
173+
private static func resultFrame(id: String?, result: [String: Any]) throws -> Data {
174+
let envelope: [String: Any] = [
175+
"id": id ?? UUID().uuidString,
176+
"ok": true,
177+
"result": result,
178+
]
179+
return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope))
180+
}
181+
182+
private static func errorFrame(id: String?, message: String) throws -> Data {
183+
let envelope: [String: Any] = [
184+
"id": id ?? UUID().uuidString,
185+
"ok": false,
186+
"error": ["message": message],
187+
]
188+
return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope))
189+
}
190+
}
191+
192+
private struct RoutingTransportFactory: CmxByteTransportFactory {
193+
let router: RoutingHostRouter
194+
195+
func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport {
196+
RoutingTransport(router: router)
197+
}
198+
}
199+
200+
private actor RoutingTransport: CmxByteTransport {
201+
private let router: RoutingHostRouter
202+
private var pendingFrames: [Data] = []
203+
private var receiveWaiters: [CheckedContinuation<Data?, Never>] = []
204+
private var isClosed = false
205+
206+
init(router: RoutingHostRouter) {
207+
self.router = router
208+
}
209+
210+
func connect() async throws {}
211+
212+
func receive() async throws -> Data? {
213+
if !pendingFrames.isEmpty {
214+
return pendingFrames.removeFirst()
215+
}
216+
if isClosed {
217+
return nil
218+
}
219+
return await withCheckedContinuation { continuation in
220+
receiveWaiters.append(continuation)
221+
}
222+
}
223+
224+
func send(_ data: Data) async throws {
225+
var buffer = data
226+
let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer)
227+
for payload in payloads {
228+
let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any]
229+
let params = parsed?["params"] as? [String: Any]
230+
// Extract only the Sendable fields the router needs BEFORE the Task,
231+
// so the non-Sendable params dictionary never crosses the boundary.
232+
let info = RoutingHostRouter.RequestInfo(
233+
method: parsed?["method"] as? String,
234+
id: parsed?["id"] as? String,
235+
surfaceID: params?["surface_id"] as? String,
236+
imageFormat: params?["image_format"] as? String,
237+
text: params?["text"] as? String
238+
)
239+
Task { [router, weak self] in
240+
guard let response = await router.response(info) else {
241+
return
242+
}
243+
await self?.deliver(response)
244+
}
245+
}
246+
}
247+
248+
func close() async {
249+
isClosed = true
250+
let waiters = receiveWaiters
251+
receiveWaiters = []
252+
for waiter in waiters {
253+
waiter.resume(returning: nil)
254+
}
255+
}
256+
257+
private func deliver(_ frame: Data) {
258+
if receiveWaiters.isEmpty {
259+
pendingFrames.append(frame)
260+
return
261+
}
262+
let waiter = receiveWaiters.removeFirst()
263+
waiter.resume(returning: frame)
264+
}
265+
}
266+
267+
// MARK: - Connected-store builder
268+
269+
/// Build a store with a workspace of two terminals (term-a selected) and a real
270+
/// `MobileCoreRPCClient` wired DIRECTLY onto the store, backed by the recording
271+
/// transport. This deliberately bypasses the pairing/connect handshake (which
272+
/// the scripted-host harness cannot complete in this environment): the composer
273+
/// send path only needs a live `remoteClient` to reach the wire, and the
274+
/// session connects its transport lazily on the first request. The result is a
275+
/// deterministic end-to-end exercise of submitComposer's routing over the real
276+
/// terminal.paste / terminal.paste_image RPC frames.
277+
@MainActor
278+
func makeRoutingConnectedStore(router: RoutingHostRouter) async throws -> MobileShellComposite {
279+
let runtime = RoutingTestRuntime(
280+
transportFactory: RoutingTransportFactory(router: router)
281+
)
282+
let terminals = [
283+
MobileTerminalPreview(id: .init(rawValue: RoutingHostRouter.terminalA), name: "A"),
284+
MobileTerminalPreview(id: .init(rawValue: RoutingHostRouter.terminalB), name: "B"),
285+
]
286+
let store = MobileShellComposite(
287+
runtime: runtime,
288+
isSignedIn: true,
289+
workspaces: [
290+
MobileWorkspacePreview(
291+
id: .init(rawValue: RoutingHostRouter.workspaceID),
292+
name: "Routing Workspace",
293+
terminals: terminals
294+
),
295+
]
296+
)
297+
// 127.0.0.1 is a Stack-auth-trusted route, so authorized requests carry the
298+
// Stack token and do not throw insecureManualRoute before reaching the
299+
// transport. Enable the fallback to match the trusted-route production path.
300+
let route = try CmxAttachRoute(
301+
id: "debug_loopback",
302+
kind: .debugLoopback,
303+
endpoint: .hostPort(host: "127.0.0.1", port: 56585)
304+
)
305+
let ticket = try CmxAttachTicket(
306+
workspaceID: RoutingHostRouter.workspaceID,
307+
terminalID: RoutingHostRouter.terminalA,
308+
macDeviceID: "test-mac",
309+
macDisplayName: "Test Mac",
310+
routes: [route],
311+
expiresAt: Date().addingTimeInterval(3600)
312+
)
313+
store.remoteClient = MobileCoreRPCClient(
314+
runtime: runtime,
315+
route: route,
316+
ticket: ticket,
317+
allowsStackAuthFallback: true
318+
)
319+
return store
320+
}
321+
322+
/// Install a fresh `remoteClient` on an already-built store, backed by `router`.
323+
/// Models the new transport a reconnect / account switch / Mac switch installs:
324+
/// the mid-submit identity guard must abort BEFORE any further image or the text
325+
/// reaches this second router, so a test can assert that router recorded nothing.
326+
@MainActor
327+
func installFreshRemoteClient(on store: MobileShellComposite, router: RoutingHostRouter) throws {
328+
let runtime = RoutingTestRuntime(
329+
transportFactory: RoutingTransportFactory(router: router)
330+
)
331+
let route = try CmxAttachRoute(
332+
id: "debug_loopback",
333+
kind: .debugLoopback,
334+
endpoint: .hostPort(host: "127.0.0.1", port: 56586)
335+
)
336+
let ticket = try CmxAttachTicket(
337+
workspaceID: RoutingHostRouter.workspaceID,
338+
terminalID: RoutingHostRouter.terminalA,
339+
macDeviceID: "test-mac-2",
340+
macDisplayName: "Test Mac 2",
341+
routes: [route],
342+
expiresAt: Date().addingTimeInterval(3600)
343+
)
344+
store.remoteClient = MobileCoreRPCClient(
345+
runtime: runtime,
346+
route: route,
347+
ticket: ticket,
348+
allowsStackAuthFallback: true
349+
)
350+
}

0 commit comments

Comments
 (0)