This commit is contained in:
2026-10-17 21:21:03 +07:00
parent 1d4028b183
commit 402e212332
15 changed files with 1017 additions and 24 deletions
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { DiagnosticLog } from './diagnostics.js'
test('журнал сохраняет только разрешённые поля и значения без секретов', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'screen-log-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const log = new DiagnosticLog(dir)
log.record('app-start', {
version: '0.2.0-rc.1',
electron: '42.11.15',
platform: 'linux',
arch: 'x64',
token: 'SECRET',
})
log.record('output-close', { reason: 'renderer', password: 'SECRET', error: new Error('SECRET') })
log.record('output-close', { reason: 'https://secret.example/SECRET' })
log.record('package', { action: 'download', status: 'failed', response: 'SECRET' })
const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
assert.equal(content.includes('SECRET'), false)
const lines = content
.trim()
.split('\n')
.map((s) => JSON.parse(s))
assert.equal(lines.length, 3)
assert.equal(lines[0].version, '0.2.0-rc.1')
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
})
test('ротация ограничивает журнал тремя файлами; ошибка записи не ломает приложение', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'screen-log-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const log = new DiagnosticLog(dir, 256)
for (let i = 0; i < 100; i++) log.record('output-open')
const files = await readdir(dir)
assert.equal(files.length, 3)
for (const file of files) assert.ok((await stat(join(dir, file))).size <= 256)
const blocked = join(dir, 'not-a-directory')
await writeFile(blocked, 'file')
const broken = new DiagnosticLog(blocked)
assert.doesNotThrow(() => broken.record('output-open'))
assert.doesNotThrow(() => broken.record('app-stop'))
})
+98
View File
@@ -0,0 +1,98 @@
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
type Event =
| 'app-start'
| 'app-stop'
| 'startup-error'
| 'settings-error'
| 'invalid-report'
| 'output-open'
| 'output-close'
| 'media-error'
| 'connection'
| 'package'
const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
'output-close': {
reason: [
'operator',
'display',
'power',
'fullscreen',
'renderer',
'document',
'geometry',
'unresponsive',
],
},
'media-error': { channel: ['main', 'music', 'scene'] },
connection: { status: ['disabled', 'waiting', 'connected', 'offline', 'expired'] },
package: { action: ['download', 'check', 'remove'], status: ['success', 'failed'] },
}
const events: readonly string[] = [
'app-start',
'app-stop',
'startup-error',
'settings-error',
'invalid-report',
'output-open',
'output-close',
'media-error',
'connection',
'package',
]
export class DiagnosticLog {
private disabled = false
constructor(
private directory: string,
private limit = 1024 * 1024,
) {}
record(event: Event, data: Record<string, unknown> = {}) {
if (this.disabled || !events.includes(event)) return
const entry: Record<string, string> = { time: new Date().toISOString(), event }
// Только разрешённые значения: никаких Error.message, URL, токенов или состояния игры.
for (const [key, allowed] of Object.entries(fields[event] ?? {})) {
if (typeof data[key] !== 'string' || !allowed.includes(data[key])) return
entry[key] = data[key]
}
if (event === 'app-start') {
for (const key of ['version', 'electron'] as const) {
if (typeof data[key] !== 'string' || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(data[key]))
return
entry[key] = data[key]
}
if (
!['linux', 'darwin', 'win32'].includes(String(data.platform)) ||
!['x64', 'arm64', 'ia32'].includes(String(data.arch))
)
return
entry.platform = String(data.platform)
entry.arch = String(data.arch)
}
try {
mkdirSync(this.directory, { recursive: true, mode: 0o700 })
const path = join(this.directory, 'diagnostic.jsonl')
const line = JSON.stringify(entry) + '\n'
let size = 0
try {
size = statSync(path).size
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (size + Buffer.byteLength(line) > this.limit) {
rmSync(join(this.directory, 'diagnostic.2.jsonl'), { force: true })
const previous = join(this.directory, 'diagnostic.1.jsonl')
try {
renameSync(previous, join(this.directory, 'diagnostic.2.jsonl'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
renameSync(path, previous)
}
appendFileSync(path, line, { mode: 0o600 })
} catch {
this.disabled = true
console.error('SCREEN_LOG_UNAVAILABLE')
}
}
}
+67 -8
View File
@@ -13,6 +13,7 @@ import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { DiagnosticLog } from './diagnostics.js'
import { Controller, parseCommand } from './controller.js'
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
import { parseRange } from './range.js'
@@ -43,8 +44,18 @@ let settings: WindowSettings
let sleepBlocker: number | null = null
let hostNotice = ''
let suspended = false
let diagnostics: DiagnosticLog | null = null
const lastErrors = { main: false, music: false, scene: false }
let saveTimer: ReturnType<typeof setTimeout> | undefined
const controller = new Controller((state) => {
for (const channel of ['main', 'music', 'scene'] as const) {
const failed =
channel === 'scene'
? state.scene.status === 'error'
: state.channels[channel].status === 'error'
if (failed && !lastErrors[channel]) diagnostics?.record('media-error', { channel })
lastErrors[channel] = failed
}
for (const window of [control, output])
if (window && !window.isDestroyed()) window.webContents.send('state', state)
})
@@ -101,6 +112,23 @@ function closeOutput(reason = '', destroy = true) {
if (reason) outputEpoch++
const window = output
if (!window && (!reason || controller.state.timer.status !== 'running')) return
diagnostics?.record('output-close', {
reason: !reason
? 'operator'
: reason.includes('сон')
? 'power'
: reason.includes('полноэкран')
? 'fullscreen'
: reason.includes('Процесс')
? 'renderer'
: reason.includes('не отвечает')
? 'unresponsive'
: reason.includes('загрузить')
? 'document'
: reason.includes('Параметры')
? 'geometry'
: 'display',
})
output = null
if (window) closingWindow = window
selectedDisplay = null
@@ -123,6 +151,7 @@ function saveWindowSettings() {
try {
settings.save()
} catch {
diagnostics?.record('settings-error')
hostNotice = 'Не удалось сохранить расположение окон; материалы игры не затронуты'
}
}
@@ -180,6 +209,7 @@ async function show(displayId: number) {
saveWindowSettings()
secure(window)
controller.output(true)
diagnostics?.record('output-open')
try {
sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
} catch {
@@ -241,10 +271,18 @@ app.on('second-instance', () => {
app
.whenReady()
.then(async () => {
diagnostics = new DiagnosticLog(join(app.getPath('userData'), 'logs'))
diagnostics.record('app-start', {
version: app.getVersion(),
electron: process.versions.electron,
platform: process.platform,
arch: process.arch,
})
settings = new WindowSettings(join(app.getPath('userData'), 'windows.json'))
try {
await settings.load()
} catch (error) {
diagnostics.record('settings-error')
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
}
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
@@ -258,8 +296,14 @@ app
decrypt: (value) => safeStorage.decryptString(value),
})
await online.initialize()
let previousConnection = online.state.connection
const logConnection = () => {
const status = online!.state.connection
if (status !== previousConnection) diagnostics?.record('connection', { status })
previousConnection = status
}
const onlineTick = setInterval(() => {
void online?.poll()
void online?.poll().then(logConnection)
}, 1000)
const timerTick = setInterval(() => controller.tick(), 250)
app.on('before-quit', () => {
@@ -271,6 +315,7 @@ app
clearInterval(onlineTick)
online?.close()
packages.cancel()
diagnostics?.record('app-stop')
})
const handleAppRequest: Parameters<typeof protocol.handle>[1] = async (request) => {
const url = new URL(request.url)
@@ -378,7 +423,20 @@ app
ipcMain.handle('online-action', async (event, intent: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
return online!.act(intent)
const action =
intent && typeof intent === 'object' && 'action' in intent ? intent.action : null
try {
const result = await online!.act(intent)
if (['download', 'check', 'remove'].includes(String(action)))
diagnostics?.record('package', { action, status: 'success' })
return result
} catch (error) {
if (['download', 'check', 'remove'].includes(String(action)))
diagnostics?.record('package', { action, status: 'failed' })
throw error
} finally {
logConnection()
}
})
ipcMain.handle('close-output', (event) => {
requireControl(event)
@@ -484,8 +542,8 @@ app
return
try {
controller.reportScene(report)
} catch (error) {
console.error('Ошибка подтверждения сцены', error)
} catch {
diagnostics?.record('invalid-report')
}
})
ipcMain.on('report', (event, report: unknown) => {
@@ -496,8 +554,8 @@ app
return
try {
controller.report(report)
} catch (error) {
console.error('Ошибка отчёта проигрывателя', error)
} catch {
diagnostics?.record('invalid-report')
}
})
screen.on('display-removed', (_event, display) => {
@@ -526,8 +584,9 @@ app
})
await control.loadURL('screen-app://app/index.html')
})
.catch((error) => {
console.error(error)
.catch(() => {
diagnostics?.record('startup-error')
console.error('SCREEN_STARTUP_FAILED')
app.quit()
})
app.on('window-all-closed', () => app.quit())