Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Glint/Resources/WebRemote/web-remote.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
107 changes: 102 additions & 5 deletions Glint/Resources/WebRemote/web-remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ const translations = {
refresh: "刷新",
remote_terminal: "远程终端",
select_terminal: "选择一个终端",
selection_in_progress: "正在切换终端,请稍候",
sync_description: "画面和输入会在浏览器与这台 Mac 上的 Glint 会话之间实时同步。",
syncing_terminal: "正在同步终端画面…",
terminal_count: "{count} 个终端",
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -299,6 +373,7 @@ function connect() {
if (socket) {
socket.close();
}
lastServerMessageAt = Date.now();
authenticated = false;
controllingPane = "";
resetSession();
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
82 changes: 66 additions & 16 deletions Glint/WebRemote/WebRemoteServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,24 @@ final class WebRemoteServer: @unchecked Sendable {
return String(describing: endpoint)
}

static func panesToReconcileWhenSelecting(
subscribedPane: String?,
pendingPane: String?,
nextPane: String
) -> Set<String>? {
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
Expand Down Expand Up @@ -863,38 +881,51 @@ 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(
byteLimit: Self.maxSelectionOutputBytes
)
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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Loading