From 5482b50ae5f9279ccd3cdfeca6e7123758d032e3 Mon Sep 17 00:00:00 2001 From: defia Date: Fri, 31 Jul 2026 14:31:25 +0800 Subject: [PATCH 1/4] fix(web-remote): improve mobile scrolling and pane switching --- Glint/Resources/WebRemote/web-remote.css | 8 ++- Glint/Resources/WebRemote/web-remote.js | 76 +++++++++++++++++++++++- Glint/WebRemote/WebRemoteServer.swift | 35 +++++++---- GlintTests/WebRemoteProtocolTests.swift | 31 ++++++++++ 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/Glint/Resources/WebRemote/web-remote.css b/Glint/Resources/WebRemote/web-remote.css index 76e8c60..7a54dac 100644 --- a/Glint/Resources/WebRemote/web-remote.css +++ b/Glint/Resources/WebRemote/web-remote.css @@ -284,7 +284,13 @@ input:focus { background: var(--terminal-bg); } -#terminal { min-width: 0; min-height: 0; padding: 12px 10px 4px; display: none; } +#terminal { + min-width: 0; + min-height: 0; + padding: 12px 10px 4px; + display: none; + touch-action: none; +} #terminal.visible { display: block; } #terminal .xterm { height: 100%; } #terminal .xterm-viewport { scrollbar-color: color-mix(in srgb, var(--text) 16%, transparent) transparent; } diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js index f5da93d..7493025 100644 --- a/Glint/Resources/WebRemote/web-remote.js +++ b/Glint/Resources/WebRemote/web-remote.js @@ -86,7 +86,6 @@ const translations = { refresh: "刷新", remote_terminal: "远程终端", select_terminal: "选择一个终端", - selection_in_progress: "正在切换终端,请稍候", sync_description: "画面和输入会在浏览器与这台 Mac 上的 Glint 会话之间实时同步。", syncing_terminal: "正在同步终端画面…", terminal_count: "{count} 个终端", @@ -137,7 +136,6 @@ const translations = { refresh: "Refresh", remote_terminal: "Remote terminal", select_terminal: "Select a terminal", - selection_in_progress: "Switching terminals, please wait", sync_description: "The browser stays in sync with this Mac's live Glint session.", syncing_terminal: "Syncing terminal…", terminal_count: "{count} terminal(s)", @@ -247,6 +245,79 @@ document.fonts?.load('13px "Glint Nerd Symbols"').then(() => { fitTerminal(); }); +let touchScrollY = null; +let touchScrollRemainder = 0; + +function resetTouchScroll() { + touchScrollY = null; + touchScrollRemainder = 0; +} + +function touchCenterY(touches) { + return (touches[0].clientY + touches[1].clientY) / 2; +} + +function terminalLineHeight() { + const row = elements.terminal.querySelector(".xterm-rows > div"); + const measured = row?.getBoundingClientRect().height; + return measured > 0 + ? measured + : terminal.options.fontSize * terminal.options.lineHeight; +} + +function scrollTerminalLinesFromTouch(lines, clientX, clientY) { + const screen = elements.terminal.querySelector(".xterm-screen"); + let handledByTerminal = false; + if (screen) { + const direction = Math.sign(lines); + for (let index = 0; index < Math.abs(lines); index += 1) { + const wheelEvent = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + clientX, + clientY, + deltaMode: WheelEvent.DOM_DELTA_LINE, + deltaY: direction, + }); + if (!screen.dispatchEvent(wheelEvent)) handledByTerminal = true; + } + } + if (!handledByTerminal && terminal.buffer.active.baseY > 0) { + terminal.scrollLines(lines); + } +} + +elements.terminal.addEventListener("touchstart", event => { + if (event.touches.length !== 2) { + resetTouchScroll(); + return; + } + event.preventDefault(); + touchScrollY = touchCenterY(event.touches); + touchScrollRemainder = 0; +}, { passive: false }); + +elements.terminal.addEventListener("touchmove", event => { + if (event.touches.length !== 2 || touchScrollY === null) { + resetTouchScroll(); + return; + } + event.preventDefault(); + const nextY = touchCenterY(event.touches); + touchScrollRemainder += touchScrollY - nextY; + touchScrollY = nextY; + + const lineHeight = terminalLineHeight(); + const lines = Math.trunc(touchScrollRemainder / lineHeight); + if (lines === 0) return; + const clientX = (event.touches[0].clientX + event.touches[1].clientX) / 2; + scrollTerminalLinesFromTouch(lines, clientX, nextY); + touchScrollRemainder -= lines * lineHeight; +}, { passive: false }); + +elements.terminal.addEventListener("touchend", resetTouchScroll); +elements.terminal.addEventListener("touchcancel", resetTouchScroll); + let socket; let reconnectTimer; let reconnectDelay = 500; @@ -616,7 +687,6 @@ function errorLabel(code) { "workspace-archived": t("workspace_archived"), "last-terminal": t("last_terminal"), "terminal-not-ready": t("terminal_not_ready"), - "selection-in-progress": t("selection_in_progress"), "unknown-command": t("unknown_command"), }; return labels[code] || t("operation_failed", { code }); diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index 1c33cfa..ca29b1d 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -195,6 +195,15 @@ final class WebRemoteServer: @unchecked Sendable { return String(describing: endpoint) } + static func panesToReconcileWhenSelecting( + subscribedPane: String?, + pendingPane: String?, + nextPane: String + ) -> Set? { + guard pendingPane != nextPane else { return nil } + return Set([subscribedPane, pendingPane].compactMap { $0 }) + } + private enum ListenerKind: Hashable { case http case webSocket @@ -863,12 +872,14 @@ final class WebRemoteServer: @unchecked Sendable { size: WebRemoteTerminalSize, for clientID: UUID ) { - guard let client = clients[clientID], client.pendingPane == nil else { - sendError("selection-in-progress", to: clientID) - return - } + guard let client = clients[clientID], + let previousPanes = Self.panesToReconcileWhenSelecting( + subscribedPane: client.subscribedPane, + pendingPane: client.pendingPane, + nextPane: pane + ) + else { return } - let previousPane = client.subscribedPane client.subscribedPane = nil client.pendingPane = pane client.pendingSelectionOutput = WebRemoteOutputBuffer( @@ -876,9 +887,7 @@ final class WebRemoteServer: @unchecked Sendable { ) client.terminalSize = nil updateSubscribedPanesLocked() - if let previousPane { - reconcileTerminalSizeLocked(for: previousPane) - } + previousPanes.forEach { reconcileTerminalSizeLocked(for: $0) } DispatchQueue.main.async { [weak self] in guard let self, let store = WorkspaceStore.current else { return } @@ -891,11 +900,15 @@ final class WebRemoteServer: @unchecked Sendable { let result = store.webRemoteTerminalSnapshot(pane: pane) self.queue.async { [weak self, weak store] in guard let self, - let store, - let client = clients[clientID], + let store + else { return } + guard let client = clients[clientID], client.authenticated, client.pendingPane == pane - else { return } + else { + reconcileTerminalSizeLocked(for: pane) + return + } switch result { case let .success(snapshot): let bufferedOutput = client.pendingSelectionOutput.take( diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift index 5020ad0..544e8f4 100644 --- a/GlintTests/WebRemoteProtocolTests.swift +++ b/GlintTests/WebRemoteProtocolTests.swift @@ -313,6 +313,37 @@ final class WebRemoteProtocolTests: XCTestCase { XCTAssertNil(WebRemoteAssets.asset(for: "/favicon.ico")) } + func testBundledAssetsSupportTwoFingerTerminalScrolling() throws { + let scriptURL = try XCTUnwrap( + Bundle.main.url(forResource: "web-remote", withExtension: "js") + ) + let styleURL = try XCTUnwrap( + Bundle.main.url(forResource: "web-remote", withExtension: "css") + ) + let script = try String(contentsOf: scriptURL, encoding: .utf8) + let style = try String(contentsOf: styleURL, encoding: .utf8) + + XCTAssertTrue(script.contains("\"touchstart\"")) + XCTAssertTrue(script.contains("\"touchmove\"")) + XCTAssertTrue(script.contains("terminal.scrollLines")) + XCTAssertTrue(script.contains("terminal.buffer.active.baseY > 0")) + XCTAssertTrue(script.contains("new WheelEvent(\"wheel\"")) + XCTAssertTrue(script.contains(".xterm-screen")) + XCTAssertTrue(script.contains("passive: false")) + XCTAssertTrue(style.contains("touch-action: none")) + } + + func testSelectingNewPaneSupersedesPendingSelection() { + XCTAssertEqual( + WebRemoteServer.panesToReconcileWhenSelecting( + subscribedPane: nil, + pendingPane: "pane-a", + nextPane: "pane-b" + ), + Set(["pane-a"]) + ) + } + func testHeadResponseKeepsContentLengthWithoutBody() { let body = Data("hello".utf8) let response = WebRemoteHTTPResponse.make( From e27fe7ae447e729a75f11a86a04c7a9df4730f4c Mon Sep 17 00:00:00 2001 From: defia Date: Sun, 9 Aug 2026 10:24:16 +0800 Subject: [PATCH 2/4] fix(web-remote): recover stale browser connections --- Glint/Resources/WebRemote/web-remote.js | 30 +++++++++++++++++++++++-- Glint/WebRemote/WebRemoteServer.swift | 1 + GlintTests/WebRemoteProtocolTests.swift | 14 ++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js index 7493025..7f8d0ed 100644 --- a/Glint/Resources/WebRemote/web-remote.js +++ b/Glint/Resources/WebRemote/web-remote.js @@ -321,6 +321,9 @@ elements.terminal.addEventListener("touchcancel", resetTouchScroll); let socket; let reconnectTimer; let reconnectDelay = 500; +const heartbeatInterval = 3000; +const serverSilenceTimeout = heartbeatInterval * 4; +let lastServerMessageAt = Date.now(); let authenticated = false; let selectedPane = sessionStorage.getItem("glint-selected-pane") || ""; let lastState; @@ -370,6 +373,7 @@ function connect() { if (socket) { socket.close(); } + lastServerMessageAt = Date.now(); authenticated = false; controllingPane = ""; resetSession(); @@ -378,12 +382,14 @@ function connect() { const currentSocket = socket; socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { + lastServerMessageAt = Date.now(); reconnectDelay = 500; // The server issues an auth-challenge as soon as the socket opens; we wait // for it rather than sending the token ourselves. if (!token) showAuth(); }); socket.addEventListener("message", event => { + lastServerMessageAt = Date.now(); const data = event.data; if (typeof data === "string") { handleMessage(data); // plaintext: auth-challenge / handshake error @@ -402,6 +408,16 @@ function connect() { socket.addEventListener("error", () => setStatus("error", t("unable_connect"))); } +function reconnectIfStale() { + const stale = authenticated && Date.now() - lastServerMessageAt >= serverSilenceTimeout; + if (!socket || + socket.readyState === WebSocket.CLOSING || + socket.readyState === WebSocket.CLOSED || + stale) { + connect(); + } +} + function resetSession() { pendingChallenge = null; c2sKey = null; @@ -959,12 +975,22 @@ function encodeBase64(bytes) { } window.addEventListener("resize", syncVisualViewport); +window.addEventListener("online", connect); +window.addEventListener("pageshow", reconnectIfStale); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") reconnectIfStale(); +}); window.visualViewport?.addEventListener("resize", syncVisualViewport); window.visualViewport?.addEventListener("scroll", syncVisualViewport); new ResizeObserver(fitTerminal).observe(elements.terminal); syncVisualViewport(); setInterval(() => { - if (authenticated) send({ type: "list" }); -}, 3000); + if (!authenticated) return; + if (Date.now() - lastServerMessageAt >= serverSilenceTimeout) { + connect(); + return; + } + send({ type: "list" }); +}, heartbeatInterval); connect(); diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index ca29b1d..ba32a0f 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -1312,6 +1312,7 @@ private final class WebRemoteClientConnection: @unchecked Sendable { connection.receiveMessage { [weak self] content, context, _, error in guard let self else { return } if error != nil { + cancel() server?.removeClient(id) return } diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift index 544e8f4..775eb97 100644 --- a/GlintTests/WebRemoteProtocolTests.swift +++ b/GlintTests/WebRemoteProtocolTests.swift @@ -333,6 +333,20 @@ final class WebRemoteProtocolTests: XCTestCase { XCTAssertTrue(style.contains("touch-action: none")) } + func testBundledClientRecoversStaleWebSocketConnections() throws { + let scriptURL = try XCTUnwrap( + Bundle.main.url(forResource: "web-remote", withExtension: "js") + ) + let script = try String(contentsOf: scriptURL, encoding: .utf8) + + XCTAssertTrue(script.contains("const serverSilenceTimeout = heartbeatInterval * 4")) + XCTAssertTrue(script.contains("Date.now() - lastServerMessageAt >= serverSilenceTimeout")) + XCTAssertTrue(script.contains("window.addEventListener(\"online\", connect)")) + XCTAssertTrue(script.contains("window.addEventListener(\"pageshow\", reconnectIfStale)")) + XCTAssertTrue(script.contains("document.addEventListener(\"visibilitychange\"")) + XCTAssertTrue(script.contains("if (socket !== currentSocket) return")) + } + func testSelectingNewPaneSupersedesPendingSelection() { XCTAssertEqual( WebRemoteServer.panesToReconcileWhenSelecting( From 2e76145c943ba29063bf71aea9247036894a03c0 Mon Sep 17 00:00:00 2001 From: defia Date: Sun, 9 Aug 2026 10:42:32 +0800 Subject: [PATCH 3/4] fix(web-remote): isolate stale async callbacks --- Glint/Resources/WebRemote/web-remote.js | 1 + Glint/WebRemote/WebRemoteServer.swift | 54 ++++++++++++++++++++----- GlintTests/WebRemoteProtocolTests.swift | 38 ++++++++++++++++- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js index 7f8d0ed..55cf1f2 100644 --- a/Glint/Resources/WebRemote/web-remote.js +++ b/Glint/Resources/WebRemote/web-remote.js @@ -389,6 +389,7 @@ function connect() { if (!token) showAuth(); }); socket.addEventListener("message", event => { + if (socket !== currentSocket) return; lastServerMessageAt = Date.now(); const data = event.data; if (typeof data === "string") { diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index ba32a0f..3f30b66 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -204,6 +204,15 @@ final class WebRemoteServer: @unchecked Sendable { return Set([subscribedPane, pendingPane].compactMap { $0 }) } + static func isCurrentPaneSelection( + pendingPane: String?, + pendingGeneration: UInt64, + pane: String, + generation: UInt64 + ) -> Bool { + pendingPane == pane && pendingGeneration == generation + } + private enum ListenerKind: Hashable { case http case webSocket @@ -880,6 +889,8 @@ final class WebRemoteServer: @unchecked Sendable { ) else { return } + client.paneSelectionGeneration &+= 1 + let selectionGeneration = client.paneSelectionGeneration client.subscribedPane = nil client.pendingPane = pane client.pendingSelectionOutput = WebRemoteOutputBuffer( @@ -893,7 +904,12 @@ final class WebRemoteServer: @unchecked Sendable { guard let self, let store = WorkspaceStore.current else { return } if let error = store.controlFocus(pane: pane, activateApp: false) { self.queue.async { [weak self] in - self?.finishSelectionFailure(error, pane: pane, clientID: clientID) + self?.finishSelectionFailure( + error, + pane: pane, + generation: selectionGeneration, + clientID: clientID + ) } return } @@ -904,11 +920,13 @@ final class WebRemoteServer: @unchecked Sendable { else { return } guard let client = clients[clientID], client.authenticated, - client.pendingPane == pane - else { - reconcileTerminalSizeLocked(for: pane) - return - } + Self.isCurrentPaneSelection( + pendingPane: client.pendingPane, + pendingGeneration: client.paneSelectionGeneration, + pane: pane, + generation: selectionGeneration + ) + else { return } switch result { case let .success(snapshot): let bufferedOutput = client.pendingSelectionOutput.take( @@ -935,14 +953,31 @@ final class WebRemoteServer: @unchecked Sendable { } } case let .failure(error): - finishSelectionFailure(error, pane: pane, clientID: clientID) + finishSelectionFailure( + error, + pane: pane, + generation: selectionGeneration, + clientID: clientID + ) } } } } - private func finishSelectionFailure(_ error: String, pane: String, clientID: UUID) { - guard let client = clients[clientID], client.pendingPane == pane else { return } + private func finishSelectionFailure( + _ error: String, + pane: String, + generation: UInt64, + clientID: UUID + ) { + guard let client = clients[clientID], + Self.isCurrentPaneSelection( + pendingPane: client.pendingPane, + pendingGeneration: client.paneSelectionGeneration, + pane: pane, + generation: generation + ) + else { return } _ = client.pendingSelectionOutput.take() client.pendingPane = nil updateSubscribedPanesLocked() @@ -1148,6 +1183,7 @@ private final class WebRemoteClientConnection: @unchecked Sendable { var authenticated = false var subscribedPane: String? var pendingPane: String? + var paneSelectionGeneration: UInt64 = 0 var pendingSelectionOutput = WebRemoteOutputBuffer(byteLimit: 0) var terminalSize: WebRemoteTerminalSize? var terminalSizeRevision: UInt64 = 0 diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift index 775eb97..dea8f6b 100644 --- a/GlintTests/WebRemoteProtocolTests.swift +++ b/GlintTests/WebRemoteProtocolTests.swift @@ -344,7 +344,24 @@ final class WebRemoteProtocolTests: XCTestCase { XCTAssertTrue(script.contains("window.addEventListener(\"online\", connect)")) XCTAssertTrue(script.contains("window.addEventListener(\"pageshow\", reconnectIfStale)")) XCTAssertTrue(script.contains("document.addEventListener(\"visibilitychange\"")) - XCTAssertTrue(script.contains("if (socket !== currentSocket) return")) + + let messageHandlerStart = try XCTUnwrap( + script.range(of: "socket.addEventListener(\"message\", event => {") + ) + let closeHandlerStart = try XCTUnwrap( + script.range( + of: "socket.addEventListener(\"close\", () => {", + range: messageHandlerStart.upperBound ..< script.endIndex + ) + ) + let messageHandler = script[messageHandlerStart.lowerBound ..< closeHandlerStart.lowerBound] + let identityGuard = try XCTUnwrap( + messageHandler.range(of: "if (socket !== currentSocket) return") + ) + let timestampUpdate = try XCTUnwrap( + messageHandler.range(of: "lastServerMessageAt = Date.now()") + ) + XCTAssertLessThan(identityGuard.lowerBound, timestampUpdate.lowerBound) } func testSelectingNewPaneSupersedesPendingSelection() { @@ -358,6 +375,25 @@ final class WebRemoteProtocolTests: XCTestCase { ) } + func testRepeatedPaneSelectionOnlyAcceptsLatestGeneration() { + XCTAssertFalse( + WebRemoteServer.isCurrentPaneSelection( + pendingPane: "pane-a", + pendingGeneration: 3, + pane: "pane-a", + generation: 1 + ) + ) + XCTAssertTrue( + WebRemoteServer.isCurrentPaneSelection( + pendingPane: "pane-a", + pendingGeneration: 3, + pane: "pane-a", + generation: 3 + ) + ) + } + func testHeadResponseKeepsContentLengthWithoutBody() { let body = Data("hello".utf8) let response = WebRemoteHTTPResponse.make( From f2e34e11cd9b40cdb9cb2d31b0eae82ba7d1fdd6 Mon Sep 17 00:00:00 2001 From: defia Date: Sun, 9 Aug 2026 10:47:14 +0800 Subject: [PATCH 4/4] test(web-remote): avoid random token assertion flake --- GlintTests/WebRemoteProtocolTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift index dea8f6b..eceaf27 100644 --- a/GlintTests/WebRemoteProtocolTests.swift +++ b/GlintTests/WebRemoteProtocolTests.swift @@ -162,7 +162,8 @@ final class WebRemoteProtocolTests: XCTestCase { XCTAssertTrue(WebRemoteAccessToken.matches(token, expected: token)) XCTAssertFalse(WebRemoteAccessToken.matches(nil, expected: token)) XCTAssertFalse(WebRemoteAccessToken.matches(token + "0", expected: token)) - XCTAssertFalse(WebRemoteAccessToken.matches(String(token.dropLast()) + "0", expected: token)) + let differentSuffix = token.last == "0" ? "1" : "0" + XCTAssertFalse(WebRemoteAccessToken.matches(String(token.dropLast()) + differentSuffix, expected: token)) } func testAccessKeyPersistsUntilExplicitReset() {