Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
2c7fc20a
Commit
2c7fc20a
authored
Apr 21, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(desktop): fix single-instance window restore and focus behavior
parent
ac1146f1
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
386 additions
and
16 deletions
+386
-16
index.ts
apps/desktop/src/main/index.ts
+380
-15
workspace-startup.ts
apps/desktop/src/main/workspace-startup.ts
+6
-1
No files found.
apps/desktop/src/main/index.ts
View file @
2c7fc20a
...
...
@@ -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 =
create
MainWindow(smokeEnabled);
const window =
restoreOrCreateMainWindow("bootstrap") ?? createTracked
MainWindow(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,16 +2068,45 @@ app.on("window-all-closed", () => {
}
});
void bootstrap().catch(async (error) => {
const smokeOutputPath = process.env.QJCLAW_SMOKE_OUTPUT;
const message = error instanceof Error ? error.message : String(error);
if (smokeOutputPath) {
await writeFile(smokeOutputPath, JSON.stringify({ ok: false, stage: "bootstrap", error: message, finishedAt: new Date().toISOString() }, null, 2), "utf8");
} else {
console.error(message);
}
app.quit();
});
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) {
await writeFile(smokeOutputPath, JSON.stringify({ ok: false, stage: "bootstrap", error: message, finishedAt: new Date().toISOString() }, null, 2), "utf8");
} else {
console.error(message);
}
app.quit();
});
}
...
...
apps/desktop/src/main/workspace-startup.ts
View file @
2c7fc20a
...
...
@@ -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"
>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment