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..55cf1f2 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,9 +245,85 @@ 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; +const heartbeatInterval = 3000; +const serverSilenceTimeout = heartbeatInterval * 4; +let lastServerMessageAt = Date.now(); let authenticated = false; let selectedPane = sessionStorage.getItem("glint-selected-pane") || ""; let lastState; @@ -299,6 +373,7 @@ function connect() { if (socket) { socket.close(); } + lastServerMessageAt = Date.now(); authenticated = false; controllingPane = ""; resetSession(); @@ -307,12 +382,15 @@ 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 => { + if (socket !== currentSocket) return; + lastServerMessageAt = Date.now(); const data = event.data; if (typeof data === "string") { handleMessage(data); // plaintext: auth-challenge / handshake error @@ -331,6 +409,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; @@ -616,7 +704,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 }); @@ -889,12 +976,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 1c33cfa..3f30b66 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -195,6 +195,24 @@ 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 }) + } + + static func isCurrentPaneSelection( + pendingPane: String?, + pendingGeneration: UInt64, + pane: String, + generation: UInt64 + ) -> Bool { + pendingPane == pane && pendingGeneration == generation + } + private enum ListenerKind: Hashable { case http case webSocket @@ -863,12 +881,16 @@ 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 - } - - let previousPane = client.subscribedPane + guard let client = clients[clientID], + let previousPanes = Self.panesToReconcileWhenSelecting( + subscribedPane: client.subscribedPane, + pendingPane: client.pendingPane, + nextPane: pane + ) + else { return } + + client.paneSelectionGeneration &+= 1 + let selectionGeneration = client.paneSelectionGeneration client.subscribedPane = nil client.pendingPane = pane client.pendingSelectionOutput = WebRemoteOutputBuffer( @@ -876,25 +898,34 @@ 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 } 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 } 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 + Self.isCurrentPaneSelection( + pendingPane: client.pendingPane, + pendingGeneration: client.paneSelectionGeneration, + pane: pane, + generation: selectionGeneration + ) else { return } switch result { case let .success(snapshot): @@ -922,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() @@ -1135,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 @@ -1299,6 +1348,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 5020ad0..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() { @@ -313,6 +314,87 @@ 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 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\"")) + + 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() { + XCTAssertEqual( + WebRemoteServer.panesToReconcileWhenSelecting( + subscribedPane: nil, + pendingPane: "pane-a", + nextPane: "pane-b" + ), + Set(["pane-a"]) + ) + } + + 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(