Template tests / tests (pull_request) Failing after 34s
Phase 1 of the improvement plan (PR 3 of the sequence). The docs claimed "fully offline"/"never talks to the network," but text-intel makes HTTP requests to a configurable Ollama host that could be remote, with no timeout, no cancellation, and no size limit; the Windows hook logged raw keystrokes into capture metadata that could then be sent to that host. Privacy — raw keystroke capture: - New capture.captureTypedText setting, default false. With it off, printable characters are never buffered in JS and the Windows keyboard hook never even emits them across the process boundary (the flag is threaded into the C#). Shortcut/navigation detection (Ctrl+T, Enter, …) is unaffected. AI network hardening (app/text-intel.js): - Every Ollama call goes through fetchJson with an AbortController deadline (ai.timeoutMs, default 60s): a dead endpoint fails fast instead of leaving UI actions pending forever. - Cancellation: in-flight requests are tracked and cancelInflight(guideId) aborts them; new ai:cancel IPC + api.ai.cancel are called when the editor closes, and shutdown cancels everything. - Bounded concurrency (2) for AI network work. - Screenshots are only attached when allowed (ai.attachScreenshots), the model is vision-capable, and the image is within ai.maxImageBytes — no more unbounded base64-expanded 4K bodies. Local-first host policy (core/text-intel.js): - New isLoopbackHost + validateOllamaHost. By default only a loopback Ollama endpoint is contacted; a remote host is refused with a clear message unless ai.allowRemoteHost is explicitly enabled. Blocked hosts are never contacted. Honest documentation: - README, package.json, and the welcome screen drop "fully offline"/"never talks to the network"/"Electron is the only dependency" for an accurate local-first contract that discloses the optional AI path and the bundled Tesseract OCR dependency. - New docs/PRIVACY.md details exactly what is collected locally and the one outbound (opt-in, loopback-by-default) AI feature. Tests: loopback/remote host matrix, remote-blocked-without-opt-in (and never contacted), remote-allowed-with-opt-in, request timeout, explicit cancel vs timeout, typed-text off-by-default vs opted-in, shortcut detection still works, and a source guard that the C# CHAR emission stays behind the opt-in. 224 unit tests pass; startup smoke and workflow E2E pass. Co-Authored-By: Claude Fable 5 <[email protected]>
152 lines
5.3 KiB
JavaScript
152 lines
5.3 KiB
JavaScript
'use strict';
|
|
|
|
const path = require('node:path');
|
|
const { writeJsonSync, readJsonIfExists, deepClone } = require('./util');
|
|
|
|
const DEFAULT_SETTINGS = {
|
|
schemaVersion: 1,
|
|
appearance: 'system', // system | light | dark
|
|
language: 'en',
|
|
spellcheck: true,
|
|
capture: {
|
|
delayMs: 0,
|
|
mode: 'fullscreen', // fullscreen | window | region
|
|
includeCursor: true,
|
|
clickMarker: true,
|
|
clickMarkerColor: '#E5484D',
|
|
hotkeyCapture: 'CommandOrControl+Shift+1',
|
|
hotkeyPauseResume: 'CommandOrControl+Shift+2',
|
|
captureOutsideClicks: true,
|
|
confirmSimpleCapture: false,
|
|
// Fallback trigger when click capture is unavailable: keep the old timer
|
|
// fallback by default, but let users switch to hotkey-only recordings.
|
|
fallbackTrigger: 'interval', // interval | hotkey
|
|
// Leading-edge click debounce (ms): clicks of the same button closer
|
|
// together than this collapse into one step, so accidental fast/double
|
|
// clicks don't each become a step. Clicks spaced further apart always
|
|
// register. Set to 0 to capture every click.
|
|
clickDebounceMs: 200,
|
|
autoIntervalSec: 5, // session fallback when click capture is unavailable
|
|
// Strict click timing: a step never uses a frame whose grab started
|
|
// after the click. Turn off only if captures are too slow to keep a
|
|
// pre-click frame buffered (re-enables the legacy slack heuristics).
|
|
strictClickFrames: true,
|
|
// Off-main-process frame recorder (hidden worker window sampling a
|
|
// desktop media stream). Falls back to the in-process loop when false
|
|
// or when streams cannot start on this desktop.
|
|
streamCapture: true,
|
|
frameSampleMs: 50, // stream backend sampling cadence (finer = fresher frames)
|
|
// Target the screen this many ms *before* each click. The hook fires on
|
|
// button-down but the UI/cursor often start reacting within a frame, and
|
|
// stream pixels lag slightly; a small lead keeps the saved screenshot
|
|
// clear of the click's onset. Raise it if screenshots still feel late.
|
|
clickLeadMs: 120,
|
|
// After the window hides at recording start, wait this long before the
|
|
// user is likely to click so the buffer holds frames of the now-visible
|
|
// screen rather than the just-dismissed app window.
|
|
postHideSettleMs: 150,
|
|
// Raw typed-text capture. When true, printable characters typed between
|
|
// captures are buffered and stored in step capture metadata (and can be
|
|
// sent to a configured AI host). This can record passwords or other
|
|
// secrets, so it is OFF by default and must be explicitly opted into.
|
|
// Shortcut detection (Ctrl+T, Enter, …) is unaffected — only raw
|
|
// character logging is gated here.
|
|
captureTypedText: false,
|
|
},
|
|
editor: {
|
|
focusedViewDefaultForNewSteps: false,
|
|
autoTitleTemplate: '[[Mode]] capture [[Time]]',
|
|
},
|
|
ai: {
|
|
enabled: false,
|
|
// Auto-document captured steps in the background when a session capture
|
|
// lands. Requires enabled + a reachable model.
|
|
autoDoc: false,
|
|
// Local-first: only a loopback Ollama endpoint is contacted unless this
|
|
// is explicitly turned on. Turning it on sends screenshots and text to
|
|
// the configured remote host.
|
|
allowRemoteHost: false,
|
|
// Attach the step screenshot to AI requests (only for vision-capable
|
|
// models). Turning this off keeps requests text-only.
|
|
attachScreenshots: true,
|
|
// Per-request network deadline (ms). A dead endpoint fails fast instead
|
|
// of leaving UI actions pending forever.
|
|
timeoutMs: 60000,
|
|
// Skip attaching a screenshot larger than this many bytes (pre-base64)
|
|
// to avoid multi-hundred-MB request bodies.
|
|
maxImageBytes: 12 * 1024 * 1024,
|
|
ollama: {
|
|
host: 'http://127.0.0.1:11434',
|
|
model: 'llama3.2:1b',
|
|
},
|
|
},
|
|
exports: {
|
|
previewStepCount: 3,
|
|
openFolderAfterExport: true,
|
|
lastOutputDirs: {}, // format -> dir
|
|
},
|
|
library: {
|
|
sortBy: 'updatedAt',
|
|
},
|
|
backups: {
|
|
automatic: true,
|
|
keepLast: 10,
|
|
everyNSaves: 25,
|
|
},
|
|
};
|
|
|
|
class Settings {
|
|
constructor(settingsDir) {
|
|
this.file = path.join(settingsDir, 'app-settings.json');
|
|
this.globalPlaceholdersFile = path.join(settingsDir, 'placeholders.json');
|
|
this.data = this.load();
|
|
}
|
|
|
|
load() {
|
|
const stored = readJsonIfExists(this.file, {});
|
|
return mergeDeep(deepClone(DEFAULT_SETTINGS), stored);
|
|
}
|
|
|
|
save() {
|
|
writeJsonSync(this.file, this.data);
|
|
return this.data;
|
|
}
|
|
|
|
get(keyPath) {
|
|
return keyPath.split('.').reduce((o, k) => (o == null ? undefined : o[k]), this.data);
|
|
}
|
|
|
|
set(keyPath, value) {
|
|
const keys = keyPath.split('.');
|
|
let obj = this.data;
|
|
for (const k of keys.slice(0, -1)) {
|
|
if (typeof obj[k] !== 'object' || obj[k] === null) obj[k] = {};
|
|
obj = obj[k];
|
|
}
|
|
obj[keys[keys.length - 1]] = value;
|
|
return this.save();
|
|
}
|
|
|
|
getGlobalPlaceholders() {
|
|
return readJsonIfExists(this.globalPlaceholdersFile, {});
|
|
}
|
|
|
|
setGlobalPlaceholders(values) {
|
|
writeJsonSync(this.globalPlaceholdersFile, values);
|
|
return values;
|
|
}
|
|
}
|
|
|
|
function mergeDeep(base, extra) {
|
|
for (const [k, v] of Object.entries(extra || {})) {
|
|
if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
|
|
mergeDeep(base[k], v);
|
|
} else {
|
|
base[k] = v;
|
|
}
|
|
}
|
|
return base;
|
|
}
|
|
|
|
module.exports = { Settings, DEFAULT_SETTINGS };
|