Fix Guacamole tab visibility lifecycle (#1074)

Co-authored-by: default-student <default-student@github.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
This commit is contained in:
L.H.
2026-07-22 14:37:44 -05:00
committed by GitHub
co-authored by default-student Luke Gustafson
parent 08825c256d
commit 9b7f52b629
7 changed files with 120 additions and 25 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
"format:check": "prettier --check .", "format:check": "prettier --check .",
"biome:check": "biome check biome.json package.json", "biome:check": "biome check biome.json package.json",
"biome:fix": "biome check --write biome.json package.json", "biome:fix": "biome check --write biome.json package.json",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs", "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
"prebuild": "node scripts/write-electron-build-info.cjs", "prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
+66
View File
@@ -0,0 +1,66 @@
const fs = require("fs");
const path = require("path");
const packageRoot = path.join(
__dirname,
"..",
"node_modules",
"guacamole-common-js",
);
const bundlePaths = [
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
];
const oldFlushBlock =
" if (window.requestAnimationFrame && document.hasFocus())\n" +
" asyncFlush();\n" +
" else\n" +
" syncFlush();";
const newFlushBlock =
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
" // synchronously so Guacamole connections do not stall while waiting for\n" +
" // a frame callback that may never run.\n" +
" syncFlush();";
let patched = false;
let foundBundle = false;
for (const bundlePath of bundlePaths) {
if (!fs.existsSync(bundlePath)) {
console.log(`[patch-guacamole-common-js] ${bundlePath} not found, skipping`);
continue;
}
foundBundle = true;
let content = fs.readFileSync(bundlePath, "utf8");
if (content.includes(newFlushBlock)) continue;
if (!content.includes(oldFlushBlock)) {
console.log(
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
);
continue;
}
content = content.replace(oldFlushBlock, newFlushBlock);
fs.writeFileSync(bundlePath, content);
patched = true;
}
if (!foundBundle) {
console.log("[patch-guacamole-common-js] File not found, skipping");
process.exit(0);
}
if (!patched) {
console.log("[patch-guacamole-common-js] Already patched");
process.exit(0);
}
console.log(
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
);
@@ -170,7 +170,6 @@ const clientOptions = {
vnc: { vnc: {
"swap-red-blue": false, "swap-red-blue": false,
cursor: "remote", cursor: "remote",
security: "any",
width: 1280, width: 1280,
height: 720, height: 720,
}, },
+2 -2
View File
@@ -589,7 +589,8 @@ router.post(
? { guacdPort: perConnectionGuacdPort } ? { guacdPort: perConnectionGuacdPort }
: {}), : {}),
}; };
const recordingEnabled = host.enableSessionLogging !== false; const recordingEnabled =
connectionType !== "vnc" && host.enableSessionLogging !== false;
const recordingName = `${crypto.randomUUID()}.guac`; const recordingName = `${crypto.randomUUID()}.guac`;
const recordingPath = const recordingPath =
process.env.GUACD_RECORDING_PATH || process.env.GUACD_RECORDING_PATH ||
@@ -657,7 +658,6 @@ router.post(
password, password,
{ {
port, port,
security: "any",
...guacConfig, ...guacConfig,
...guacdOverrides, ...guacdOverrides,
}, },
+6 -3
View File
@@ -39,6 +39,7 @@ interface GuacamoleAppProps {
hostId?: string; hostId?: string;
tabId?: string; tabId?: string;
protocol?: "rdp" | "vnc" | "telnet"; protocol?: "rdp" | "vnc" | "telnet";
isVisible?: boolean;
} }
export interface GuacamoleAppHandle { export interface GuacamoleAppHandle {
@@ -49,7 +50,7 @@ export interface GuacamoleAppHandle {
} }
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>( const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
function GuacamoleApp({ hostId, tabId, protocol }, ref) { function GuacamoleApp({ hostId, tabId, protocol, isVisible = true }, ref) {
const { t } = useTranslation(); const { t } = useTranslation();
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null); const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -103,6 +104,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
hostName={hostConfig.name || hostConfig.ip || String(hostId)} hostName={hostConfig.name || hostConfig.ip || String(hostId)}
tabId={tabId} tabId={tabId}
protocol={protocol} protocol={protocol}
isVisible={isVisible}
ref={ref} ref={ref}
/> />
); );
@@ -118,13 +120,14 @@ interface GuacamoleAppInnerProps {
hostName: string; hostName: string;
tabId?: string; tabId?: string;
protocol?: "rdp" | "vnc" | "telnet"; protocol?: "rdp" | "vnc" | "telnet";
isVisible: boolean;
} }
const GuacamoleAppInner = React.forwardRef< const GuacamoleAppInner = React.forwardRef<
GuacamoleAppHandle, GuacamoleAppHandle,
GuacamoleAppInnerProps GuacamoleAppInnerProps
>(function GuacamoleAppInner( >(function GuacamoleAppInner(
{ hostId, hostConfig, hostName, tabId, protocol }, { hostId, hostConfig, hostName, tabId, protocol, isVisible },
ref, ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -402,7 +405,7 @@ const GuacamoleAppInner = React.forwardRef<
? configuredDpi ? configuredDpi
: undefined, : undefined,
}} }}
isVisible={true} isVisible={isVisible}
touchMode={touchMode} touchMode={touchMode}
onError={(err) => setConnectionError(err)} onError={(err) => setConnectionError(err)}
/> />
+44 -18
View File
@@ -130,11 +130,11 @@ export const GuacamoleDisplay = forwardRef<
}, },
})); }));
const getWebSocketUrl = useCallback( const getWebSocketConnection = useCallback(
async ( async (
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
): Promise<string | null> => { ): Promise<{ url: string; query: string } | null> => {
try { try {
let token: string; let token: string;
const connectionProtocol = const connectionProtocol =
@@ -205,7 +205,7 @@ export const GuacamoleDisplay = forwardRef<
height: String(displaySize.height), height: String(displaySize.height),
}); });
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi)); if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
return `${wsBase}?${params.toString()}`; return { url: wsBase, query: params.toString() };
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "Unknown error"; error instanceof Error ? error.message : "Unknown error";
@@ -308,10 +308,9 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(false); setIsReady(false);
setHasError(false); setHasError(false);
// Wait two frames so the container is fully laid out before measuring. // Let layout settle before measuring without depending on animation frames,
await new Promise<void>((resolve) => // which may be throttled while Electron windows or tabs are inactive.
requestAnimationFrame(() => requestAnimationFrame(() => resolve())), await new Promise<void>((resolve) => setTimeout(resolve, 0));
);
if (!isMountedRef.current) { if (!isMountedRef.current) {
isConnectingRef.current = false; isConnectingRef.current = false;
return; return;
@@ -333,9 +332,7 @@ export const GuacamoleDisplay = forwardRef<
(containerWidth < 100 || containerHeight < 100) && attempt < 40; (containerWidth < 100 || containerHeight < 100) && attempt < 40;
attempt++ attempt++
) { ) {
await new Promise<void>((resolve) => await new Promise<void>((resolve) => setTimeout(resolve, 25));
requestAnimationFrame(() => resolve()),
);
if (!isMountedRef.current) { if (!isMountedRef.current) {
isConnectingRef.current = false; isConnectingRef.current = false;
return; return;
@@ -348,19 +345,28 @@ export const GuacamoleDisplay = forwardRef<
containerHeight = window.innerHeight || 720; containerHeight = window.innerHeight || 720;
} }
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight); const wsConnection = await getWebSocketConnection(
containerWidth,
containerHeight,
);
if (!isMountedRef.current) { if (!isMountedRef.current) {
isConnectingRef.current = false; isConnectingRef.current = false;
return; return;
} }
if (!wsUrl) { if (!wsConnection) {
isConnectingRef.current = false; isConnectingRef.current = false;
return; return;
} }
const tunnel = new Guacamole.WebSocketTunnel(wsUrl); const tunnel = new Guacamole.WebSocketTunnel(wsConnection.url);
const client = new Guacamole.Client(tunnel); const client = new Guacamole.Client(tunnel);
clientRef.current = client; clientRef.current = client;
let connectWatchdog: ReturnType<typeof setTimeout> | null = null;
const clearConnectWatchdog = () => {
if (!connectWatchdog) return;
clearTimeout(connectWatchdog);
connectWatchdog = null;
};
const display = client.getDisplay(); const display = client.getDisplay();
const displayElement = display.getElement(); const displayElement = display.getElement();
@@ -401,7 +407,7 @@ export const GuacamoleDisplay = forwardRef<
} }
display.onresize = () => { display.onresize = () => {
if (!isMountedRef.current) return; if (!isMountedRef.current || clientRef.current !== client) return;
rescaleDisplay(true); rescaleDisplay(true);
setIsReady(true); setIsReady(true);
}; };
@@ -474,7 +480,7 @@ export const GuacamoleDisplay = forwardRef<
refreshKeyboardHandlers(); refreshKeyboardHandlers();
client.onstatechange = (state: number) => { client.onstatechange = (state: number) => {
if (!isMountedRef.current) return; if (!isMountedRef.current || clientRef.current !== client) return;
switch (state) { switch (state) {
case 0: case 0:
break; break;
@@ -483,6 +489,7 @@ export const GuacamoleDisplay = forwardRef<
case 2: case 2:
break; break;
case 3: case 3:
clearConnectWatchdog();
isConnectingRef.current = false; isConnectingRef.current = false;
setIsReady(true); setIsReady(true);
onConnect?.(); onConnect?.();
@@ -502,16 +509,21 @@ export const GuacamoleDisplay = forwardRef<
case 4: case 4:
break; break;
case 5: case 5:
clearConnectWatchdog();
isConnectingRef.current = false;
setIsReady(false); setIsReady(false);
setHasError(true);
hasKeyboardFocusRef.current = false; hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers(); refreshKeyboardHandlers();
onError?.(t("guacamole.connectionError"));
onDisconnect?.(); onDisconnect?.();
break; break;
} }
}; };
client.onerror = (error: Guacamole.Status) => { client.onerror = (error: Guacamole.Status) => {
if (!isMountedRef.current) return; if (!isMountedRef.current || clientRef.current !== client) return;
clearConnectWatchdog();
const errorMessage = error.message || t("guacamole.connectionError"); const errorMessage = error.message || t("guacamole.connectionError");
setIsReady(false); setIsReady(false);
setHasError(true); setHasError(true);
@@ -555,8 +567,21 @@ export const GuacamoleDisplay = forwardRef<
}; };
try { try {
client.connect(); connectWatchdog = setTimeout(() => {
if (
!isMountedRef.current ||
clientRef.current !== client ||
!isConnectingRef.current
) {
return;
}
disconnectClient();
void connect();
}, 8000);
client.connect(wsConnection.query);
} catch (error) { } catch (error) {
clearConnectWatchdog();
isConnectingRef.current = false; isConnectingRef.current = false;
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setIsReady(false); setIsReady(false);
@@ -566,12 +591,13 @@ export const GuacamoleDisplay = forwardRef<
); );
} }
}, [ }, [
getWebSocketUrl, getWebSocketConnection,
onConnect, onConnect,
onDisconnect, onDisconnect,
onError, onError,
refreshKeyboardHandlers, refreshKeyboardHandlers,
rescaleDisplay, rescaleDisplay,
disconnectClient,
connectionConfig.protocol, connectionConfig.protocol,
connectionConfig.type, connectionConfig.type,
connectionConfig.dpi, connectionConfig.dpi,
+1
View File
@@ -390,6 +390,7 @@ export function renderTabContent(
hostId={host.id} hostId={host.id}
tabId={tab.id} tabId={tab.id}
protocol={tab.type as "rdp" | "vnc" | "telnet"} protocol={tab.type as "rdp" | "vnc" | "telnet"}
isVisible={isVisible}
/>, />,
); );