mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
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:
co-authored by
default-student
Luke Gustafson
parent
08825c256d
commit
9b7f52b629
+1
-1
@@ -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 .",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user