feat(commands): whole-history dedupe + switchable, capped history
历史命令去重从「只跟最新一条比」改为「全历史去重」:重敲任意一条已有命令 时把原条目提到最前(刷新 lastUsedAt)而不是新插一条,历史里每个命令只出现一次。 新增两个设置(设置 → 终端): - 记录命令历史:关闭后不再记录新命令;已有历史保留,不清空 - 历史条数上限:默认 100,可配 1..500;写入按上限截断,读取也按当前 上限截断(调低立即生效) 顺修一个暴露的既有 bug:logWrite 用 async appendFile,两次快速追加会 乱序落盘(line two 先于 line one)。改为按文件串行化追加。 测试:tests/commands-store.mjs 全历史去重 / 提到最前 / 上限生效 / 开关关闭不记录 / 关闭保留已有 / 恢复记录 / 越界上限夹紧,全部通过。
This commit is contained in:
1 parent
beeb32a76b
commit
277d8bb117
5 files changed
+254
-29
No files matched your search
+86
-10
@@ -24,9 +24,13 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
|
|||||||
import { appendFile } from 'fs/promises'
|
import { appendFile } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||||
|
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||||
|
import { loadSettings } from './settingsStore'
|
||||||
|
|
||||||
/** Upper bound on recorded history entries. */
|
/** Upper bound on recorded history entries when no limit is configured. */
|
||||||
const HISTORY_CAP = 500
|
const HISTORY_CAP = 500
|
||||||
|
/** Hard ceiling: the history is also the completion source, so it stays small. */
|
||||||
|
export const HISTORY_LIMIT_MAX = 500
|
||||||
|
|
||||||
interface CommandsFile {
|
interface CommandsFile {
|
||||||
history: CommandItem[]
|
history: CommandItem[]
|
||||||
@@ -67,6 +71,12 @@ export class CommandsStore {
|
|||||||
private readonly metasByFile = new Map<string, SessionLogMeta>()
|
private readonly metasByFile = new Map<string, SessionLogMeta>()
|
||||||
/** Logs currently accumulating output, keyed by sessionId. */
|
/** Logs currently accumulating output, keyed by sessionId. */
|
||||||
private readonly activeBySession = new Map<string, SessionLogMeta>()
|
private readonly activeBySession = new Map<string, SessionLogMeta>()
|
||||||
|
/**
|
||||||
|
* Pending append per log file. appendFile is async, so two rapid logWrite
|
||||||
|
* calls would otherwise race and land out of order. Chaining each new append
|
||||||
|
* onto the previous one keeps the file append-only in call order.
|
||||||
|
*/
|
||||||
|
private readonly pendingAppends = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
|
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
|
||||||
const ud = userData ?? app.getPath('userData')
|
const ud = userData ?? app.getPath('userData')
|
||||||
@@ -100,15 +110,32 @@ export class CommandsStore {
|
|||||||
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
|
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record one executed command: trim; dedupe vs newest; move to front; cap. */
|
/**
|
||||||
|
* Record one executed command.
|
||||||
|
*
|
||||||
|
* Deduplication is over the *whole* history, not just the newest entry: a
|
||||||
|
* command used three commands ago is the same command, so re-running it moves
|
||||||
|
* the existing entry to the front (refreshing `lastUsedAt`) instead of adding
|
||||||
|
* a second copy. History reads back sorted by `lastUsedAt`, so hoisting is
|
||||||
|
* exactly what "most recently used" means — and it keeps the list from
|
||||||
|
* filling up with one line repeated.
|
||||||
|
*
|
||||||
|
* Silently does nothing when 设置 → 终端 → 记录命令历史 is off: the setting is
|
||||||
|
* read here (not at the call site) so every recording path honours it, and
|
||||||
|
* existing history is left untouched rather than cleared.
|
||||||
|
*/
|
||||||
recordCommand(cmd: string): void {
|
recordCommand(cmd: string): void {
|
||||||
const trimmed = cmd.trim()
|
const trimmed = cmd.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
|
const { historyEnabled, historyLimit } = loadHistoryPrefs()
|
||||||
|
if (!historyEnabled) return
|
||||||
|
const cap = normalizeLimit(historyLimit)
|
||||||
const data = this.loadCommands()
|
const data = this.loadCommands()
|
||||||
const top = data.history[0]
|
const existing = data.history.findIndex((item) => item.command === trimmed)
|
||||||
if (top && top.command === trimmed) {
|
if (existing >= 0) {
|
||||||
// Same as the newest entry: refresh its recency (already at the front).
|
const [item] = data.history.splice(existing, 1)
|
||||||
top.lastUsedAt = Date.now()
|
item.lastUsedAt = Date.now()
|
||||||
|
data.history.unshift(item)
|
||||||
} else {
|
} else {
|
||||||
data.history.unshift({
|
data.history.unshift({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -116,16 +143,23 @@ export class CommandsStore {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastUsedAt: Date.now()
|
lastUsedAt: Date.now()
|
||||||
})
|
})
|
||||||
if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP
|
|
||||||
}
|
}
|
||||||
|
if (data.history.length > cap) data.history.length = cap
|
||||||
this.saveCommands(data)
|
this.saveCommands(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** History sorted by most recently used. */
|
/**
|
||||||
|
* History sorted by most recently used, trimmed to the configured limit.
|
||||||
|
*
|
||||||
|
* The trim happens on read as well as on write so lowering the limit in
|
||||||
|
* settings takes effect at once instead of waiting for the next command.
|
||||||
|
*/
|
||||||
listHistory(): CommandItem[] {
|
listHistory(): CommandItem[] {
|
||||||
|
const { historyLimit } = loadHistoryPrefs()
|
||||||
return this.loadCommands()
|
return this.loadCommands()
|
||||||
.history.slice()
|
.history.slice()
|
||||||
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
|
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
|
||||||
|
.slice(0, normalizeLimit(historyLimit))
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHistory(): void {
|
clearHistory(): void {
|
||||||
@@ -246,8 +280,18 @@ export class CommandsStore {
|
|||||||
// not logging (or already stopped) -> ignore, prevents stop/append race
|
// not logging (or already stopped) -> ignore, prevents stop/append race
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appendFile(meta.file, data, 'utf8').catch(() => {
|
// Chain onto any in-flight append so rapid writes land in call order.
|
||||||
// file may have been removed after stop -> ignore
|
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
|
||||||
|
const next = prev.then(() => appendFile(meta.file, data, 'utf8')).then(
|
||||||
|
() => undefined,
|
||||||
|
() => {
|
||||||
|
// file may have been removed after stop -> ignore
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.pendingAppends.set(meta.file, next)
|
||||||
|
// Avoid unbounded growth of the chain map once the file settles.
|
||||||
|
next.finally(() => {
|
||||||
|
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,3 +330,35 @@ function describeFile(p: string): void {
|
|||||||
// best effort; appends will also create it lazily
|
// best effort; appends will also create it lazily
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History preferences straight from settings.
|
||||||
|
*
|
||||||
|
* Read on every use rather than cached: the store is constructed once at boot
|
||||||
|
* but the user can flip the switch at any time, and a stale copy would keep
|
||||||
|
* recording after they turned it off.
|
||||||
|
*/
|
||||||
|
function loadHistoryPrefs(): { historyEnabled: boolean; historyLimit: number } {
|
||||||
|
try {
|
||||||
|
const terminal = loadSettings().terminal
|
||||||
|
return {
|
||||||
|
historyEnabled: terminal.historyEnabled !== false,
|
||||||
|
historyLimit: normalizeLimit(terminal.historyLimit)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Settings unreadable (e.g. store used outside Electron in a test): fall
|
||||||
|
// back to the same defaults the app ships with.
|
||||||
|
return {
|
||||||
|
historyEnabled: DEFAULT_SETTINGS.terminal.historyEnabled,
|
||||||
|
historyLimit: DEFAULT_SETTINGS.terminal.historyLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a configured limit into 1..HISTORY_LIMIT_MAX; unusable → the default. */
|
||||||
|
function normalizeLimit(value: unknown): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_SETTINGS.terminal.historyLimit
|
||||||
|
const rounded = Math.floor(value)
|
||||||
|
if (rounded < 1) return 1
|
||||||
|
return Math.min(rounded, HISTORY_LIMIT_MAX)
|
||||||
|
}
|
||||||
@@ -288,6 +288,33 @@ export function RenderSettingsTab(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label="记录命令历史"
|
||||||
|
desc="关闭后不再记录新命令;已保存的历史仍然保留"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.terminal.historyEnabled !== false}
|
||||||
|
onChange={(checked) => void updateTerminal({ historyEnabled: checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label="历史条数上限"
|
||||||
|
desc="命令历史最多保留多少条,重复命令只记一次"
|
||||||
|
control={
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
step={10}
|
||||||
|
value={settings.terminal.historyLimit}
|
||||||
|
disabled={settings.terminal.historyEnabled === false}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (typeof value === 'number') void updateTerminal({ historyLimit: value })
|
||||||
|
}}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface TerminalSettings {
|
|||||||
pasteRiskConfirm: boolean
|
pasteRiskConfirm: boolean
|
||||||
/** inline command suggestion popup while typing (history + library) */
|
/** inline command suggestion popup while typing (history + library) */
|
||||||
suggestEnabled: boolean
|
suggestEnabled: boolean
|
||||||
|
/** record executed commands into the history panel */
|
||||||
|
historyEnabled: boolean
|
||||||
|
/** max entries kept in command history */
|
||||||
|
historyLimit: number
|
||||||
/** accent color for the active tab outline, rail indicator and SSH badges */
|
/** accent color for the active tab outline, rail indicator and SSH badges */
|
||||||
tabAccentColor: string
|
tabAccentColor: string
|
||||||
}
|
}
|
||||||
@@ -114,6 +118,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
copyOnSelect: false,
|
copyOnSelect: false,
|
||||||
pasteRiskConfirm: true,
|
pasteRiskConfirm: true,
|
||||||
suggestEnabled: true,
|
suggestEnabled: true,
|
||||||
|
historyEnabled: true,
|
||||||
|
historyLimit: 100,
|
||||||
tabAccentColor: '#3fb950'
|
tabAccentColor: '#3fb950'
|
||||||
},
|
},
|
||||||
customThemes: [],
|
customThemes: [],
|
||||||
|
|||||||
+78
-15
@@ -6,10 +6,11 @@
|
|||||||
* session log write/read/index behavior against a temp userData dir.
|
* session log write/read/index behavior against a temp userData dir.
|
||||||
*
|
*
|
||||||
* Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \
|
* Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \
|
||||||
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs
|
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs \
|
||||||
|
* --alias:@shared=./src/shared
|
||||||
* Run: node tests/commands-store.mjs (must exit 0)
|
* Run: node tests/commands-store.mjs (must exit 0)
|
||||||
*/
|
*/
|
||||||
import { mkdtempSync, readFileSync } from 'fs'
|
import { mkdtempSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { createRequire } from 'module'
|
import { createRequire } from 'module'
|
||||||
@@ -25,16 +26,23 @@ const ok = (cond, msg) => {
|
|||||||
}
|
}
|
||||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) -------
|
// ---- 1. Temp userData must exist before the bundle loads ------------------------
|
||||||
|
// The settings store resolves its own path via app.getPath('userData'). The stub
|
||||||
|
// is bundled *into* the module under test, so requiring it here gives a different
|
||||||
|
// instance — `__setUserData` would not reach the bundle. The env var is read when
|
||||||
|
// the stub loads, so it has to be set before the bundle is required below.
|
||||||
|
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
|
||||||
|
process.env.OT_STUB_USERDATA = userData
|
||||||
|
|
||||||
|
// ---- 1b. Bundle the store once (assumes tests/.commands-store.cjs exists) ------
|
||||||
let commandsMod
|
let commandsMod
|
||||||
try {
|
try {
|
||||||
commandsMod = require_('./.commands-store.cjs')
|
commandsMod = require_('./.commands-store.cjs')
|
||||||
} catch {
|
} catch {
|
||||||
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs')
|
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs --alias:@shared=./src/shared')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 2. Temp userData; capture openDir target ----------------------------------
|
// ---- 2. Store over the temp userData; capture openDir target -------------------
|
||||||
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
|
|
||||||
let openedPath = null
|
let openedPath = null
|
||||||
const store = new commandsMod.CommandsStore(userData, async (p) => {
|
const store = new commandsMod.CommandsStore(userData, async (p) => {
|
||||||
openedPath = p
|
openedPath = p
|
||||||
@@ -53,23 +61,78 @@ store.recordCommand('ps aux')
|
|||||||
hist = store.listHistory()
|
hist = store.listHistory()
|
||||||
ok(hist.length === 3, 're-run of newest command is deduped (no new entry)')
|
ok(hist.length === 3, 're-run of newest command is deduped (no new entry)')
|
||||||
|
|
||||||
// Re-run an OLDER command -> it is NOT the newest, so it becomes a fresh front
|
// Re-run an OLDER command -> dedupe is over the whole history, so it moves the
|
||||||
// entry (spec dedupes only against the newest entry) with blank-recency guard.
|
// existing entry to the front instead of adding a copy.
|
||||||
store.recordCommand('echo hello')
|
store.recordCommand('echo hello')
|
||||||
hist = store.listHistory()
|
hist = store.listHistory()
|
||||||
ok(hist.length === 4, 'older command re-run is a new front entry (dedupe is newest-only)')
|
ok(hist.length === 3, 'older command re-run does NOT add a copy (whole-history dedupe)')
|
||||||
ok(hist[0].command === 'echo hello', 'older re-run lands at front')
|
ok(hist[0].command === 'echo hello', 'older re-run is hoisted to the front')
|
||||||
|
// The hoisted entry keeps its original createdAt (it is the same command, not a new one).
|
||||||
|
ok(new Set(hist.map((h) => h.command)).size === 3, 'history holds 3 distinct commands')
|
||||||
|
|
||||||
// Cap: fill until well above 500 keeps only the newest 500.
|
// A command used long ago stays single even after many others in between.
|
||||||
for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`)
|
for (let i = 0; i < 20; i++) store.recordCommand(`noise-${i}`)
|
||||||
|
store.recordCommand('ls -la')
|
||||||
hist = store.listHistory()
|
hist = store.listHistory()
|
||||||
ok(hist.length === 500, `history capped at 500 (got ${hist.length})`)
|
ok(hist.filter((h) => h.command === 'ls -la').length === 1, 'a command seen 20 entries ago is not duplicated')
|
||||||
ok(hist[0].command === 'cmd-519', 'newest filler at front')
|
ok(hist[0].command === 'ls -la', 'and it is hoisted to the front')
|
||||||
|
|
||||||
// Clear.
|
// Clear before the cap test so the assertions below are exact.
|
||||||
store.clearHistory()
|
store.clearHistory()
|
||||||
ok(store.listHistory().length === 0, 'clearHistory empties history')
|
ok(store.listHistory().length === 0, 'clearHistory empties history')
|
||||||
|
|
||||||
|
// ---- 3b. History limit + the recording switch -----------------------------------
|
||||||
|
const writeSettings = (terminal) =>
|
||||||
|
writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal, system: {} }), 'utf8')
|
||||||
|
const DEFAULT_LIMIT = 100
|
||||||
|
|
||||||
|
// The bundled default raises past the old hard cap of 500 only when configured;
|
||||||
|
// out of the box the list is trimmed to the default limit.
|
||||||
|
for (let i = 0; i < 150; i++) store.recordCommand(`filler-${i}`)
|
||||||
|
hist = store.listHistory()
|
||||||
|
ok(hist.length === DEFAULT_LIMIT, `history trimmed to the default limit of ${DEFAULT_LIMIT} (got ${hist.length})`)
|
||||||
|
ok(hist[0].command === 'filler-149', 'newest filler at front')
|
||||||
|
|
||||||
|
// Lower the limit -> takes effect immediately, without needing a new command.
|
||||||
|
writeSettings({ historyLimit: 20 })
|
||||||
|
hist = store.listHistory()
|
||||||
|
ok(hist.length === 20, `lowered limit applies on read (got ${hist.length})`)
|
||||||
|
|
||||||
|
// Raise it -> entries that survived the *disk* cap (the configured limit at write
|
||||||
|
// time) become visible again, up to the new limit. Nothing was resurrected from
|
||||||
|
// beyond the write-time cap; the write cap is the real bound on disk growth.
|
||||||
|
writeSettings({ historyLimit: 50 })
|
||||||
|
store.recordCommand('after-raise')
|
||||||
|
hist = store.listHistory()
|
||||||
|
ok(hist.length === 50, `raising the limit reveals still-stored entries (got ${hist.length})`)
|
||||||
|
ok(hist[0].command === 'after-raise', 'new command recorded after raising the limit')
|
||||||
|
|
||||||
|
// Nonsense / out-of-range limits fall back to something sane instead of throwing.
|
||||||
|
writeSettings({ historyLimit: 0 })
|
||||||
|
ok(store.listHistory().length === 1, 'limit 0 clamps to 1 rather than emptying history')
|
||||||
|
writeSettings({ historyLimit: 99999 })
|
||||||
|
ok(store.listHistory().length === 50, 'absurd limit is clamped to the hard ceiling')
|
||||||
|
|
||||||
|
// The switch: recording stops, and existing history is preserved (not cleared).
|
||||||
|
writeSettings({ historyLimit: 50, historyEnabled: false })
|
||||||
|
const before = store.listHistory()
|
||||||
|
store.recordCommand('must-not-be-recorded')
|
||||||
|
store.recordCommand('must-not-be-recorded')
|
||||||
|
hist = store.listHistory()
|
||||||
|
ok(!hist.some((h) => h.command === 'must-not-be-recorded'), 'historyEnabled=false records nothing')
|
||||||
|
ok(hist.length === before.length, 'turning the switch off keeps existing history intact')
|
||||||
|
|
||||||
|
// Turning it back on resumes recording.
|
||||||
|
writeSettings({ historyLimit: 50, historyEnabled: true })
|
||||||
|
store.recordCommand('recorded-again')
|
||||||
|
ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording')
|
||||||
|
|
||||||
|
// Default (keys absent from settings.json) records, matching a fresh install.
|
||||||
|
writeSettings({})
|
||||||
|
store.recordCommand('default-on')
|
||||||
|
ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording')
|
||||||
|
store.clearHistory()
|
||||||
|
|
||||||
// ---- 4. Library CRUD + ordering ------------------------------------------------
|
// ---- 4. Library CRUD + ordering ------------------------------------------------
|
||||||
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
|
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
|
||||||
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
|
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
|
||||||
|
|||||||
+57
-4
@@ -1,12 +1,65 @@
|
|||||||
// Minimal electron stub for bundling src/main/pty.ts under plain Node
|
// Minimal electron stub for bundling src/main/*.ts under plain Node
|
||||||
// (tests/ssh-session-e2e.mjs). Only what broadcast.ts touches.
|
// (tests/ssh-session-e2e.mjs, tests/commands-store.mjs). Only what the bundled
|
||||||
|
// modules touch.
|
||||||
|
//
|
||||||
|
// `app.getPath('userData')` is configurable via `__setUserData` so a test can
|
||||||
|
// point the settings store at a temp dir and exercise settings-driven behavior.
|
||||||
|
let userDataPath = process.env.OT_STUB_USERDATA || ''
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BrowserWindow: {
|
BrowserWindow: {
|
||||||
getAllWindows: () => []
|
getAllWindows: () => []
|
||||||
},
|
},
|
||||||
app: {},
|
app: {
|
||||||
ipcMain: {},
|
getPath: (name) => {
|
||||||
|
if (name === 'userData' && userDataPath) return userDataPath
|
||||||
|
throw new Error(`electron stub: app.getPath(${name}) is not configured`)
|
||||||
|
},
|
||||||
|
setLoginItemSettings: () => {},
|
||||||
|
isPackaged: false
|
||||||
|
},
|
||||||
|
ipcMain: {
|
||||||
|
handle: () => {},
|
||||||
|
on: () => {}
|
||||||
|
},
|
||||||
|
globalShortcut: {
|
||||||
|
register: () => true,
|
||||||
|
unregister: () => {},
|
||||||
|
unregisterAll: () => {},
|
||||||
|
isRegistered: () => false
|
||||||
|
},
|
||||||
|
webContents: {},
|
||||||
|
powerSaveBlocker: {
|
||||||
|
start: () => 1,
|
||||||
|
stop: () => {},
|
||||||
|
isStarted: () => false
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
openPath: async () => ''
|
||||||
|
},
|
||||||
safeStorage: {
|
safeStorage: {
|
||||||
isEncryptionAvailable: () => false
|
isEncryptionAvailable: () => false
|
||||||
|
},
|
||||||
|
nativeTheme: {
|
||||||
|
shouldUseDarkColors: true,
|
||||||
|
on: () => {}
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
buildFromTemplate: () => ({ popup: () => {} }),
|
||||||
|
setApplicationMenu: () => {}
|
||||||
|
},
|
||||||
|
Tray: class {
|
||||||
|
setToolTip() {}
|
||||||
|
setContextMenu() {}
|
||||||
|
on() {}
|
||||||
|
destroy() {}
|
||||||
|
static getBounds() {
|
||||||
|
return { x: 0, y: 0, width: 0, height: 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nativeImage: {
|
||||||
|
createFromPath: () => ({ isEmpty: () => true, resize: () => ({}) })
|
||||||
|
},
|
||||||
|
__setUserData: (p) => {
|
||||||
|
userDataPath = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user