dsh-notch 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BASELINE.md +13 -0
- package/DESIGN.md +34 -0
- package/LICENSE +21 -0
- package/README.en.md +136 -0
- package/README.md +145 -0
- package/cordis.yml +5 -0
- package/docs/motion-audit.md +65 -0
- package/docs/motion-continuity.md +77 -0
- package/docs/releases/v0.3.0.md +20 -0
- package/dshx.yml +14 -0
- package/macos/Package.swift +17 -0
- package/macos/STATUS-MOTION.md +49 -0
- package/macos/Sources/Client.swift +139 -0
- package/macos/Sources/IdleRobot.swift +423 -0
- package/macos/Sources/NotchMarkdown.swift +266 -0
- package/macos/Sources/Panel.swift +153 -0
- package/macos/Sources/Resources/Idle/balance.json +1 -0
- package/macos/Sources/Resources/Idle/blink.json +1 -0
- package/macos/Sources/Resources/Idle/cube-in.json +1 -0
- package/macos/Sources/Resources/Idle/dance.json +1 -0
- package/macos/Sources/Resources/Idle/hop.json +1 -0
- package/macos/Sources/Resources/Idle/nod.json +1 -0
- package/macos/Sources/Resources/Idle/satellite-in.json +1 -0
- package/macos/Sources/Resources/Idle/satellite-out.json +1 -0
- package/macos/Sources/Resources/Idle/scan.json +1 -0
- package/macos/Sources/Resources/Idle/sleep.json +1 -0
- package/macos/Sources/Resources/Idle/sneeze.json +1 -0
- package/macos/Sources/Resources/Idle/stretch.json +1 -0
- package/macos/Sources/Resources/Idle/tilt.json +1 -0
- package/macos/Sources/ReviewStudio.swift +337 -0
- package/macos/Sources/RootView.swift +1093 -0
- package/macos/Sources/StatusOrbit.swift +642 -0
- package/macos/Sources/main.swift +153 -0
- package/macos/Tests/ExpandedHeightProbe.swift +108 -0
- package/macos/Tests/GeometryProbe.swift +98 -0
- package/macos/Tests/IdleProbe.swift +294 -0
- package/macos/Tests/MotionProbe.swift +106 -0
- package/macos/Tests/OutcomeProbe.swift +133 -0
- package/macos/Tests/ScrollbarProbe.swift +91 -0
- package/macos/Tests/expanded-height.sh +13 -0
- package/macos/Tests/geometry.sh +14 -0
- package/macos/Tests/idle.sh +14 -0
- package/macos/Tests/motion.sh +14 -0
- package/macos/Tests/outcome.sh +14 -0
- package/macos/Tests/scrollbar.sh +13 -0
- package/package.json +46 -0
- package/src/board.ts +299 -0
- package/src/browse-sync.ts +59 -0
- package/src/dsh-notch.ts +60 -0
- package/src/http.ts +215 -0
- package/src/session-state.ts +31 -0
- package/src/store.ts +49 -0
- package/src/types.ts +65 -0
- package/tests/answer-lifecycle.test.mjs +23 -0
- package/tests/board.test.mjs +7 -0
- package/tests/browse-sync.test.mjs +20 -0
- package/tests/mirror.test.mjs +4 -0
- package/tests/peer-range.test.mjs +21 -0
- package/tests/session-state.test.mjs +54 -0
- package/tests/unread-hold.test.mjs +90 -0
- package/tools/desktop-shell/README.md +56 -0
- package/tools/desktop-shell/child-log.mjs +6 -0
- package/tools/desktop-shell/child-log.test.mjs +14 -0
- package/tools/desktop-shell/package-recovery.py +78 -0
- package/tools/desktop-shell/recovery-probe.cjs +50 -0
- package/tools/desktop-shell/renderer-recovery.mjs +95 -0
- package/tools/desktop-shell/renderer-recovery.test.mjs +24 -0
- package/tools/diagnostics/completion-watch.py +101 -0
- package/tools/diagnostics/completion-watch.test.py +17 -0
- package/tools/diagnostics/renderer-diagnostics.mjs +56 -0
- package/tools/diagnostics/renderer-diagnostics.test.mjs +32 -0
- package/tools/idle/LICENSE.open-bot-motion +21 -0
- package/tools/idle/export.mjs +40 -0
- package/tools/idle/index.html +4 -0
- package/tools/idle/motions.js +83 -0
- package/tools/idle/original.js +1673 -0
- package/tools/idle/renderer.js +1673 -0
- package/tools/recording/Client.swift +76 -0
- package/tools/recording/DemoCatalog.swift +174 -0
- package/tools/recording/README.md +23 -0
- package/tools/recording/build.sh +41 -0
- package/tools/recording/main.swift +100 -0
- package/tools/review/ReviewStudio.swift +236 -0
- package/tools/review/audit.sh +19 -0
- package/tools/review/build.sh +7 -0
- package/tools/review/main.swift +158 -0
- package/tools/review/render-audit.swift +32 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
const DIR = join(homedir(), '.dsh', 'dsh-notch')
|
|
7
|
+
const RUNTIME = join(DIR, 'runtime.json')
|
|
8
|
+
const SEEN = join(DIR, 'seen.json')
|
|
9
|
+
|
|
10
|
+
export interface RuntimeFile {
|
|
11
|
+
origin: string
|
|
12
|
+
token: string
|
|
13
|
+
pid: number
|
|
14
|
+
writtenAt: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureDir(): void {
|
|
18
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readJson<T>(path: string): T | undefined {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadOrCreateToken(): string {
|
|
30
|
+
const existing = readJson<RuntimeFile>(RUNTIME)
|
|
31
|
+
if (existing?.token && existing.token.length >= 16) return existing.token
|
|
32
|
+
return randomBytes(24).toString('hex')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeRuntime(file: RuntimeFile): void {
|
|
36
|
+
ensureDir()
|
|
37
|
+
writeFileSync(RUNTIME, `${JSON.stringify(file, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadSeen(): Record<string, number> {
|
|
41
|
+
const value = readJson<Record<string, number>>(SEEN)
|
|
42
|
+
if (!value || typeof value !== 'object') return {}
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function saveSeen(map: Record<string, number>): void {
|
|
47
|
+
ensureDir()
|
|
48
|
+
writeFileSync(SEEN, `${JSON.stringify(map)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
49
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** JSON contract between the Host plugin and the native helper. */
|
|
2
|
+
|
|
3
|
+
export type ApprovalOutcomeWire = 'allowed-once' | 'rejected'
|
|
4
|
+
|
|
5
|
+
export interface NotchOption {
|
|
6
|
+
label: string
|
|
7
|
+
description?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NotchQuestion {
|
|
11
|
+
id: string
|
|
12
|
+
question: string
|
|
13
|
+
detail?: string
|
|
14
|
+
header?: string
|
|
15
|
+
options?: NotchOption[]
|
|
16
|
+
multiSelect?: boolean
|
|
17
|
+
intent?: { kind: 'plan-review'; approve: string }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NotchApproval {
|
|
21
|
+
id: string
|
|
22
|
+
toolName: string
|
|
23
|
+
reason?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NotchAsk {
|
|
27
|
+
id: string
|
|
28
|
+
questions: NotchQuestion[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NotchLastTurn {
|
|
32
|
+
at: number
|
|
33
|
+
kind: string
|
|
34
|
+
failed: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NotchRow {
|
|
38
|
+
id: string
|
|
39
|
+
title: string
|
|
40
|
+
child: boolean
|
|
41
|
+
busy: boolean
|
|
42
|
+
unread: boolean
|
|
43
|
+
lastTurn?: NotchLastTurn
|
|
44
|
+
approval?: NotchApproval
|
|
45
|
+
ask?: NotchAsk
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NotchSnapshot {
|
|
49
|
+
ok: true
|
|
50
|
+
generatedAt: number
|
|
51
|
+
sidebarSyncedAt?: number
|
|
52
|
+
origin: string
|
|
53
|
+
rows: NotchRow[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface NotchAnswerItem {
|
|
57
|
+
id: string
|
|
58
|
+
selected: string[]
|
|
59
|
+
custom?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface NotchFocus {
|
|
63
|
+
sessionId: string
|
|
64
|
+
at: number
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {test} from 'node:test';import assert from 'node:assert/strict';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import {mkdtempSync, rmSync} from 'node:fs';
|
|
4
|
+
import {syncBuiltinESMExports} from 'node:module';
|
|
5
|
+
import {after} from 'node:test';
|
|
6
|
+
const testDir=mkdtempSync(os.tmpdir()+'/notch-answer-test-');
|
|
7
|
+
const realHomedir=os.homedir;
|
|
8
|
+
os.homedir=()=>testDir; syncBuiltinESMExports();
|
|
9
|
+
const {Board}=await import('../src/board.ts');
|
|
10
|
+
os.homedir=realHomedir; syncBuiltinESMExports();
|
|
11
|
+
after(()=>rmSync(testDir,{recursive:true,force:true}));
|
|
12
|
+
const session={id:'notch-lifecycle-test',header:{},snapshotEvents:()=>[]};
|
|
13
|
+
const ctx={sessions:{list:()=>[session]},agents:{get:()=>({status:'running'})},get:()=>undefined,logger:{warn(){}}};
|
|
14
|
+
test('Notch answer cancels the web wait and preserves caller signal',async()=>{
|
|
15
|
+
const board=new Board(ctx), owner=new AbortController();const request={agent:{id:session.id},questions:[{id:'q',question:'test'}],signal:owner.signal};let webVisible=true;
|
|
16
|
+
const pending=board.holdAsk(request,()=>new Promise((resolve,reject)=>{request.signal.addEventListener('abort',()=>{webVisible=false;reject(Error('web wait ended'))},{once:true})}));
|
|
17
|
+
const ask=board.snapshot('http://localhost').rows[0].ask;assert.ok(ask);assert.equal(board.answerAsk(ask.id,[{id:'q',selected:['test']}]),true);
|
|
18
|
+
assert.deepEqual(await pending,{answers:[{id:'q',selected:['test']}]});assert.equal(webVisible,false);assert.equal(owner.signal.aborted,false);assert.equal(request.signal,owner.signal);
|
|
19
|
+
});
|
|
20
|
+
test('Web answer removes the Notch request',async()=>{
|
|
21
|
+
const board=new Board(ctx);let finish;const answer={answers:[{id:'q',selected:[]}]};
|
|
22
|
+
const pending=board.holdAsk({agent:{id:session.id},questions:[{id:'q',question:'test'}]},()=>new Promise(r=>finish=r));await Promise.resolve();finish(answer);assert.equal(await pending,answer);assert.equal(board.snapshot('http://localhost').rows[0].ask,undefined);
|
|
23
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import {test} from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {Board} from '../src/board.ts'
|
|
4
|
+
const session={id:'session-regression',header:{},snapshotEvents:()=>[]}
|
|
5
|
+
const ctx={sessions:{list:()=>[session]},agents:{get:()=>({status:'running'})},get:()=>undefined,logger:{warn:()=>{}}}
|
|
6
|
+
test('snapshot and background notification handle live sessions without missing seen state',()=>{const board=new Board(ctx);let notifications=0;board.onChange(()=>{assert.equal(board.snapshot('http://localhost').rows.length,1);notifications++});board.notify();assert.equal(notifications,1)})
|
|
7
|
+
test('one broken subscriber cannot crash Host or stop other subscribers',()=>{const board=new Board(ctx);let n=0;board.onChange(()=>{throw Error('broken stream')});board.onChange(()=>n++);assert.doesNotThrow(()=>board.notify());assert.equal(n,1)})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {test} from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {installBrowseSync} from '../src/browse-sync.ts'
|
|
4
|
+
class Controller { history={page:async()=>({records:[1]}),follow:async function*(){yield {records:[1]}}}; page(request,signal){return this.history.page(request,signal)} follow(request,signal){return this.history.follow(request,signal)} }
|
|
5
|
+
const req={address:{sessionId:'test'}}
|
|
6
|
+
test('legacy synchronous Notch wrapper reproduces exact error; repair removes all nested wrappers',async()=>{
|
|
7
|
+
const c=new Controller();const board={markSeen(){}};const getTargetSessionId=a=>a.sessionId
|
|
8
|
+
for(let i=0;i<2;i++){const origFollow=c.follow.bind(c);c.follow=function* (req, signal) {const sid=getTargetSessionId(req.address);if(sid)board.markSeen(sid);return yield* origFollow(req,signal)}}
|
|
9
|
+
await assert.rejects(async()=>{for await(const x of c.follow(req)){}},/is not iterable/)
|
|
10
|
+
const stop=installBrowseSync(c,()=>{});const rows=[];for await(const x of c.follow(req))rows.push(x)
|
|
11
|
+
assert.deepEqual(rows,[{records:[1]}]);stop();assert.equal(Object.hasOwn(c,'follow'),false)
|
|
12
|
+
})
|
|
13
|
+
test('preserves async stream identity, return, cancellation; repeated generations dispose cleanly',async()=>{
|
|
14
|
+
const c=new Controller();let returned=0;const signal=AbortSignal.abort();const source={async *[Symbol.asyncIterator](){try{yield 1;yield 2}finally{returned++}}}
|
|
15
|
+
c.history.follow=(r,s)=>{assert.equal(s,signal);return source}
|
|
16
|
+
let a=0,b=0;const stopA=installBrowseSync(c,()=>a++);const stopB=installBrowseSync(c,()=>b++)
|
|
17
|
+
assert.equal(c.follow(req,signal),source);stopA();const stream=c.follow(req,signal);for await(const v of stream){break}
|
|
18
|
+
assert.equal(a,1);assert.equal(b,2);assert.equal(returned,1);stopB();stopB();assert.equal(Object.hasOwn(c,'follow'),false)
|
|
19
|
+
const stop=installBrowseSync(c,()=>{throw Error('read marker failed')});assert.deepEqual(await c.page(req),{records:[1]});stop()
|
|
20
|
+
})
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import {test} from 'node:test';import assert from 'node:assert/strict';import {Board} from '../src/board.ts';
|
|
2
|
+
const ctx={sessions:{list:()=>[]},agents:{get:()=>undefined},get:()=>undefined,logger:{warn:()=>{}}};
|
|
3
|
+
test('sidebar completion survives cold session; opening in DSH clears green; background page cannot overwrite',()=>{const b=new Board(ctx);const row={id:'session-cold',title:'Cold',running:false,completed:true};assert.equal(b.syncSidebar({clientId:'app',focused:true,rows:[row]}),true);assert.equal(b.snapshot('').rows[0].unread,true);assert.equal(b.requestFocus(row.id),true);b.syncSidebar({clientId:'app',focused:true,rows:[]});assert.equal(b.snapshot('').rows.length,0);b.syncSidebar({clientId:'browser',focused:false,rows:[row]});assert.equal(b.snapshot('').rows.length,0)});
|
|
4
|
+
test('malformed mirror never replaces previous valid state',()=>{const b=new Board(ctx);assert.equal(b.syncSidebar({clientId:'app',rows:[{id:123}]}),false);assert.deepEqual(b.snapshot('').rows,[])});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const RANGE = '>=0.1.7-rc.1 <0.1.8'
|
|
6
|
+
const PEERS = [
|
|
7
|
+
'@deepseek-ai/dsh',
|
|
8
|
+
'@deepseek-ai/dsh-host-webserver',
|
|
9
|
+
'@deepseek-ai/dsh-session',
|
|
10
|
+
'@deepseek-ai/dsh-user-approval',
|
|
11
|
+
'@deepseek-ai/dsh-user-questions',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
test('Harness peer range is the 0.1.7-rc.1 desk pin', () => {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
16
|
+
assert.equal(pkg.version, '0.3.1')
|
|
17
|
+
for (const name of PEERS) {
|
|
18
|
+
assert.equal(pkg.peerDependencies[name], RANGE)
|
|
19
|
+
assert.equal(pkg.peerDependenciesMeta[name].optional, true)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { foldSession } from '../src/session-state.ts'
|
|
4
|
+
|
|
5
|
+
function session(events) {
|
|
6
|
+
return { snapshotEvents: () => events }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test('aborted last turn is not a failure lamp', () => {
|
|
10
|
+
const folded = foldSession(session([
|
|
11
|
+
{ type: 'turn/start', time: 1 },
|
|
12
|
+
{ type: 'turn/end', time: 2, data: { reason: { kind: 'aborted' } } },
|
|
13
|
+
]))
|
|
14
|
+
assert.equal(folded.busy, false)
|
|
15
|
+
assert.equal(folded.lastTurn.kind, 'aborted')
|
|
16
|
+
assert.equal(folded.lastTurn.failed, false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('interrupted last turn is not a failure lamp', () => {
|
|
20
|
+
const folded = foldSession(session([
|
|
21
|
+
{ type: 'turn/start', time: 1 },
|
|
22
|
+
{ type: 'turn/end', time: 2, data: { reason: { kind: 'interrupted' } } },
|
|
23
|
+
]))
|
|
24
|
+
assert.equal(folded.lastTurn.failed, false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('a new turn clears the previous abort so a running session cannot stay red', () => {
|
|
28
|
+
const folded = foldSession(session([
|
|
29
|
+
{ type: 'turn/start', time: 1 },
|
|
30
|
+
{ type: 'turn/end', time: 2, data: { reason: { kind: 'aborted' } } },
|
|
31
|
+
{ type: 'turn/start', time: 3 },
|
|
32
|
+
]))
|
|
33
|
+
assert.equal(folded.busy, true)
|
|
34
|
+
assert.equal(folded.lastTurn, undefined)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a fork-seed closer is not a failure lamp', () => {
|
|
38
|
+
const folded = foldSession(session([
|
|
39
|
+
{ type: 'turn/start', time: 1 },
|
|
40
|
+
{ type: 'turn/end', time: 2, data: { reason: { kind: 'forked' } } },
|
|
41
|
+
]))
|
|
42
|
+
assert.equal(folded.busy, false)
|
|
43
|
+
assert.equal(folded.lastTurn.kind, 'forked')
|
|
44
|
+
assert.equal(folded.lastTurn.failed, false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('error still counts as failed after the turn ends', () => {
|
|
48
|
+
const folded = foldSession(session([
|
|
49
|
+
{ type: 'turn/start', time: 1 },
|
|
50
|
+
{ type: 'turn/end', time: 2, data: { reason: { kind: 'error' } } },
|
|
51
|
+
]))
|
|
52
|
+
assert.equal(folded.busy, false)
|
|
53
|
+
assert.equal(folded.lastTurn.failed, true)
|
|
54
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { test, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
5
|
+
import { syncBuiltinESMExports } from 'node:module'
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(os.tmpdir() + '/notch-unread-')
|
|
8
|
+
const realHomedir = os.homedir
|
|
9
|
+
os.homedir = () => testDir
|
|
10
|
+
syncBuiltinESMExports()
|
|
11
|
+
const { Board } = await import('../src/board.ts')
|
|
12
|
+
os.homedir = realHomedir
|
|
13
|
+
syncBuiltinESMExports()
|
|
14
|
+
after(() => rmSync(testDir, { recursive: true, force: true }))
|
|
15
|
+
|
|
16
|
+
test('a single running task becomes a green unread lamp instead of vanishing', () => {
|
|
17
|
+
let running = true
|
|
18
|
+
const events = [{ type: 'turn/start', time: 1 }]
|
|
19
|
+
const session = {
|
|
20
|
+
id: 'session-one',
|
|
21
|
+
header: {},
|
|
22
|
+
snapshotEvents: () => events,
|
|
23
|
+
}
|
|
24
|
+
const ctx = {
|
|
25
|
+
sessions: { list: () => [session] },
|
|
26
|
+
agents: { get: () => ({ status: running ? 'running' : 'idle' }) },
|
|
27
|
+
get: () => undefined,
|
|
28
|
+
logger: { warn() {} },
|
|
29
|
+
}
|
|
30
|
+
const board = new Board(ctx)
|
|
31
|
+
assert.equal(board.snapshot('').rows[0].busy, true)
|
|
32
|
+
|
|
33
|
+
running = false
|
|
34
|
+
events.push({ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } })
|
|
35
|
+
const done = board.snapshot('').rows[0]
|
|
36
|
+
assert.equal(done.busy, false)
|
|
37
|
+
assert.equal(done.unread, true)
|
|
38
|
+
assert.equal(board.snapshot('').rows[0].unread, true)
|
|
39
|
+
|
|
40
|
+
board.markSeen(session.id)
|
|
41
|
+
assert.equal(board.snapshot('').rows.length, 0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('clicking seen clears green even if the sidebar still says completed', () => {
|
|
45
|
+
let running = true
|
|
46
|
+
const events = [{ type: 'turn/start', time: 1 }]
|
|
47
|
+
const session = {
|
|
48
|
+
id: 'session-sidebar',
|
|
49
|
+
header: {},
|
|
50
|
+
snapshotEvents: () => events,
|
|
51
|
+
}
|
|
52
|
+
const ctx = {
|
|
53
|
+
sessions: { list: () => [session] },
|
|
54
|
+
agents: { get: () => ({ status: running ? 'running' : 'idle' }) },
|
|
55
|
+
get: () => undefined,
|
|
56
|
+
logger: { warn() {} },
|
|
57
|
+
}
|
|
58
|
+
const board = new Board(ctx)
|
|
59
|
+
board.snapshot('')
|
|
60
|
+
running = false
|
|
61
|
+
events.push({ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } })
|
|
62
|
+
board.snapshot('')
|
|
63
|
+
board.syncSidebar({
|
|
64
|
+
clientId: 'app',
|
|
65
|
+
focused: true,
|
|
66
|
+
rows: [{ id: session.id, title: 'Done', running: false, completed: true }],
|
|
67
|
+
})
|
|
68
|
+
assert.equal(board.snapshot('').rows[0].unread, true)
|
|
69
|
+
board.markSeen(session.id)
|
|
70
|
+
assert.equal(board.snapshot('').rows.length, 0)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('cold completed history is not replayed as green', () => {
|
|
74
|
+
const session = {
|
|
75
|
+
id: 'session-old',
|
|
76
|
+
header: {},
|
|
77
|
+
snapshotEvents: () => [
|
|
78
|
+
{ type: 'turn/start', time: 1 },
|
|
79
|
+
{ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } },
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
const ctx = {
|
|
83
|
+
sessions: { list: () => [session] },
|
|
84
|
+
agents: { get: () => ({ status: 'idle' }) },
|
|
85
|
+
get: () => undefined,
|
|
86
|
+
logger: { warn() {} },
|
|
87
|
+
}
|
|
88
|
+
const board = new Board(ctx)
|
|
89
|
+
assert.equal(board.snapshot('').rows.length, 0)
|
|
90
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Desktop renderer recovery
|
|
2
|
+
|
|
3
|
+
This optional thin-shell patch prevents an unexpected renderer exit from leaving
|
|
4
|
+
an existing DSH window permanently blank. It does **not** identify or fix the
|
|
5
|
+
underlying long-idle renderer termination.
|
|
6
|
+
|
|
7
|
+
The recovery controller listens to `render-process-gone`, including `clean-exit`.
|
|
8
|
+
While the app/window remains open it reloads the original WebContents, preserves
|
|
9
|
+
its navigation/auth context, requests a repaint after load, and records the result.
|
|
10
|
+
It never starts, stops or restarts a Host. Two automatic attempts are allowed per
|
|
11
|
+
minute; further exits and a 15-second load timeout go to a native error prompt.
|
|
12
|
+
App quit/window close cancels pending work. The shell integration adds window and
|
|
13
|
+
system idle metadata to bounded local diagnostic logs.
|
|
14
|
+
|
|
15
|
+
`child-log.mjs` fixes a separate shutdown race: each child stdout/stderr stream
|
|
16
|
+
pipes with `end: false` so EOF cannot close the shared desktop log before an exit
|
|
17
|
+
callback writes to it. Logging errors cannot raise an uncaught main-process error.
|
|
18
|
+
|
|
19
|
+
## Checks
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
node --test tools/desktop-shell/*.test.mjs tools/diagnostics/renderer-diagnostics.test.mjs
|
|
23
|
+
"$DSH_TEST_ELECTRON" tools/desktop-shell/recovery-probe.cjs --baseline
|
|
24
|
+
"$DSH_TEST_ELECTRON" tools/desktop-shell/recovery-probe.cjs
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use the target app's matching Electron executable. The baseline is expected to
|
|
28
|
+
fail: it has no recovery handler. The repaired native integration fixture uses a
|
|
29
|
+
temporary profile/static page, crashes only its own renderer, checks both visible
|
|
30
|
+
and hidden windows twice, verifies an actual painted frame against its healthy
|
|
31
|
+
baseline (including display color management), and verifies retry limiting.
|
|
32
|
+
It never reads DSH conversations or invokes models. This is an Electron lifecycle
|
|
33
|
+
test, not an alternative browser automation interface.
|
|
34
|
+
|
|
35
|
+
## Package and activate
|
|
36
|
+
|
|
37
|
+
`package-recovery.py <original-app.asar> <staging-directory>` produces a patched
|
|
38
|
+
ASAR, readable main module, and input/output hash manifest. It requires the known
|
|
39
|
+
thin-shell source shape with the earlier diagnostics hook and refuses an already
|
|
40
|
+
patched or unfamiliar archive. Every unrelated archive entry is preserved and
|
|
41
|
+
all output entry bytes are verified. To test packaged recovery, set
|
|
42
|
+
`DSH_RECOVERY_MODULE` to the staged ASAR's `src/renderer-recovery.mjs` before the
|
|
43
|
+
native probe. The script never installs or controls a process.
|
|
44
|
+
|
|
45
|
+
Keep a verified copy of the original ASAR. Installation is a separate, authorized,
|
|
46
|
+
atomic resource replacement after checking that the current archive still matches
|
|
47
|
+
the reviewed input. Shell-main changes require a controlled App reopen. The current
|
|
48
|
+
App owns its Host: do this only with the applicable task authorization and an idle
|
|
49
|
+
runtime, and prove that the old Host exited before launching the original App.
|
|
50
|
+
Once active, routine renderer recovery does not require another App/Host restart.
|
|
51
|
+
Verify diagnostic attachment plus the actual conversation/composer through the
|
|
52
|
+
normal app. A package checksum or a recovery log is not visual acceptance.
|
|
53
|
+
|
|
54
|
+
For rollback, restore the exact preserved original archive and perform the same
|
|
55
|
+
controlled App reopen. Never replace the user's checkout, history or Notch assets.
|
|
56
|
+
Do not commit local logs, screenshots, session data or installed-artifact backups.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// stdout and stderr (and sometimes several children) share one desktop log.
|
|
2
|
+
// EOF belongs to an input stream; it must not end the shared destination.
|
|
3
|
+
export function pipeChildLog(child, log) {
|
|
4
|
+
child.stdout?.pipe(log, {end: false});
|
|
5
|
+
child.stderr?.pipe(log, {end: false});
|
|
6
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {test} from 'node:test';import assert from 'node:assert/strict';import {PassThrough} from 'node:stream';
|
|
2
|
+
import {pipeChildLog} from './child-log.mjs';
|
|
3
|
+
test('child EOF leaves shared log open for exit callback and other child',async()=>{
|
|
4
|
+
const log=new PassThrough(),chunks=[],errors=[];log.on('data',c=>chunks.push(c.toString()));log.on('error',e=>errors.push(e));
|
|
5
|
+
const first={stdout:new PassThrough(),stderr:new PassThrough()},second={stdout:new PassThrough(),stderr:new PassThrough()};
|
|
6
|
+
pipeChildLog(first,log);pipeChildLog(second,log);
|
|
7
|
+
first.stdout.end('first-out\n');first.stderr.end('first-err\n');
|
|
8
|
+
await new Promise(setImmediate);
|
|
9
|
+
assert.equal(log.writableEnded,false);
|
|
10
|
+
log.write('first-exit\n');second.stdout.end('second-out\n');second.stderr.end('second-err\n');
|
|
11
|
+
await new Promise(setImmediate);
|
|
12
|
+
log.write('second-exit\n');await new Promise(setImmediate);
|
|
13
|
+
assert.equal(errors.length,0);assert.match(chunks.join(''),/first-exit/);assert.match(chunks.join(''),/second-exit/);log.end();
|
|
14
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stage a reviewed thin-shell patch. Never installs or restarts DSH."""
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import argparse, hashlib, json, struct
|
|
5
|
+
p=argparse.ArgumentParser();p.add_argument('archive',type=Path);p.add_argument('output',type=Path);args=p.parse_args()
|
|
6
|
+
raw=args.archive.read_bytes();hs=struct.unpack_from('<I',raw,4)[0];js=struct.unpack_from('<I',raw,12)[0]
|
|
7
|
+
tree=json.loads(raw[16:16+js]);files={}
|
|
8
|
+
def walk(entries,prefix=''):
|
|
9
|
+
for name,e in entries.items():
|
|
10
|
+
key=prefix+name
|
|
11
|
+
if 'files' in e:walk(e['files'],key+'/')
|
|
12
|
+
else:
|
|
13
|
+
assert not e.get('unpacked') and 'link' not in e, 'unsupported archive entry'
|
|
14
|
+
start=8+hs+int(e['offset']);files[key]=raw[start:start+e['size']]
|
|
15
|
+
walk(tree['files'])
|
|
16
|
+
original=dict(files);source=files['src/main.mjs'].decode()
|
|
17
|
+
assert 'installRendererRecovery' not in source, 'recovery already installed; use the preserved base archive'
|
|
18
|
+
def replace(old,new):
|
|
19
|
+
global source
|
|
20
|
+
assert source.count(old)==1, 'unexpected shell source: '+old[:70]
|
|
21
|
+
source=source.replace(old,new,1)
|
|
22
|
+
replace("import { createDiagnosticLog, installRendererDiagnostics }", "import { pipeChildLog } from './child-log.mjs'\nimport { installRendererRecovery } from './renderer-recovery.mjs'\nimport { createDiagnosticLog, installRendererDiagnostics }")
|
|
23
|
+
replace('const { app, BrowserWindow, Menu, clipboard, session, shell, systemPreferences } = electron','const { app, BrowserWindow, Menu, clipboard, session, shell, systemPreferences, powerMonitor, dialog } = electron')
|
|
24
|
+
replace(''' const attachDiagnostics = contents => installRendererDiagnostics({
|
|
25
|
+
app, contents, write: diagnosticWrite,
|
|
26
|
+
related: () => ({hostPid: sidecar?.pid ?? null, notchPid: notch?.pid ?? null}),
|
|
27
|
+
})''',''' const attachDiagnostics = contents => {
|
|
28
|
+
const owner = BrowserWindow.fromWebContents(contents)
|
|
29
|
+
installRendererDiagnostics({
|
|
30
|
+
app, contents, window: owner, powerMonitor, write: diagnosticWrite,
|
|
31
|
+
related: () => ({hostPid: sidecar?.pid ?? null, notchPid: notch?.pid ?? null}),
|
|
32
|
+
})
|
|
33
|
+
let promptOpen = false
|
|
34
|
+
installRendererRecovery({
|
|
35
|
+
app, window: owner, write: diagnosticWrite,
|
|
36
|
+
onUnavailable: async () => {
|
|
37
|
+
if (promptOpen || owner.isDestroyed()) return
|
|
38
|
+
promptOpen = true
|
|
39
|
+
try {
|
|
40
|
+
const result = await dialog.showMessageBox(owner, {
|
|
41
|
+
type: 'error', title: 'DSH 页面未能恢复',
|
|
42
|
+
message: '页面连续异常退出或重新加载超时。',
|
|
43
|
+
detail: '后台服务没有因页面恢复而重启。可以重新加载页面,或稍后从 View → Reload 重试。',
|
|
44
|
+
buttons: ['重新加载页面', '稍后'], defaultId: 0, cancelId: 1, noLink: true,
|
|
45
|
+
})
|
|
46
|
+
if (result.response === 0 && !owner.isDestroyed()) contents.reload()
|
|
47
|
+
} finally { promptOpen = false }
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
}''')
|
|
51
|
+
pair=" child.stdout?.pipe(log)\n child.stderr?.pipe(log)"
|
|
52
|
+
assert source.count(pair)==2
|
|
53
|
+
source=source.replace(pair, ' pipeChildLog(child, log)')
|
|
54
|
+
replace(" return log\n}", " log.on('error', () => {}) // Logging failure must not crash the desktop shell.\n return log\n}")
|
|
55
|
+
root=Path(__file__).parent
|
|
56
|
+
files['src/child-log.mjs']=(root/'child-log.mjs').read_bytes()
|
|
57
|
+
files['src/main.mjs']=source.encode()
|
|
58
|
+
files['src/renderer-recovery.mjs']=(root/'renderer-recovery.mjs').read_bytes()
|
|
59
|
+
files['src/renderer-diagnostics.mjs']=(root.parent/'diagnostics/renderer-diagnostics.mjs').read_bytes()
|
|
60
|
+
header={'files':{}};payload=bytearray()
|
|
61
|
+
for name,body in files.items():
|
|
62
|
+
node=header['files'];parts=name.split('/')
|
|
63
|
+
for part in parts[:-1]:node=node.setdefault(part,{'files':{}})['files']
|
|
64
|
+
digest=hashlib.sha256(body).hexdigest()
|
|
65
|
+
node[parts[-1]]={'size':len(body),'offset':str(len(payload)),'integrity':{'algorithm':'SHA256','hash':digest,'blockSize':4194304,'blocks':[hashlib.sha256(body[i:i+4194304]).hexdigest() for i in range(0,len(body),4194304)]}}
|
|
66
|
+
payload.extend(body)
|
|
67
|
+
encoded=json.dumps(header,separators=(',',':')).encode();body=struct.pack('<I',len(encoded))+encoded;body+=b'\0'*((-len(body))%4);pickle=struct.pack('<I',len(body))+body
|
|
68
|
+
archive=struct.pack('<II',4,len(pickle))+pickle+payload
|
|
69
|
+
args.output.mkdir(parents=True,exist_ok=True)
|
|
70
|
+
(args.output/'app.asar').write_bytes(archive)
|
|
71
|
+
(args.output/'main.mjs').write_text(source)
|
|
72
|
+
new_hs=struct.unpack_from('<I',archive,4)[0]
|
|
73
|
+
for name,expected in files.items():
|
|
74
|
+
node=header['files']
|
|
75
|
+
for part in name.split('/')[:-1]:node=node[part]['files']
|
|
76
|
+
e=node[name.split('/')[-1]];start=8+new_hs+int(e['offset']);assert archive[start:start+e['size']]==expected
|
|
77
|
+
manifest={'inputSHA256':hashlib.sha256(raw).hexdigest(),'outputSHA256':hashlib.sha256(archive).hexdigest(),'changed':[n for n,b in files.items() if original.get(n)!=b]}
|
|
78
|
+
(args.output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n');print(json.dumps(manifest))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Native integration test: isolated profile/static fixture, never the user's DSH.
|
|
2
|
+
const {app,BrowserWindow}=require('electron');
|
|
3
|
+
const assert=require('node:assert/strict');
|
|
4
|
+
const fs=require('node:fs'),os=require('node:os'),path=require('node:path');
|
|
5
|
+
const {pathToFileURL}=require('node:url');
|
|
6
|
+
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'dsh-recovery-test-'));
|
|
7
|
+
app.setPath('userData',profile);app.setPath('sessionData',profile);
|
|
8
|
+
app.on('window-all-closed',()=>{});
|
|
9
|
+
const nativePid=process.pid;
|
|
10
|
+
const waitFor=(emitter,event)=>new Promise(resolve=>emitter.once(event,resolve));
|
|
11
|
+
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
12
|
+
const deadline=setTimeout(()=>{console.error('native recovery timeout');app.exit(2)},20000);
|
|
13
|
+
app.whenReady().then(async()=>{
|
|
14
|
+
const {installRendererRecovery}=await import(pathToFileURL(process.env.DSH_RECOVERY_MODULE||path.join(__dirname,'renderer-recovery.mjs')));
|
|
15
|
+
const results=[];
|
|
16
|
+
for(const hidden of [false,true]){
|
|
17
|
+
const events=[];let unavailable=0;
|
|
18
|
+
const window=new BrowserWindow({width:460,height:220,show:false,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false}});
|
|
19
|
+
const wc=window.webContents;
|
|
20
|
+
let complete;
|
|
21
|
+
if(!process.argv.includes('--baseline'))installRendererRecovery({app,window,write:(event,data)=>{events.push({event,...data});if(event==='renderer-recovery-complete')complete?.()},onUnavailable:()=>unavailable++});
|
|
22
|
+
await window.loadURL('data:text/html,<title>DSH recovery fixture</title><body style="margin:0;background:%23004c2e;color:white;font:24px sans-serif;padding:32px"><h1>Recovery fixture</h1><p>Local static page. No model calls.</p></body>');
|
|
23
|
+
if(!hidden)window.showInactive();
|
|
24
|
+
const originalContents=wc.id;
|
|
25
|
+
const baselinePixels=(await wc.capturePage()).toBitmap();
|
|
26
|
+
const reference=Array.from(baselinePixels.subarray(0,4));
|
|
27
|
+
assert.ok(reference[1]>reference[0]&&reference[1]>reference[2]&&reference[3]>0,'baseline fixture must be green');
|
|
28
|
+
for(let cycle=1;cycle<=2;cycle++){
|
|
29
|
+
const oldPid=wc.getOSProcessId();
|
|
30
|
+
const recovered=new Promise(resolve=>{complete=resolve});
|
|
31
|
+
const gone=waitFor(wc,'render-process-gone');
|
|
32
|
+
wc.forcefullyCrashRenderer();
|
|
33
|
+
await gone;
|
|
34
|
+
if(process.argv.includes('--baseline')){await delay(700);assert.ok(wc.getOSProcessId()>0,'renderer remains dead: persistent blank window after exit');}
|
|
35
|
+
await recovered;
|
|
36
|
+
assert.equal(process.pid,nativePid);assert.equal(wc.id,originalContents);
|
|
37
|
+
assert.ok(wc.getOSProcessId()>0);assert.notEqual(wc.getOSProcessId(),oldPid);
|
|
38
|
+
assert.equal(await wc.executeJavaScript('document.querySelector("h1").textContent'),'Recovery fixture');
|
|
39
|
+
const frame=await wc.capturePage();const pixels=frame.toBitmap();
|
|
40
|
+
let green=0;for(let i=0;i<pixels.length;i+=4)if(reference.every((v,j)=>Math.abs(pixels[i+j]-v)<=3))green++;
|
|
41
|
+
assert.ok(green>pixels.length/4*.5,'recovered page must paint fixture background instead of white');
|
|
42
|
+
results.push({hidden,cycle,recovered:true,sameContents:true,painted:true});
|
|
43
|
+
}
|
|
44
|
+
const gone=waitFor(wc,'render-process-gone');wc.forcefullyCrashRenderer();await gone;await delay(400);
|
|
45
|
+
assert.equal(unavailable,1,'third rapid exit must stop auto reload');
|
|
46
|
+
window.destroy();
|
|
47
|
+
}
|
|
48
|
+
console.log(JSON.stringify({electron:process.versions.electron,results,boundedRetry:true}));
|
|
49
|
+
clearTimeout(deadline);app.exit(0);
|
|
50
|
+
}).catch(error=>{console.error(error.stack);clearTimeout(deadline);app.exit(1)});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Recovery belongs to the desktop window, never to the Host or a plugin session.
|
|
2
|
+
// A zero exit code is still unexpected while the user's window remains open.
|
|
3
|
+
export function installRendererRecovery({
|
|
4
|
+
app, window, write = () => {}, onUnavailable = () => {},
|
|
5
|
+
repaint = () => window.webContents.invalidate(),
|
|
6
|
+
now = Date.now, scheduler = {setTimeout, clearTimeout},
|
|
7
|
+
retryDelayMs = 250, loadTimeoutMs = 15000, maxAttempts = 2, retryWindowMs = 60000,
|
|
8
|
+
}) {
|
|
9
|
+
const contents = window.webContents;
|
|
10
|
+
const listeners = [];
|
|
11
|
+
let disposed = false, quitting = false, closing = false;
|
|
12
|
+
let pending = false, reloadTimer = null, deadlineTimer = null, attempts = [];
|
|
13
|
+
const on = (emitter, event, fn) => {
|
|
14
|
+
emitter.on(event, fn);
|
|
15
|
+
listeners.push(() => emitter.removeListener(event, fn));
|
|
16
|
+
};
|
|
17
|
+
const live = () => !disposed && !quitting && !closing && !window.isDestroyed() && !contents.isDestroyed();
|
|
18
|
+
const record = (event, data = {}) => {
|
|
19
|
+
try { write(event, {contentsId: contents.id, ...data}); } catch {}
|
|
20
|
+
};
|
|
21
|
+
const clearTimers = () => {
|
|
22
|
+
if (reloadTimer !== null) scheduler.clearTimeout(reloadTimer);
|
|
23
|
+
if (deadlineTimer !== null) scheduler.clearTimeout(deadlineTimer);
|
|
24
|
+
reloadTimer = deadlineTimer = null;
|
|
25
|
+
};
|
|
26
|
+
const unavailable = reason => {
|
|
27
|
+
record('renderer-recovery-unavailable', {reason});
|
|
28
|
+
try { Promise.resolve(onUnavailable(reason)).catch(() => {}); } catch {}
|
|
29
|
+
};
|
|
30
|
+
const watchLoad = () => {
|
|
31
|
+
if (deadlineTimer !== null) scheduler.clearTimeout(deadlineTimer);
|
|
32
|
+
deadlineTimer = scheduler.setTimeout(() => {
|
|
33
|
+
deadlineTimer = null;
|
|
34
|
+
if (live() && pending) unavailable('load-timeout');
|
|
35
|
+
}, loadTimeoutMs);
|
|
36
|
+
};
|
|
37
|
+
on(contents, 'render-process-gone', (_event, details) => {
|
|
38
|
+
if (!live()) return;
|
|
39
|
+
if (reloadTimer !== null) return;
|
|
40
|
+
clearTimers();
|
|
41
|
+
pending = false;
|
|
42
|
+
const time = now();
|
|
43
|
+
attempts = attempts.filter(at => time - at < retryWindowMs);
|
|
44
|
+
if (attempts.length >= maxAttempts) { unavailable('repeated-exit'); return; }
|
|
45
|
+
attempts.push(time);
|
|
46
|
+
pending = true;
|
|
47
|
+
record('renderer-recovery-scheduled', {reason: details.reason, exitCode: details.exitCode, attempt: attempts.length});
|
|
48
|
+
reloadTimer = scheduler.setTimeout(() => {
|
|
49
|
+
reloadTimer = null;
|
|
50
|
+
if (!live() || !pending) return;
|
|
51
|
+
watchLoad();
|
|
52
|
+
try {
|
|
53
|
+
// Preserve the existing navigation controller, auth session and conversation.
|
|
54
|
+
if (!contents.isLoading()) contents.reload();
|
|
55
|
+
} catch {
|
|
56
|
+
clearTimers();
|
|
57
|
+
pending = false;
|
|
58
|
+
unavailable('reload-failed');
|
|
59
|
+
}
|
|
60
|
+
}, retryDelayMs);
|
|
61
|
+
});
|
|
62
|
+
on(contents, 'did-start-loading', () => {
|
|
63
|
+
if (!live() || !pending) return;
|
|
64
|
+
// A user's manual Reload can win the race with our queued recovery.
|
|
65
|
+
if (reloadTimer !== null) scheduler.clearTimeout(reloadTimer);
|
|
66
|
+
reloadTimer = null;
|
|
67
|
+
watchLoad();
|
|
68
|
+
});
|
|
69
|
+
on(contents, 'did-finish-load', () => {
|
|
70
|
+
if (!live() || !pending || contents.getOSProcessId() <= 0) return;
|
|
71
|
+
clearTimers();
|
|
72
|
+
pending = false;
|
|
73
|
+
try { repaint(); } catch { record('renderer-repaint-failed'); }
|
|
74
|
+
record('renderer-recovery-complete', {rendererPid: contents.getOSProcessId()});
|
|
75
|
+
});
|
|
76
|
+
on(app, 'before-quit', () => { quitting = true; pending = false; clearTimers(); });
|
|
77
|
+
on(window, 'close', () => {
|
|
78
|
+
closing = true;
|
|
79
|
+
pending = false;
|
|
80
|
+
clearTimers();
|
|
81
|
+
// A cancelled close must not disable recovery for the remaining window.
|
|
82
|
+
queueMicrotask(() => { if (!disposed && !quitting && !window.isDestroyed()) closing = false; });
|
|
83
|
+
});
|
|
84
|
+
on(contents, 'will-prevent-unload', () => { quitting = false; closing = false; });
|
|
85
|
+
function dispose() {
|
|
86
|
+
if (disposed) return;
|
|
87
|
+
disposed = true;
|
|
88
|
+
clearTimers();
|
|
89
|
+
for (const remove of listeners) remove();
|
|
90
|
+
}
|
|
91
|
+
on(window, 'closed', dispose);
|
|
92
|
+
on(contents, 'destroyed', dispose);
|
|
93
|
+
record('renderer-recovery-attached');
|
|
94
|
+
return dispose;
|
|
95
|
+
}
|