Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions electron/ai-edition/document-service.defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getEditorSettings } from "../../src/lib/ai-edition/store/editorSettings";
import { DEFAULT_PROJECT_APPEARANCE } from "../../src/lib/projectDefaults";
import { DocumentService } from "./document-service";

const dirs: string[] = [];
function temp() {
const root = mkdtempSync(path.join(os.tmpdir(), "openscreen-default-doc-"));
dirs.push(root);
return root;
}
afterEach(() => {
for (const root of dirs.splice(0)) rmSync(root, { recursive: true, force: true });
});

describe("DocumentService project appearance defaults", () => {
it("materializes the current defaults before the new project's first write", async () => {
const root = temp();
const projects = path.join(root, "projects");
const service = new DocumentService(projects, path.join(root, "media"), undefined, () => ({
...DEFAULT_PROJECT_APPEARANCE,
wallpaper: "#123456",
padding: 22,
}));
const created = await service.createProject("New recording or CLI project");
const onDisk = JSON.parse(
readFileSync(path.join(projects, `${created.project.id}.openscreen`), "utf8"),
);
expect(getEditorSettings(onDisk)).toMatchObject({ wallpaper: "#123456", padding: 22 });
expect(onDisk.assets).toEqual([]);
expect(onDisk.annotations).toEqual([]);
});

it("never reapplies changed defaults while opening an existing project", async () => {
const root = temp();
let padding = 11;
const service = new DocumentService(
path.join(root, "projects"),
path.join(root, "media"),
undefined,
() => ({ ...DEFAULT_PROJECT_APPEARANCE, padding }),
);
const created = await service.createProject("Existing");
padding = 44;
const reopened = await service.getProject(created.project.id);
expect(getEditorSettings(reopened).padding).toBe(11);
});
});
12 changes: 11 additions & 1 deletion electron/ai-edition/document-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
documentSchema,
migrateRawDocumentToCurrent,
} from "../../src/lib/ai-edition/schema";
import {
applyProjectAppearanceDefaults,
type ProjectAppearanceDefaults,
} from "../../src/lib/projectDefaults";
import { ensureDocumentExtensions } from "../media/extensionClip";
import { relinkProjectMedia } from "../media/projectMediaRelinker";

Expand Down Expand Up @@ -165,15 +169,18 @@ export class DocumentService {
* `electron` import. Optional so the tests and the CLI construct it as they always did.
*/
private readonly onProjectRead?: (document: AxcutDocument) => void;
private readonly loadProjectDefaults?: () => ProjectAppearanceDefaults;

constructor(
projectsRoot: string,
mediaRegistryDir: string,
onProjectRead?: (document: AxcutDocument) => void,
loadProjectDefaults?: () => ProjectAppearanceDefaults,
) {
this.projectsRoot = projectsRoot;
this.mediaRegistryDir = mediaRegistryDir;
this.onProjectRead = onProjectRead;
this.loadProjectDefaults = loadProjectDefaults;
}

async ensureProjectsDir(): Promise<void> {
Expand Down Expand Up @@ -299,10 +306,13 @@ export class DocumentService {
async createProject(title: string): Promise<AxcutDocument> {
await this.ensureProjectsDir();
const projectId = createId("proj");
const doc = createEmptyDocument({
const empty = createEmptyDocument({
projectId,
title: title?.trim() || "Untitled Project",
});
const doc = this.loadProjectDefaults
? applyProjectAppearanceDefaults(empty, this.loadProjectDefaults())
: empty;
await this.writeProject(doc);
return doc;
}
Expand Down
84 changes: 84 additions & 0 deletions electron/app-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_PROJECT_APPEARANCE } from "../src/lib/projectDefaults";
import { AppSettingsStore, DEFAULT_RECORDING_PREFERENCES } from "./app-settings";

const dirs: string[] = [];
const temp = () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-app-settings-"));
dirs.push(dir);
return dir;
};
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

describe("app settings store", () => {
it("migrates the auto-zoom-only shape and preserves unknown keys", () => {
const dir = temp();
const file = path.join(dir, "recording-settings.json");
writeFileSync(file, JSON.stringify({ autoZoomEnabled: false, future: { keep: true } }));
const store = new AppSettingsStore(dir);
expect(store.getSnapshot().recording.autoZoomEnabled).toBe(false);
store.setRecordingPreferences({ micEnabled: true, camDeviceName: "Camera A" });
expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({
future: { keep: true },
autoZoomEnabled: false,
micEnabled: true,
camDeviceName: "Camera A",
});
});

it("uses validated defaults for absent, corrupt, and invalid fields", () => {
const dir = temp();
const file = path.join(dir, "recording-settings.json");
const store = new AppSettingsStore(dir);
expect(store.getSnapshot().recording).toEqual(DEFAULT_RECORDING_PREFERENCES);
for (const raw of ["{broken", "[]", JSON.stringify({ micEnabled: "yes" })]) {
writeFileSync(file, raw);
expect(store.getSnapshot().recording.micEnabled).toBe(false);
}
});

it("stores and resets versioned appearance and recording setup", () => {
const dir = temp();
const store = new AppSettingsStore(dir);
const custom = { ...DEFAULT_PROJECT_APPEARANCE, wallpaper: "#010203", padding: 7 };
expect(store.setAppearanceDefaults(custom).appearance).toMatchObject({ custom: true });
store.setLastSource({
platform: "win32",
kind: "screen",
id: "screen:1",
name: "Display",
displayId: "1",
});
store.setRecordingPreferences({ micEnabled: true, micDeviceId: "mic" });
expect(store.resetRecordingSetup()).toMatchObject({
recording: DEFAULT_RECORDING_PREFERENCES,
lastSource: null,
});
expect(store.resetAppearanceDefaults().appearance).toEqual({
version: 1,
custom: false,
defaults: DEFAULT_PROJECT_APPEARANCE,
});
expect(
JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8")),
).toMatchObject({
projectAppearance: { version: 1, defaults: null },
});
});

it("rejects invalid or failed writes without changing the published durable value", () => {
const dir = temp();
const store = new AppSettingsStore(dir);
store.setRecordingPreferences({ micEnabled: true });
expect(() => store.setRecordingPreferences({ micEnabled: "yes" as never })).toThrow(TypeError);
expect(store.getSnapshot().recording.micEnabled).toBe(true);
const missing = new AppSettingsStore(path.join(dir, "missing"));
expect(() => missing.setRecordingPreferences({ micEnabled: true })).toThrow();
expect(missing.getSnapshot().recording.micEnabled).toBe(false);
});
});
217 changes: 217 additions & 0 deletions electron/app-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
DEFAULT_PROJECT_APPEARANCE,
type ProjectAppearanceDefaults,
parseProjectAppearanceDefaults,
} from "../src/lib/projectDefaults";
import type { CursorCaptureMode } from "../src/lib/recordingSession";

export interface RecordingPreferences {
micEnabled: boolean;
micDeviceId: string | null;
micDeviceName: string | null;
camEnabled: boolean;
camDeviceId: string | null;
camDeviceName: string | null;
systemAudioEnabled: boolean;
cursorCaptureMode: CursorCaptureMode;
autoZoomEnabled: boolean;
}

export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = {
micEnabled: false,
micDeviceId: null,
micDeviceName: null,
camEnabled: false,
camDeviceId: null,
camDeviceName: null,
systemAudioEnabled: false,
cursorCaptureMode: "editable-overlay",
autoZoomEnabled: true,
};

export interface RecordingSourceDescriptor {
platform: NodeJS.Platform;
kind: "screen" | "window";
id: string;
name: string;
displayId: string | null;
}

export interface AppSettingsSnapshot {
recording: RecordingPreferences;
lastSource: RecordingSourceDescriptor | null;
appearance: {
version: 1;
custom: boolean;
defaults: ProjectAppearanceDefaults;
};
}

type RawSettings = Record<string, unknown>;

function readRaw(userData: string): RawSettings {
try {
const value: unknown = JSON.parse(
readFileSync(path.join(userData, "recording-settings.json"), "utf8"),
);
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as RawSettings)
: {};
} catch {
return {};
}
}

function atomicWrite(userData: string, value: RawSettings): void {
const destination = path.join(userData, "recording-settings.json");
const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
renameSync(temporary, destination);
} finally {
rmSync(temporary, { force: true });
}
}

const bool = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback);
const nullableString = (value: unknown, fallback: string | null) =>
value === null || typeof value === "string" ? value : fallback;

function parseRecording(raw: RawSettings): RecordingPreferences {
return {
micEnabled: bool(raw.micEnabled, DEFAULT_RECORDING_PREFERENCES.micEnabled),
micDeviceId: nullableString(raw.micDeviceId, DEFAULT_RECORDING_PREFERENCES.micDeviceId),
micDeviceName: nullableString(raw.micDeviceName, DEFAULT_RECORDING_PREFERENCES.micDeviceName),
camEnabled: bool(raw.camEnabled, DEFAULT_RECORDING_PREFERENCES.camEnabled),
camDeviceId: nullableString(raw.camDeviceId, DEFAULT_RECORDING_PREFERENCES.camDeviceId),
camDeviceName: nullableString(raw.camDeviceName, DEFAULT_RECORDING_PREFERENCES.camDeviceName),
systemAudioEnabled: bool(
raw.systemAudioEnabled,
DEFAULT_RECORDING_PREFERENCES.systemAudioEnabled,
),
cursorCaptureMode:
raw.cursorCaptureMode === "system" || raw.cursorCaptureMode === "editable-overlay"
? raw.cursorCaptureMode
: DEFAULT_RECORDING_PREFERENCES.cursorCaptureMode,
autoZoomEnabled: bool(raw.autoZoomEnabled, DEFAULT_RECORDING_PREFERENCES.autoZoomEnabled),
};
}

function parseSource(value: unknown): RecordingSourceDescriptor | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
if (
(candidate.platform !== "win32" &&
candidate.platform !== "darwin" &&
candidate.platform !== "linux") ||
(candidate.kind !== "screen" && candidate.kind !== "window") ||
typeof candidate.id !== "string" ||
candidate.id.length === 0 ||
typeof candidate.name !== "string" ||
candidate.name.length === 0 ||
!(candidate.displayId === null || typeof candidate.displayId === "string")
) {
return null;
}
return candidate as unknown as RecordingSourceDescriptor;
}

function parseAppearance(raw: RawSettings): AppSettingsSnapshot["appearance"] {
const value = raw.projectAppearance;
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE };
}
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1 || candidate.defaults === null) {
return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE };
}
try {
return {
version: 1,
custom: true,
defaults: parseProjectAppearanceDefaults(candidate.defaults),
};
} catch {
return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE };
}
}

function validateRecordingPatch(patch: Partial<RecordingPreferences>): void {
const allowed = new Set(Object.keys(DEFAULT_RECORDING_PREFERENCES));
for (const [key, value] of Object.entries(patch)) {
if (!allowed.has(key)) throw new TypeError(`unknown recording preference: ${key}`);
if (value === undefined) continue;
if (key.endsWith("Enabled") && typeof value !== "boolean") {
throw new TypeError(`${key} must be a boolean`);
}
if (
(key.endsWith("DeviceId") || key.endsWith("DeviceName")) &&
value !== null &&
typeof value !== "string"
) {
throw new TypeError(`${key} must be a string or null`);
}
if (key === "cursorCaptureMode" && value !== "system" && value !== "editable-overlay") {
throw new TypeError("cursorCaptureMode is invalid");
}
}
}

export class AppSettingsStore {
constructor(private readonly userData: string) {}

getSnapshot(): AppSettingsSnapshot {
const raw = readRaw(this.userData);
return {
recording: parseRecording(raw),
lastSource: parseSource(raw.lastSource),
appearance: parseAppearance(raw),
};
}

setRecordingPreferences(patch: Partial<RecordingPreferences>): AppSettingsSnapshot {
validateRecordingPatch(patch);
const raw = readRaw(this.userData);
const current = parseRecording(raw);
const next = Object.fromEntries(
Object.entries(patch).filter(([, value]) => value !== undefined),
) as Partial<RecordingPreferences>;
atomicWrite(this.userData, { ...raw, ...current, ...next });
return this.getSnapshot();
}

setLastSource(source: RecordingSourceDescriptor | null): AppSettingsSnapshot {
if (source !== null && !parseSource(source)) throw new TypeError("last source is invalid");
const raw = readRaw(this.userData);
atomicWrite(this.userData, { ...raw, lastSource: source });
return this.getSnapshot();
}

setAppearanceDefaults(defaults: ProjectAppearanceDefaults): AppSettingsSnapshot {
const parsed = parseProjectAppearanceDefaults(defaults);
const raw = readRaw(this.userData);
atomicWrite(this.userData, {
...raw,
projectAppearance: { version: 1, defaults: parsed },
});
return this.getSnapshot();
}

resetAppearanceDefaults(): AppSettingsSnapshot {
const raw = readRaw(this.userData);
atomicWrite(this.userData, { ...raw, projectAppearance: { version: 1, defaults: null } });
return this.getSnapshot();
}

resetRecordingSetup(): AppSettingsSnapshot {
const raw = readRaw(this.userData);
atomicWrite(this.userData, {
...raw,
...DEFAULT_RECORDING_PREFERENCES,
lastSource: null,
});
return this.getSnapshot();
}
}
Loading
Loading