diff --git a/package.json b/package.json index c7db65a2..91eedd96 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "e2e": "playwright test", "pree2e:ui": "electron-rebuild -f -o better-sqlite3", "e2e:ui": "playwright test --ui", + "gen:settings-search": "node scripts/gen-settings-search.mjs", "verify:architecture": "node scripts/verify-architecture.mjs" }, "dependencies": { diff --git a/scripts/gen-settings-search.mjs b/scripts/gen-settings-search.mjs new file mode 100644 index 00000000..bd764389 --- /dev/null +++ b/scripts/gen-settings-search.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Regenerate src/renderer/src/lib/settingsSearchIndex.ts from the Settings +// page sources. Run after adding or removing a setting; the settings-search +// test fails until you do. +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { PAGE_SOURCES, pageKeys } from './settings-search-sources.mjs' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const read = (f) => fs.readFileSync(path.join(root, 'src/renderer/src/components', f), 'utf8') +let out = `// GENERATED by \`npm run gen:settings-search\` from the Settings page sources +// (scripts/settings-search-sources.mjs). test/settings-search.test.ts fails +// when a page renders a key this index does not have — regenerate. + +import type { SettingsPage } from '../components/Settings' + +export const SETTINGS_SEARCH_KEYS: Record = { +` +const pages = Object.keys(PAGE_SOURCES) +pages.forEach((p, i) => { + out += ` ${p}: [\n${pageKeys(p, read).map((k) => ` '${k}'`).join(',\n')}\n ]${i < pages.length - 1 ? ',' : ''}\n` +}) +out += '}\n' +fs.writeFileSync(path.join(root, 'src/renderer/src/lib/settingsSearchIndex.ts'), out) +console.log('settingsSearchIndex.ts regenerated') diff --git a/scripts/settings-search-sources.mjs b/scripts/settings-search-sources.mjs new file mode 100644 index 00000000..9efd50f7 --- /dev/null +++ b/scripts/settings-search-sources.mjs @@ -0,0 +1,38 @@ +// Which source files make up each Settings page, for the field-search index +// (src/renderer/src/lib/settingsSearchIndex.ts) and the test that keeps that +// index in step with the pages. `after` takes the file from that marker on; +// `before` up to it — AgentsPanel.tsx holds two panels shown on two pages. +export const PAGE_SOURCES = { + hooks: [ + { file: 'settings/HooksPanel.tsx' }, + { file: 'WslPanel.tsx' }, + { file: 'settings/AgentsPanel.tsx', after: 'export function HookWatchPathsPanel' } + ], + agents: [{ file: 'settings/AgentsPanel.tsx', before: 'export function HookWatchPathsPanel' }], + captureControl: [{ file: 'settings/CaptureControlPage.tsx' }, { file: 'settings/LootRulesGroup.tsx' }], + scope: [{ file: 'settings/ScopePage.tsx' }], + network: [{ file: 'settings/NetworkPage.tsx' }], + integrity: [{ file: 'settings/IntegrityPanel.tsx' }], + plugins: [{ file: 'settings/PluginsPanel.tsx' }], + general: [{ file: 'settings/GeneralPage.tsx' }], + hud: [{ file: 'settings/HudPage.tsx' }] +} + +const KEY = /\bt\(\s*['"]([a-zA-Z]+\.[A-Za-z0-9_.]+)['"]/g + +/** The i18n keys a page renders, in first-seen order. `read` returns a + * component file's source by its path under src/renderer/src/components. */ +export function pageKeys(page, read) { + const keys = [] + for (const { file, after, before } of PAGE_SOURCES[page]) { + let src = read(file) + if (after) src = src.slice(src.indexOf(after)) + if (before) src = src.slice(0, src.indexOf(before)) + for (const m of src.matchAll(KEY)) { + // Toasts and shared button words are not something you look a setting up by. + if (/^(toast|common)\./.test(m[1])) continue + if (!keys.includes(m[1])) keys.push(m[1]) + } + } + return keys +} diff --git a/specs/034-operator-words/checklists/requirements.md b/specs/034-operator-words/checklists/requirements.md new file mode 100644 index 00000000..d6d1f13c --- /dev/null +++ b/specs/034-operator-words/checklists/requirements.md @@ -0,0 +1,6 @@ +# Requirements Checklist: Settings Search, Type Names, Clock Meaning + +- [x] Each FR has an observable, tested outcome. +- [x] Empty and no-match states specified. +- [x] The clock decision records the prior audit decision it keeps. +- [x] The label change is reconciled with #143's earlier relabel. diff --git a/specs/034-operator-words/plan.md b/specs/034-operator-words/plan.md new file mode 100644 index 00000000..6b7e43bc --- /dev/null +++ b/specs/034-operator-words/plan.md @@ -0,0 +1,27 @@ +# Implementation Plan: Settings Search, Type Names, Clock Meaning + +## Canonical module interface + +- `lib/settingsSearch.ts` `searchSettings(query, t)` over + `lib/settingsSearchIndex.ts` (generated by `scripts/gen-settings-search.mjs` + from `scripts/settings-search-sources.mjs`). +- `lib/timelineDomain.ts` `LANE_LABEL_KEYS`, `agentTypeLabel(type, t)` — + the Timeline and the filter bar both read them. + +## Constitution Check + +- **Surface Truthfulness**: the search label is tied to a wired search; the + clock states what it counts; types have names an operator uses. +- **Architectural Restraint**: no new settings pages or rearrangement; one + shared name table instead of a second one. +- **Risk-Based Test-First**: copy-level changes, tested by render and source + guards; the index guard prevents silent drift. + +## Design + +1. Index: per page, the i18n keys its source renders (toast/common excluded); + `AgentsPanel.tsx` is split at `HookWatchPathsPanel`, which renders on Hooks. +2. Settings: results under the page list; choosing one switches page and + scrolls the first element containing the text into view with a brief ring. +3. Filter bar and Timeline read `LANE_LABEL_KEYS`. +4. Status bar keeps its project-creation clock; adds tooltip and aria-label. diff --git a/specs/034-operator-words/quickstart.md b/specs/034-operator-words/quickstart.md new file mode 100644 index 00000000..0f005071 --- /dev/null +++ b/specs/034-operator-words/quickstart.md @@ -0,0 +1,8 @@ +# Quickstart: Settings Search, Type Names, Clock Meaning + +1. Settings → type `retention` (or `保存`) → pick the result → Capture control + opens at "Retention and disk budgets". +2. Timeline → Filter → Type: options read Shell, HTTP, Proxy…; hover shows the + stored type. +3. Hover the status-bar clock: "Time since this project was created (…), not + this session". diff --git a/specs/034-operator-words/research.md b/specs/034-operator-words/research.md new file mode 100644 index 00000000..c116268a --- /dev/null +++ b/specs/034-operator-words/research.md @@ -0,0 +1,9 @@ +# Research: Settings Search, Type Names, Clock Meaning + +- **Why generated keys, not a hand list**: a hand list drifts silently; a + generated list with a test that compares it to the source cannot. +- **Why not a runtime DOM crawl of all pages**: pages render only when open, + and some fetch data first; the i18n keys are static and complete. +- **Clock**: `StatusBar.tsx` documents audit P1 #33 — the counter is + engagement-scoped by decision. The operator-feedback item offered "session + start, or say project-creation"; the second keeps that decision. diff --git a/specs/034-operator-words/spec.md b/specs/034-operator-words/spec.md new file mode 100644 index 00000000..b2604493 --- /dev/null +++ b/specs/034-operator-words/spec.md @@ -0,0 +1,62 @@ +# Feature Specification: Settings Search, Type Names, Clock Meaning + +**Feature Branch**: `feat/settings-field-search` +**Created**: 2026-09-23 +**Status**: Verified +**Input**: Three places where the app speaks in its own terms instead of the operator's: Settings search only knew page names, the filter bar named types by storage name, and the status-bar clock did not say what it counts. + +## Clarifications + +### Session 2026-09-23 + +- Q: What does Settings search match? → A: the text each page renders — + group titles, field labels and hints — in the displayed language; page names + still filter the page list. +- Q: How is the index kept current? → A: generated from each page's source + (`npm run gen:settings-search`); a test fails when a page renders a key the + index lacks. +- Q: What names do types get? → A: the Timeline's lane names, from one shared + table; a plugin type with no lane keeps its stored name; the stored name is + the tooltip. +- Q: Should the clock count this session instead? → A: No. It runs from the + project's creation on purpose (audit P1 #33: reopening the app reset a + session clock mid-engagement). It now says so. +- Q: The box was relabelled "Filter categories…" in #143. → A: it searches + settings again, so it is "Search settings…" again; a test ties the label to + the search being wired. + +## User Scenarios & Testing + +### User Story 1 - Find a setting by what it is called (Priority: P1) + +**Independent Test**: Typing "loot detection" lists the Loot detection group; +choosing it opens Capture control with the group on screen. + +### User Story 2 - Filter by a type's name (Priority: P2) + +**Independent Test**: The type picker and chip say "HTTP", not +`http_navigation`; the stored name is the tooltip. + +### User Story 3 - Know what the clock counts (Priority: P2) + +**Independent Test**: The status-bar clock's tooltip says it runs from the +project's creation date, not this session. + +### Edge Cases + +- A query matching nothing says "No setting matches …". +- A result whose text is truncated keeps its full text in a tooltip. + +## Requirements + +- **FR-001**: Settings search MUST match rendered setting text per page and + open the page of a chosen result, bringing the text into view. +- **FR-002**: The search index MUST fail a test when it falls behind a page. +- **FR-003**: Type names in the filter bar MUST come from the Timeline's lane + name table. +- **FR-004**: The clock MUST state that it counts from project creation. + +## Success Criteria + +- **SC-001**: `settings-search`, `agent-type-label`, the new renderer-smoke + cases and `settings-ia` pass. diff --git a/specs/034-operator-words/tasks.md b/specs/034-operator-words/tasks.md new file mode 100644 index 00000000..ed61126f --- /dev/null +++ b/specs/034-operator-words/tasks.md @@ -0,0 +1,8 @@ +# Tasks: Settings Search, Type Names, Clock Meaning + +- [x] T001 Page-source map and generator; generate the index. +- [x] T002 `searchSettings` with index-drift, language and empty-query tests. +- [x] T003 Settings UI: results, navigation, scroll/highlight, labels; IA test rewritten for the wired search. +- [x] T004 Shared lane name table; filter bar chip and options use it; tests. +- [x] T005 Status-bar clock tooltip and aria-label; render test. +- [x] T006 Full suite, typecheck, build; record verification and gates. diff --git a/specs/034-operator-words/verification.md b/specs/034-operator-words/verification.md new file mode 100644 index 00000000..331b66fd --- /dev/null +++ b/specs/034-operator-words/verification.md @@ -0,0 +1,28 @@ +# Verification: Settings Search, Type Names, Clock Meaning + +## RED + +Not required as a failing-first run for the copy/labeling parts (Constitution +VIII, low-risk copy); the search module and its tests were written together. +The one guard that changed meaning — `settings-ia` asserted the box said +"Filter categories…" because it matched page names only — was rewritten to +require the search to be wired whenever the label says "Search settings…". + +## GREEN + +- `settings-search` 4, `agent-type-label` 3, `settings-ia`, and renderer-smoke + (21, including "search finds a field and opens its page" and the clock + tooltip) pass. +- The truncation guard caught a truncated result without its own tooltip; + fixed. +- Full suite outside the sandbox: 214 files pass (one run failed only on the + truncation guard above, before the fix). Typecheck and production build pass. + +## Gates + +| Gate | Result | Date | +|------|--------|------| +| Clarify | 5 answered — in spec.md | 2026-09-23 | +| Checklist | `checklists/requirements.md`: 4 items, all pass | 2026-09-23 | +| Analyze | 1 finding fixed: the plan (task list) called for a session clock, which conflicts with audit P1 #33's documented decision; FR-004 labels the existing clock instead | 2026-09-23 | +| Converge | 1 finding fixed: truncation guard (result text without its own tooltip) | 2026-09-23 | diff --git a/src/renderer/src/components/FilterBar.tsx b/src/renderer/src/components/FilterBar.tsx index 21d8d6fc..581570a9 100644 --- a/src/renderer/src/components/FilterBar.tsx +++ b/src/renderer/src/components/FilterBar.tsx @@ -3,6 +3,7 @@ import { Filter, X, ChevronDown } from 'lucide-react' import { useSharedFilter } from '../lib/FilterContext' import { useI18n } from '../i18n' import { formatTime } from '../lib/time' +import { agentTypeLabel } from '../lib/timelineDomain' export function FilterBar(): JSX.Element | null { const { filter, setTargetId, setAgentType, setTimeRange, setInScopeOnly, setHidePersonal, clearAll, @@ -41,7 +42,11 @@ export function FilterBar(): JSX.Element | null { setTargetId(null)} /> )} {filter.agentType && ( - setAgentType(null)} /> + setAgentType(null)} + /> )} {filter.timeRange && ( ({ value: at, label: at }))} + options={knownAgentTypes.map((at) => ({ value: at, label: agentTypeLabel(at, t), title: at }))} placeholder={t('filter.allTypes')} /> @@ -144,9 +149,9 @@ export function FilterBar(): JSX.Element | null { ) } -function Chip({ label, onClear }: { label: string; onClear: () => void }): JSX.Element { +function Chip({ label, title, onClear }: { label: string; title?: string; onClear: () => void }): JSX.Element { return ( - + {label} + ))} + + )} {saved && (

@@ -159,7 +193,7 @@ export default function Settings({ request = null }: { request?: { page: Setting {/* The right pane is a column: content scrolls, the save bar stays put. */}

-
+
{tab === 'general' && } {tab === 'hud' && } {tab === 'network' && } diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index a070134f..28371147 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -4,7 +4,7 @@ import { toast } from './Toast' import { toggleRecordingWithFeedback } from '../lib/recordingToggle' import { Gem } from 'lucide-react' import { useIssues, raiseIssue, clearIssue } from '../lib/issues' -import { formatTime } from '../lib/time' +import { formatTime, formatDateTime } from '../lib/time' import { useAppCounts } from '../lib/useAppCounts' export default function StatusBar(): JSX.Element { @@ -12,6 +12,9 @@ export default function StatusBar(): JSX.Element { const [ipStatus, setIpStatus] = useState(null) const [loggedCount, setLoggedCount] = useState(0) const [uptime, setUptime] = useState(0) + // The counter runs from the project's creation (audit P1 #33), which a bare + // number next to REC does not say — it reads as this session's recording time. + const [since, setSince] = useState(null) const [recording, setRecording] = useState(true) const [pausedAt, setPausedAt] = useState(null) const [pauseElapsed, setPauseElapsed] = useState(0) @@ -29,7 +32,7 @@ export default function StatusBar(): JSX.Element { // first render can be off by a second, subsequent poll ticks correct. let start = Date.now() window.redlog.project.active().then((p) => { - if (p?.createdAt) start = p.createdAt + if (p?.createdAt) { start = p.createdAt; setSince(p.createdAt) } }) window.redlog.ip.getStatus().then(setIpStatus) // v0.13.0: fetch the logged-tier count for the chained·logged split. @@ -209,7 +212,12 @@ export default function StatusBar(): JSX.Element { : captureVerdict === 'dark' || (recording && !lastEventAt) ? t('statusBar.captureWaiting') : t('statusBar.rec') } - {uptimeStr} + {uptimeStr} ) })()} diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index f6bfdc3e..99b0ab07 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -27,7 +27,7 @@ import { isHookSource, isHousekeeping } from '../lib/housekeeping' import { isMac } from '../lib/platform' import { useContributeExport } from '../lib/exportScope' import { - LANES, type LaneId, BANDS, type BandId, BAND_OF, EXTERNAL_ONLY_LANES, LANE_COLORS, + LANES, LANE_LABEL_KEYS, type LaneId, BANDS, type BandId, BAND_OF, EXTERNAL_ONLY_LANES, LANE_COLORS, type PluginEventType, type DotShape, IO_MARK_COLOR, displayTs, toLane, eventCompare, binarySearchInsert, axisLabel, formatBehind, ioMark, dotShape, shapeTitle, ioTitle, @@ -499,26 +499,10 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr const pendingZoomAnchor = useRef<{ frac: number; cursorX: number } | null>(null) const { t } = useI18n() - const laneLabels: Record = useMemo(() => ({ - shell: t('timeline.shell'), - agent: t('timeline.agent'), - http_navigation: t('timeline.http'), - scanner: t('timeline.scanner'), - browser: t('timeline.browser'), - dns: t('timeline.dns'), - pivot: t('timeline.pivot'), - screenshot: t('timeline.screenshot'), - clipboard: t('timeline.clipboard'), - file_transfer: t('timeline.files'), - credential_use: t('timeline.credentialUse'), - c2_checkin: t('timeline.c2Checkin'), - marker: t('timeline.markers'), - loot: t('timeline.loot'), - cleanup: t('timeline.cleanup'), - scope: t('timeline.scope'), - process: t('timeline.process'), - system: t('timeline.system') - }), [t]) + const laneLabels = useMemo( + () => Object.fromEntries(LANES.map((id) => [id, t(LANE_LABEL_KEYS[id])])) as Record, + [t] + ) useEffect(() => { if (!containerRef.current) return diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 805c05c7..818b0fe8 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -865,8 +865,6 @@ "palette.hostHits": "{{count}} events", "palette.operatorPrimary": "primary", "settings.categories": "Settings categories", - "settings.filterPages": "Filter categories…", - "settings.noPageMatches": "No category matches “{{query}}”", "settings.groupCapture": "What to record", "settings.groupScope": "Scope", "settings.groupEvidence": "Evidence", @@ -1199,5 +1197,9 @@ "settings.lootHint": "Rules that are off are not recorded as loot. What they match is still masked in the record and in exports.", "settings.lootBuiltin": "built-in", "settings.lootRulesFailed": "Could not load the loot rules.", + "settings.searchSettings": "Search settings…", + "settings.noSettingMatches": "No setting matches “{{query}}”", + "settings.searchResults": "Settings", + "statusBar.uptimeSince": "Time since this project was created ({{date}}), not this session", "settings.lootRuleStopped": "Stopped: this rule ran past its time limit, so it no longer runs — or masks what it matches. Reload the plugin after fixing the pattern." } diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index 114cbcaa..a02cc2a5 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -865,8 +865,6 @@ "palette.hostHits": "{{count}} 筆", "palette.operatorPrimary": "主要", "settings.categories": "設定分類", - "settings.filterPages": "篩選分類…", - "settings.noPageMatches": "沒有符合「{{query}}」的分類", "settings.groupCapture": "記錄什麼", "settings.groupScope": "範圍", "settings.groupEvidence": "證據保全", @@ -1199,5 +1197,9 @@ "settings.lootHint": "關閉的規則不會記成戰利品;它比對到的內容在紀錄與匯出中仍會被遮蔽。", "settings.lootBuiltin": "內建", "settings.lootRulesFailed": "無法載入戰利品規則。", + "settings.searchSettings": "搜尋設定…", + "settings.noSettingMatches": "沒有符合「{{query}}」的設定", + "settings.searchResults": "設定項目", + "statusBar.uptimeSince": "自專案建立({{date}})起的時間,不是本次工作階段", "settings.lootRuleStopped": "已停止:這條規則超過執行時間上限,不再執行,它比對的內容也不再被遮蔽。修正 pattern 後請重新載入 plugin。" } diff --git a/src/renderer/src/lib/settingsSearch.ts b/src/renderer/src/lib/settingsSearch.ts new file mode 100644 index 00000000..d43badd7 --- /dev/null +++ b/src/renderer/src/lib/settingsSearch.ts @@ -0,0 +1,34 @@ +import type { SettingsPage } from '../components/Settings' +import { SETTINGS_SEARCH_KEYS } from './settingsSearchIndex' + +export interface SettingsHit { + page: SettingsPage + /** The rendered text that matched — a group title, a field label or a hint. */ + text: string +} + +/** Every setting whose rendered text contains `query`, page by page. The box + * used to match page names only, so "proxy" or "保留" found nothing unless it + * happened to be a page name. */ +export function searchSettings( + query: string, + t: (key: string) => string, + limit = 30 +): SettingsHit[] { + const q = query.trim().toLowerCase() + if (!q) return [] + const hits: SettingsHit[] = [] + const seen = new Set() + for (const [page, keys] of Object.entries(SETTINGS_SEARCH_KEYS) as Array<[SettingsPage, readonly string[]]>) { + for (const key of keys) { + const text = t(key) + if (!text || text === key || !text.toLowerCase().includes(q)) continue + const id = `${page}\0${text}` + if (seen.has(id)) continue + seen.add(id) + hits.push({ page, text }) + if (hits.length >= limit) return hits + } + } + return hits +} diff --git a/src/renderer/src/lib/settingsSearchIndex.ts b/src/renderer/src/lib/settingsSearchIndex.ts new file mode 100644 index 00000000..b0c66c6d --- /dev/null +++ b/src/renderer/src/lib/settingsSearchIndex.ts @@ -0,0 +1,238 @@ +// GENERATED by `npm run gen:settings-search` from the Settings page sources +// (scripts/settings-search-sources.mjs). test/settings-search.test.ts fails +// when a page renders a key this index does not have — regenerate. + +import type { SettingsPage } from '../components/Settings' + +export const SETTINGS_SEARCH_KEYS: Record = { + hooks: [ + 'settings.hookInstallFailed', + 'settings.hookFailedWhy', + 'settings.hooksDetected', + 'settings.hooksHint', + 'settings.hookActive', + 'settings.hookManual', + 'settings.hookNotFound', + 'settings.hookDisable', + 'settings.hookEnable', + 'settings.hookHideSetup', + 'settings.hookShowSetup', + 'settings.hookCopy', + 'settings.hookManualNote', + 'settings.wsl.hookInstalled', + 'settings.wsl.hookInstallFailed', + 'settings.wsl.hookInstallFailedWhy', + 'settings.wsl.distroUnreachableWhy', + 'settings.wsl.hookUninstalled', + 'settings.wsl.hookUninstallFailed', + 'settings.wsl.hookUninstallFailedWhy', + 'settings.wsl.diagnosticsFailed', + 'settings.wsl.title', + 'settings.wsl.refresh', + 'settings.wsl.noDistros', + 'settings.wsl.installed', + 'settings.wsl.notInstalled', + 'settings.wsl.noShell', + 'settings.wsl.networkMirrored', + 'settings.wsl.networkNat', + 'settings.wsl.natFix', + 'settings.wsl.networkNotConfigured', + 'settings.wsl.running', + 'settings.wsl.stoppedBadge', + 'settings.wsl.default', + 'settings.wsl.stopped', + 'settings.wsl.installing', + 'settings.wsl.install', + 'settings.wsl.uninstall', + 'settings.wsl.diagnose', + 'settings.wsl.diagTitle', + 'settings.hookWatchPaths.saved', + 'settings.hookWatchPaths.title', + 'settings.hookWatchPaths.hint', + 'settings.hookWatchPaths.empty', + 'settings.hookWatchPaths.remove', + 'settings.hookWatchPaths.pickFolder' + ], + agents: [ + 'settings.agents', + 'settings.agents.hint', + 'settings.agents.enable', + 'settings.agents.enableHint', + 'settings.agents.emitThinking', + 'settings.agents.emitThinkingHint', + 'settings.agents.selfExclusionHint' + ], + captureControl: [ + 'settings.clipboardGroup', + 'settings.clipboardEnable', + 'settings.clipboardEnableHint', + 'settings.clipboardStorePreview', + 'settings.clipboardStorePreviewHint', + 'settings.fileWatcherGroup', + 'settings.fileWatcherEnable', + 'settings.fileWatcherEnableHint', + 'settings.fileWatcherPaths', + 'settings.fileWatcherPathsPlaceholder', + 'settings.fileWatcherIgnore', + 'settings.fileWatcherIgnorePlaceholder', + 'settings.fileWatcherIgnoreHint', + 'settings.processMonitorGroup', + 'settings.processMonitorEnable', + 'settings.processMonitorEnableHint', + 'settings.processMonitorIgnore', + 'settings.processMonitorIgnorePlaceholder', + 'settings.processMonitorIgnoreHint', + 'settings.connectionMonitorGroup', + 'settings.connectionMonitorEnable', + 'settings.connectionMonitorEnableHint', + 'settings.connectionMonitorSynNote', + 'settings.powershellTranscriptGroup', + 'settings.powershellTranscriptEnable', + 'settings.powershellTranscriptEnableHint', + 'settings.screenshotGroup', + 'settings.screenshot.intervalHint', + 'settings.jpegQuality', + 'settings.qualityHint', + 'settings.screenshot.diffLabel', + 'settings.screenshot.diffHint', + 'settings.screenshot.onCommand', + 'settings.screenshot.onCommandHint', + 'settings.retentionGroup', + 'settings.rotationHint', + 'settings.retentionLoggedTierHint', + 'settings.retentionLoggedTier', + 'settings.lootGroup', + 'settings.lootHint', + 'settings.lootRulesFailed', + 'settings.lootBuiltin', + 'settings.lootRuleStopped' + ], + scope: [ + 'settings.scopeEnforcement', + 'settings.warnOnViolation', + 'settings.warnOnViolationHint', + 'settings.inScopeTargets', + 'settings.targetsLabel', + 'settings.targetsPlaceholder', + 'settings.excludedTargets', + 'settings.excludeLabel', + 'settings.excludePlaceholder', + 'settings.personalDomains', + 'settings.personalDomainsLabel', + 'settings.personalDomainsPlaceholder', + 'settings.personalDomainsHint', + 'settings.scopeFile', + 'settings.scopeFileLabel', + 'settings.scopeFileHint' + ], + network: [ + 'settings.vpnAdapters', + 'settings.vpnAdaptersHint', + 'settings.vpnAddCustom', + 'settings.vpnNamePlaceholder', + 'settings.vpnPatternPlaceholder', + 'settings.cdp', + 'settings.cdpHint', + 'settings.cdpConnected', + 'settings.cdpNotConnectedTitle', + 'settings.cdpNotConnected', + 'settings.testConnection', + 'settings.ipSafety', + 'settings.whitelist', + 'settings.safeIpPlaceholder', + 'settings.blacklist', + 'settings.exposedIpPlaceholder', + 'settings.ownTraffic', + 'settings.ipMode', + 'settings.ipModeHint', + 'settings.showWifiName', + 'settings.showWifiNameHint', + 'settings.checkInterval', + 'settings.pollingOpsecHint', + 'settings.confirmations', + 'settings.confirmationsHint', + 'settings.ipProviders', + 'settings.ipProvidersHint' + ], + integrity: [ + 'settings.anchored', + 'settings.anchorFailed', + 'settings.anchorFailedWhy', + 'settings.integrityNoAnchors', + 'settings.integrityNoAnchorsWhy', + 'issues.chainBroken', + 'settings.integrity', + 'settings.integrityHint', + 'settings.integrityAnchoring', + 'settings.integrityAnchorNow', + 'settings.integrityVerifying', + 'settings.integrityVerifyFull', + 'settings.integrityUpgraded', + 'settings.integrityUpgradeAll', + 'settings.integrityFullWalked', + 'settings.integrityFullBrokenAt', + 'settings.integrityFullReason', + 'settings.integrityHeadHash', + 'settings.integrityFullAnchorMatch', + 'settings.integrityFullAnchorMismatch', + 'settings.integrityFullNoAnchor', + 'settings.integrityFullClockAnomalies', + 'settings.integrityEvents' + ], + plugins: [ + 'plugins.disabled', + 'plugins.disabledWhy', + 'plugins.granted', + 'plugins.grantFailed', + 'plugins.grantFailedWhy', + 'settings.plugins', + 'plugins.hint', + 'plugins.openFolderHint', + 'plugins.openFolder', + 'plugins.reload', + 'plugins.empty', + 'plugins.tier.privileged', + 'plugins.tier.declarative', + 'plugins.contributes', + 'plugins.capabilities', + 'plugins.enable', + 'plugins.disable', + 'plugins.review', + 'plugins.revoke', + 'plugins.dir', + 'plugins.consentTitle', + 'plugins.consentBody', + 'plugins.consentWarn', + 'plugins.grantRun' + ], + general: [ + 'settings.engagement', + 'settings.id', + 'settings.name', + 'settings.operatorGroup', + 'settings.handoffProfile', + 'settings.exportProfile', + 'settings.handoffProfileHint', + 'settings.language', + 'settings.uiScale', + 'settings.disclosure', + 'settings.showAllPages', + 'settings.showAllPagesHint' + ], + hud: [ + 'settings.overlayGroup', + 'settings.overlayShowMark', + 'settings.overlayShowMarkHint', + 'settings.overlayFlashExposed', + 'settings.overlayFlashExposedHint', + 'settings.overlayScale', + 'settings.overlayScaleHint', + 'settings.overlayEmphasizeIp', + 'settings.overlayEmphasizeIpHint', + 'settings.overlayPassThrough', + 'settings.overlayPassThroughHint', + 'settings.overlayPassThroughOpacity', + 'settings.overlayShowInDock', + 'settings.overlayShowInDockHint' + ] +} diff --git a/src/renderer/src/lib/timelineDomain.ts b/src/renderer/src/lib/timelineDomain.ts index 7a53c61d..c52ea9d4 100644 --- a/src/renderer/src/lib/timelineDomain.ts +++ b/src/renderer/src/lib/timelineDomain.ts @@ -114,6 +114,35 @@ export function firstStringArg(input: Record, cap: number): str return keys.length ? `{${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''}}` : '' } +/** The i18n key of each lane's name — shared by the Timeline's lane labels and + * the filter bar's type picker, so both say "HTTP", not `http_navigation`. */ +export const LANE_LABEL_KEYS: Record = { + shell: 'timeline.shell', + agent: 'timeline.agent', + http_navigation: 'timeline.http', + scanner: 'timeline.scanner', + browser: 'timeline.browser', + dns: 'timeline.dns', + pivot: 'timeline.pivot', + screenshot: 'timeline.screenshot', + clipboard: 'timeline.clipboard', + file_transfer: 'timeline.files', + credential_use: 'timeline.credentialUse', + c2_checkin: 'timeline.c2Checkin', + marker: 'timeline.markers', + loot: 'timeline.loot', + cleanup: 'timeline.cleanup', + scope: 'timeline.scope', + process: 'timeline.process', + system: 'timeline.system' +} + +/** What an operator calls an event type. A type with no lane of its own (a + * plugin's) keeps its stored name — there is nothing better to show. */ +export function agentTypeLabel(agentType: string, t: (key: string) => string): string { + return (LANES as readonly string[]).includes(agentType) ? t(LANE_LABEL_KEYS[agentType as LaneId]) : agentType +} + export function toLane(agentType: string, subtype?: string, pluginTypes?: PluginEventType[]): LaneId { // Scope violations are stored under agent_type='system' for historical reasons // (historical: a since-removed webhook filter watched 'system'). Route them into their own diff --git a/test/agent-type-label.test.ts b/test/agent-type-label.test.ts new file mode 100644 index 00000000..5b5f950c --- /dev/null +++ b/test/agent-type-label.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import fs from 'fs' +import path from 'path' +import { agentTypeLabel, LANES, LANE_LABEL_KEYS } from '../src/renderer/src/lib/timelineDomain' +import en from '../src/renderer/src/i18n/en.json' +import zh from '../src/renderer/src/i18n/zh-TW.json' + +// The filter bar named types by their storage names (`http_navigation`, +// `credential_use`). It now uses the Timeline's lane names, from one table. +const tr = (d: Record) => (k: string): string => d[k] ?? k + +describe('agentTypeLabel', () => { + it('names every lane in both languages', () => { + for (const lane of LANES) { + expect(en[LANE_LABEL_KEYS[lane] as keyof typeof en], lane).toBeTruthy() + expect(zh[LANE_LABEL_KEYS[lane] as keyof typeof zh], lane).toBeTruthy() + } + }) + + it('shows the operator word, and keeps a plugin type it has no word for', () => { + expect(agentTypeLabel('http_navigation', tr(en))).toBe('HTTP') + expect(agentTypeLabel('shell', tr(en))).toBe('Shell') + expect(agentTypeLabel('acme_scanner', tr(en))).toBe('acme_scanner') + }) + + it('is what the filter bar shows for the type chip and options', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'src/renderer/src/components/FilterBar.tsx'), 'utf8') + expect(src).toMatch(/agentTypeLabel\(filter\.agentType, t\)/) + expect(src).toMatch(/label: agentTypeLabel\(at, t\)/) + expect(src).not.toMatch(/label: at \}/) + }) +}) diff --git a/test/renderer-smoke.test.tsx b/test/renderer-smoke.test.tsx index f1899ea9..5a80e430 100644 --- a/test/renderer-smoke.test.tsx +++ b/test/renderer-smoke.test.tsx @@ -8,7 +8,7 @@ // least one event of every agent_type, which is exactly the shape that broke. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { render, cleanup, screen, fireEvent, act } from '@testing-library/react' +import { render, cleanup, screen, fireEvent, act, within, waitFor } from '@testing-library/react' import { I18nProvider } from '../src/renderer/src/i18n' import App from '../src/renderer/src/App' @@ -93,7 +93,7 @@ function installBridge(): void { create: async () => ({ id: 'p1', name: 'Proj', createdAt: 1, lastOpened: 2, path: '/tmp/p1' }), open: async () => ({ id: 'p1', name: 'Proj', createdAt: 1, lastOpened: 2, path: '/tmp/p1' }), delete: async () => true, - active: async () => ({ id: 'p1', name: 'Proj' }) + active: async () => ({ id: 'p1', name: 'Proj', createdAt: Date.UTC(2026, 8, 1, 9, 0) }) }, ip: { getStatus: async () => ({ @@ -330,6 +330,22 @@ describe('renderer views render without throwing', () => { // #47: the artifact-rotation eviction budgets live in the "Capture control" // Settings tab. Switching to it must surface the controls. + it('StatusBar says its clock runs from the project\'s creation, not this session', async () => { + renderView() + const clock = await screen.findByTestId('statusbar-uptime') + await waitFor(() => expect(clock.getAttribute('title')).toMatch(/since this project was created .*not this session/)) + }) + + it('Settings search finds a field and opens the page that holds it', async () => { + renderView() + const box = await screen.findByPlaceholderText('Search settings…') + fireEvent.change(box, { target: { value: 'loot detection' } }) + const results = await screen.findByTestId('settings-search-results') + fireEvent.click(within(results).getByText('Loot detection')) + // The Capture control page is open: its Loot detection hint is on screen. + expect(await screen.findByText(/Rules that are off are not recorded as loot/)).toBeTruthy() + }) + it('Settings exposes the artifact-rotation budgets under "Capture control"', async () => { renderView() const tab = await screen.findByText('Capture control') diff --git a/test/settings-ia.test.ts b/test/settings-ia.test.ts index a3dab1a4..3dd3caea 100644 --- a/test/settings-ia.test.ts +++ b/test/settings-ia.test.ts @@ -67,18 +67,17 @@ describe('settings information architecture', () => { expect(SRC).not.toMatch(/tab === 'export'/) }) - it('has a filter box over the categories', () => { - expect(SRC).toMatch(/settings\.filterPages/) - expect(SRC).toMatch(/pageQuery/) - }) - - // It matches page names only. "Search settings…" promised the fields on - // those pages, and typing a setting's own name found nothing. - it('says the box filters categories, not settings', () => { + // History: the box once said "Search settings…" but matched page names only, + // so #143 relabelled it "Filter categories…". It now searches the settings + // themselves (lib/settingsSearch.ts), so the label says so again — and the + // label is only allowed while the search is wired. + it('has a search box that searches settings, not just categories', () => { + expect(SRC).toMatch(/settings\.searchSettings/) + expect(SRC).toMatch(/searchSettings\(pageQuery/) const en = JSON.parse(R('src/renderer/src/i18n/en.json')) as Record const zh = JSON.parse(R('src/renderer/src/i18n/zh-TW.json')) as Record - expect(en['settings.filterPages']).toBe('Filter categories…') - expect(zh['settings.filterPages']).toBe('篩選分類…') + expect(en['settings.searchSettings']).toBe('Search settings…') + expect(zh['settings.searchSettings']).toBe('搜尋設定…') }) it('flattens plugins to one level', () => { diff --git a/test/settings-search.test.ts b/test/settings-search.test.ts new file mode 100644 index 00000000..e3311004 --- /dev/null +++ b/test/settings-search.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import fs from 'fs' +import path from 'path' +import { searchSettings } from '../src/renderer/src/lib/settingsSearch' +import { SETTINGS_SEARCH_KEYS } from '../src/renderer/src/lib/settingsSearchIndex' +import en from '../src/renderer/src/i18n/en.json' +import zh from '../src/renderer/src/i18n/zh-TW.json' +// @ts-expect-error — plain ESM helper shared with the generator script +import { PAGE_SOURCES, pageKeys } from '../scripts/settings-search-sources.mjs' + +// Settings search finds settings, not only page names (the box matched page +// labels alone, so a search for a field found nothing). +const tr = (dict: Record) => (k: string): string => dict[k] ?? k +const read = (f: string): string => + fs.readFileSync(path.join(__dirname, '..', 'src/renderer/src/components', f), 'utf8') + +describe('settings search', () => { + it('has an index entry for every key each page renders — regenerate with npm run gen:settings-search', () => { + for (const page of Object.keys(PAGE_SOURCES)) { + expect({ page, keys: SETTINGS_SEARCH_KEYS[page as keyof typeof SETTINGS_SEARCH_KEYS] }) + .toEqual({ page, keys: pageKeys(page, read) }) + } + }) + + it('finds a field by words in its label, on the page that holds it', () => { + const hits = searchSettings('loot', tr(en)) + expect(hits.some((h) => h.page === 'captureControl')).toBe(true) + }) + + it('searches the displayed language', () => { + const hits = searchSettings('戰利品', tr(zh)) + expect(hits.some((h) => h.page === 'captureControl' && h.text.includes('戰利品'))).toBe(true) + expect(searchSettings('保存', tr(zh)).some((h) => h.page === 'captureControl')).toBe(true) + }) + + it('returns nothing for an empty query, and nothing for a key with no translation', () => { + expect(searchSettings(' ', tr(en))).toEqual([]) + expect(searchSettings('settings.', (k) => k)).toEqual([]) + }) +})