up
This commit is contained in:
@@ -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'))
|
||||
})
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user