Commit 2c7fc20a authored by AI-甘富林's avatar AI-甘富林

fix(desktop): fix single-instance window restore and focus behavior

parent ac1146f1
......@@ -146,11 +146,27 @@ interface RendererSmokeState {
const APP_DISPLAY_NAME = "千匠问天";
const forcedUserDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim();
const forcedLogsPath = process.env.QJCLAW_LOGS_PATH?.trim();
const smokeOutputPathEnabled = process.env.QJCLAW_SMOKE_OUTPUT?.trim();
const singleInstanceSmokeMode = process.env.QJCLAW_SMOKE_SINGLE_INSTANCE === "1";
const singleInstanceSmokeReadyPath = process.env.QJCLAW_SMOKE_SINGLE_INSTANCE_READY_PATH?.trim();
const singleInstanceSmokeEventPath = process.env.QJCLAW_SMOKE_SINGLE_INSTANCE_EVENT_PATH?.trim();
const PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS = 45_000;
let smokeTestInFlight = false;
let mainWindow: BrowserWindow | null = null;
let mainWindowSmokeEnabled = false;
let desktopLifecycleReady = false;
let pendingMainWindowReveal = false;
let startupLoggerRef: StartupLogger | undefined;
let secondInstanceEventCount = 0;
let lastSecondInstanceEventSnapshot: WindowInventorySnapshot | null = null;
let mainWindowLoadState: MainWindowLoadState | null = null;
app.setName(APP_DISPLAY_NAME);
if (smokeOutputPathEnabled) {
app.disableHardwareAcceleration();
}
if (forcedUserDataPath) {
app.setPath("userData", forcedUserDataPath);
app.setPath("sessionData", path.join(forcedUserDataPath, "session-data"));
......@@ -165,6 +181,214 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function countStringMatches(source: string, pattern: string): number {
if (!source || !pattern) {
return 0;
}
let count = 0;
let searchFrom = 0;
while (searchFrom < source.length) {
const nextIndex = source.indexOf(pattern, searchFrom);
if (nextIndex === -1) {
break;
}
count += 1;
searchFrom = nextIndex + pattern.length;
}
return count;
}
async function countFilePatternMatches(filePath: string, pattern: string): Promise<number> {
try {
const content = await readFile(filePath, "utf8");
return countStringMatches(content, pattern);
} catch {
return 0;
}
}
function snapshotMainWindowState(window: BrowserWindow | null): Record<string, unknown> {
if (!window || window.isDestroyed()) {
return {
exists: false
};
}
return {
exists: true,
id: window.id,
visible: window.isVisible(),
focused: window.isFocused(),
minimized: window.isMinimized(),
url: window.webContents.isDestroyed() ? undefined : window.webContents.getURL(),
isLoadingMainFrame: window.webContents.isDestroyed() ? undefined : window.webContents.isLoadingMainFrame()
};
}
interface MainWindowLoadState {
status: "created" | "did-start-loading" | "dom-ready" | "did-finish-load" | "did-fail-load" | "render-process-gone" | "web-contents-destroyed" | "window-closed";
capturedAt: string;
url?: string;
errorCode?: number;
errorDescription?: string;
reason?: string;
}
interface WindowInventorySnapshot {
reason: string;
capturedAt: string;
secondInstanceEventCount: number;
windowCount: number;
visibleWindowCount: number;
focusedWindowCount: number;
mainWindow: Record<string, unknown>;
mainWindowLoadState: MainWindowLoadState | null;
}
interface SingleInstanceReadySnapshot extends WindowInventorySnapshot {
runtimeManagerLogPath: string;
initialLaunchCommandCount: number;
}
function snapshotWindowInventory(reason: string): WindowInventorySnapshot {
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
return {
reason,
capturedAt: new Date().toISOString(),
secondInstanceEventCount,
windowCount: windows.length,
visibleWindowCount: windows.filter((window) => window.isVisible()).length,
focusedWindowCount: windows.filter((window) => window.isFocused()).length,
mainWindow: snapshotMainWindowState(mainWindow),
mainWindowLoadState
};
}
async function writeSingleInstanceSmokeSignal(filePath: string | undefined, payload: unknown): Promise<void> {
if (!filePath) {
return;
}
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8").catch(() => undefined);
}
function focusMainWindow(window: BrowserWindow): BrowserWindow {
if (window.isDestroyed()) {
return window;
}
if (window.isMinimized()) {
window.restore();
}
if (!window.isVisible()) {
window.show();
}
window.focus();
return window;
}
function updateTrackedMainWindowLoadState(window: BrowserWindow, nextState: Omit<MainWindowLoadState, "capturedAt">): void {
if (mainWindow !== window) {
return;
}
const currentUrl = !window.isDestroyed() && !window.webContents.isDestroyed()
? window.webContents.getURL()
: undefined;
mainWindowLoadState = {
capturedAt: new Date().toISOString(),
url: nextState.url ?? currentUrl,
...nextState
};
void startupLoggerRef?.info("bootstrap", "window.load-state", "Tracked main window load state updated.", {
...mainWindowLoadState
});
}
function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeEnabled): BrowserWindow {
mainWindowSmokeEnabled = smokeEnabled;
mainWindow = window;
updateTrackedMainWindowLoadState(window, {
status: "created"
});
if (!window.webContents.isDestroyed()) {
window.webContents.on("did-start-loading", () => {
updateTrackedMainWindowLoadState(window, {
status: "did-start-loading"
});
});
window.webContents.on("dom-ready", () => {
updateTrackedMainWindowLoadState(window, {
status: "dom-ready"
});
});
window.webContents.on("did-finish-load", () => {
updateTrackedMainWindowLoadState(window, {
status: "did-finish-load"
});
});
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame) {
return;
}
updateTrackedMainWindowLoadState(window, {
status: "did-fail-load",
url: validatedURL,
errorCode,
errorDescription
});
});
window.webContents.on("render-process-gone", (_event, details) => {
updateTrackedMainWindowLoadState(window, {
status: "render-process-gone",
reason: details.reason
});
});
window.webContents.on("destroyed", () => {
updateTrackedMainWindowLoadState(window, {
status: "web-contents-destroyed"
});
});
}
window.once("closed", () => {
updateTrackedMainWindowLoadState(window, {
status: "window-closed"
});
if (mainWindow === window) {
mainWindow = null;
}
});
return window;
}
function createTrackedMainWindow(smokeEnabled = mainWindowSmokeEnabled): BrowserWindow {
const window = attachMainWindow(createMainWindow(smokeEnabled), smokeEnabled);
if (pendingMainWindowReveal) {
pendingMainWindowReveal = false;
focusMainWindow(window);
}
return window;
}
function restoreOrCreateMainWindow(reason: string): BrowserWindow | null {
if (mainWindow && !mainWindow.isDestroyed()) {
return focusMainWindow(mainWindow);
}
if (!desktopLifecycleReady) {
pendingMainWindowReveal = true;
return null;
}
const window = createTrackedMainWindow(mainWindowSmokeEnabled);
void startupLoggerRef?.info("bootstrap", "window.restore-or-create", "Restored or recreated the main window.", {
reason
});
return focusMainWindow(window);
}
function withTimeout<T>(operation: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
......@@ -1406,11 +1630,120 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
app.quit();
}
async function runSingleInstanceSmoke(window: BrowserWindow, outputPath: string, systemSummary: SystemSummary): Promise<void> {
const result: Record<string, unknown> = {
startedAt: new Date().toISOString()
};
const tracePath = outputPath + ".trace.log";
const trace = async (message: string) => {
const line = "[" + new Date().toISOString() + "] " + message + "\n";
await appendFile(tracePath, line, "utf8").catch(() => undefined);
};
const runtimeManagerLogPath = path.join(systemSummary.logsPath, "runtime-manager.log");
const waitForInitialReadySnapshot = async (): Promise<{ readySnapshot: SingleInstanceReadySnapshot; initialLaunchCommandCount: number }> => {
const deadline = Date.now() + 30_000;
let runtimeLaunchObservedAt = 0;
while (Date.now() < deadline) {
if (window.isDestroyed()) {
throw new Error("Primary instance window was destroyed before single-instance readiness.");
}
if (window.webContents.isDestroyed()) {
throw new Error("Primary instance webContents was destroyed before single-instance readiness.");
}
if (mainWindowLoadState?.status === "did-fail-load") {
throw new Error("Primary instance main window failed to load: " + JSON.stringify(mainWindowLoadState));
}
if (mainWindowLoadState?.status === "render-process-gone") {
throw new Error("Primary instance renderer exited before single-instance readiness: " + JSON.stringify(mainWindowLoadState));
}
if (mainWindowLoadState?.status === "web-contents-destroyed" || mainWindowLoadState?.status === "window-closed") {
throw new Error("Primary instance main window closed before single-instance readiness: " + JSON.stringify(mainWindowLoadState));
}
const initialLaunchCommandCount = await countFilePatternMatches(runtimeManagerLogPath, "Launching bundled runtime command");
if (initialLaunchCommandCount >= 1 && runtimeLaunchObservedAt === 0) {
runtimeLaunchObservedAt = Date.now();
}
const currentSnapshot = snapshotWindowInventory("ready-check");
const mainWindowExists = currentSnapshot.windowCount === 1
&& currentSnapshot.visibleWindowCount >= 1
&& typeof currentSnapshot.mainWindow === "object"
&& currentSnapshot.mainWindow !== null
&& (currentSnapshot.mainWindow as { exists?: boolean }).exists === true;
if (mainWindowExists && runtimeLaunchObservedAt > 0 && Date.now() - runtimeLaunchObservedAt >= 1000) {
return {
readySnapshot: {
...snapshotWindowInventory("ready"),
runtimeManagerLogPath,
initialLaunchCommandCount
},
initialLaunchCommandCount
};
}
await delay(250);
}
throw new Error("Timed out waiting for primary instance readiness. lastWindowSnapshot="
+ JSON.stringify(snapshotWindowInventory("ready-timeout"))
+ " lastLoadState="
+ JSON.stringify(mainWindowLoadState));
};
try {
await trace("runSingleInstanceSmoke:start");
const { readySnapshot, initialLaunchCommandCount } = await waitForInitialReadySnapshot();
result.initialWindowSnapshot = readySnapshot;
result.initialRendererState = await waitForRendererSmokeState(window, 1000).catch(() => null);
await trace("runSingleInstanceSmoke:primary-instance-ready");
await writeSingleInstanceSmokeSignal(singleInstanceSmokeReadyPath, readySnapshot);
await trace("runSingleInstanceSmoke:ready-signal-written");
const deadline = Date.now() + 30_000;
while (Date.now() < deadline && secondInstanceEventCount < 1) {
await delay(250);
}
if (secondInstanceEventCount < 1) {
throw new Error("Timed out waiting for second-instance activation.");
}
await trace("runSingleInstanceSmoke:second-instance-detected");
await delay(1500);
result.finalRendererState = await waitForRendererSmokeState(window, 1000).catch(() => null);
result.singleInstance = {
initialLaunchCommandCount,
finalLaunchCommandCount: await countFilePatternMatches(runtimeManagerLogPath, "Launching bundled runtime command"),
secondInstanceEventCount,
lastSecondInstanceEventSnapshot,
finalWindowSnapshot: snapshotWindowInventory("final"),
runtimeManagerLogPath
};
result.ok = true;
await trace("runSingleInstanceSmoke:success");
} catch (error) {
result.ok = false;
result.error = error instanceof Error ? error.message : String(error);
await trace("runSingleInstanceSmoke:error:" + String(result.error));
}
result.finishedAt = new Date().toISOString();
await trace("runSingleInstanceSmoke:writing-output");
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
await trace("runSingleInstanceSmoke:output-written");
app.quit();
}
async function bootstrap(): Promise<void> {
await app.whenReady();
const smokeOutputPath = process.env.QJCLAW_SMOKE_OUTPUT;
const smokeEnabled = Boolean(smokeOutputPath);
mainWindowSmokeEnabled = smokeEnabled;
const smokeCloudBaseUrl = process.env.QJCLAW_SMOKE_CLOUD_API_BASE_URL?.trim();
const smokeAuthToken = process.env.QJCLAW_SMOKE_AUTH_TOKEN?.trim();
const smokeRuntimeApiKey = process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key";
......@@ -1426,6 +1759,7 @@ async function bootstrap(): Promise<void> {
};
const systemSummary = buildSystemSummary();
startupLogger = new StartupLogger(systemSummary.logsPath);
startupLoggerRef = startupLogger;
await traceBootstrap("when-ready", { isPackaged: systemSummary.isPackaged, userDataPath: systemSummary.userDataPath, logsPath: systemSummary.logsPath, smokeEnabled, smokeCloudBootstrapEnabled });
const configService = new AppConfigService(systemSummary.userDataPath);
......@@ -1684,8 +2018,9 @@ async function bootstrap(): Promise<void> {
})();
});
desktopLifecycleReady = true;
await traceBootstrap("create-window");
const window = createMainWindow(smokeEnabled);
const window = restoreOrCreateMainWindow("bootstrap") ?? createTrackedMainWindow(smokeEnabled);
await traceBootstrap("window-created");
if (cachedRuntimeCloudConfig) {
......@@ -1714,15 +2049,16 @@ async function bootstrap(): Promise<void> {
if (smokeEnabled && smokeOutputPath) {
await traceBootstrap("run-smoke-test-start");
smokeTestInFlight = true;
void runSmokeTest(window, smokeOutputPath).finally(() => {
const smokeTask = singleInstanceSmokeMode
? runSingleInstanceSmoke(window, smokeOutputPath, systemSummary)
: runSmokeTest(window, smokeOutputPath);
void smokeTask.finally(() => {
smokeTestInFlight = false;
});
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow(smokeEnabled);
}
restoreOrCreateMainWindow("activate");
});
}
......@@ -1732,7 +2068,35 @@ app.on("window-all-closed", () => {
}
});
void bootstrap().catch(async (error) => {
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.exit(0);
} else {
app.on("second-instance", () => {
secondInstanceEventCount += 1;
const handle = async () => {
restoreOrCreateMainWindow("second-instance");
const snapshot = snapshotWindowInventory("second-instance");
lastSecondInstanceEventSnapshot = snapshot;
await writeSingleInstanceSmokeSignal(singleInstanceSmokeEventPath, snapshot);
await startupLoggerRef?.info("bootstrap", "single-instance.second-instance", "Second app instance redirected to the existing main window.", {
secondInstanceEventCount,
windowCount: snapshot.windowCount,
visibleWindowCount: snapshot.visibleWindowCount,
focusedWindowCount: snapshot.focusedWindowCount
});
};
if (app.isReady()) {
void handle();
return;
}
void app.whenReady().then(handle);
});
void bootstrap().catch(async (error) => {
const smokeOutputPath = process.env.QJCLAW_SMOKE_OUTPUT;
const message = error instanceof Error ? error.message : String(error);
if (smokeOutputPath) {
......@@ -1741,7 +2105,8 @@ void bootstrap().catch(async (error) => {
console.error(message);
}
app.quit();
});
});
}
......
......@@ -144,7 +144,12 @@ export function isWorkspaceShellReady(input: {
return false;
}
return gatewayStatus?.state === "connected";
if (gatewayStatus?.state === "connected") {
return true;
}
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
return isTransientLocalGatewayError(gatewayError);
}
function buildSetupSummary(config: AppConfig): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment