Skip to content
Merged
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
7 changes: 4 additions & 3 deletions electron/ai-edition/chat-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,12 @@ describe("runTimelineOperation", () => {
// state (projectsRoot, the per-project write queue), so no object literal can
// stand in for it. Subclassing keeps the stub a real DocumentService while
// replacing the only two methods runTimelineOperation calls with in-memory
// versions — nothing here touches the filesystem, so projectsRoot is never
// read and no directory is created.
// versions — nothing here touches the filesystem, so neither projectsRoot nor
// the media-links directory is ever read and no directory is created.
class StubDocumentService extends DocumentService {
constructor(readonly file: { stored: AxcutDocument | undefined }) {
super(path.join(tmpdir(), "openscreen-chat-service-test-unused"));
const unused = path.join(tmpdir(), "openscreen-chat-service-test-unused");
super(unused, unused);
}

override async getProject(): Promise<AxcutDocument> {
Expand Down
78 changes: 75 additions & 3 deletions electron/ai-edition/document-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AxcutDocument } from "../../src/lib/ai-edition/schema";
import type { AxcutAsset, AxcutDocument } from "../../src/lib/ai-edition/schema";
import { axcutSchemaVersion } from "../../src/lib/ai-edition/schema";
import { registerMediaLinks } from "../media/mediaLinksRegistry";
import { DocumentNotFoundError, DocumentService, ProjectFileError } from "./document-service";

async function makeTempDir(): Promise<string> {
Expand All @@ -13,15 +14,18 @@ async function makeTempDir(): Promise<string> {

describe("DocumentService", () => {
let tempDir: string;
let mediaDir: string;
let service: DocumentService;

beforeEach(async () => {
tempDir = await makeTempDir();
service = new DocumentService(tempDir);
mediaDir = await makeTempDir();
service = new DocumentService(tempDir, mediaDir);
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
await fs.rm(mediaDir, { recursive: true, force: true });
});

describe("createProject", () => {
Expand Down Expand Up @@ -63,6 +67,74 @@ describe("DocumentService", () => {
await expect(service.getProject("../etc/passwd")).rejects.toBeInstanceOf(ProjectFileError);
await expect(service.getProject("proj/with/slash")).rejects.toBeInstanceOf(ProjectFileError);
});

// Issue #212 — a project authored on another machine opens with every asset
// pointing at a path that does not exist here. The relink runs on this read,
// not on import, so a document already saved broken still recovers.
describe("relinking moved media", () => {
const stalePath = "C:\\Users\\demo\\recording-42.mp4";
const staleWebcamPath = "C:\\Users\\demo\\recording-42-webcam.mp4";
const screenBytes = "screen bytes";
let screenPath: string;
let webcamPath: string;
let logged: string[];

beforeEach(async () => {
screenPath = path.join(mediaDir, "recording-42.mp4");
webcamPath = path.join(mediaDir, "recording-42-webcam.mp4");
await fs.writeFile(screenPath, screenBytes, "utf8");
await fs.writeFile(webcamPath, "webcam bytes", "utf8");
await registerMediaLinks(mediaDir, screenPath, { webcamVideoPath: webcamPath });
logged = [];
const record = (...args: unknown[]) => {
logged.push(args.join(" "));
};
vi.spyOn(console, "log").mockImplementation(record);
vi.spyOn(console, "warn").mockImplementation(record);
});

afterEach(() => {
vi.restoreAllMocks();
});

async function writeStaleProject(sizeBytes: number | undefined): Promise<string> {
const doc = await service.createProject("Moved media");
const asset: AxcutAsset = {
id: "asset_moved",
kind: "video",
label: "recording-42.mp4",
originalPath: stalePath,
sizeBytes,
cameraTrack: { sourcePath: staleWebcamPath, startMs: 0, offsetMs: 0, visible: true },
};
await service.saveProject({
...doc,
assets: [asset],
project: { ...doc.project, primaryAssetId: asset.id },
});
return doc.project.id;
}

it("repoints screen and webcam paths at the registry's copies", async () => {
const projectId = await writeStaleProject(Buffer.byteLength(screenBytes));
const loaded = await service.getProject(projectId);
expect(loaded.assets[0]?.originalPath).toBe(screenPath);
expect(loaded.assets[0]?.cameraTrack?.sourcePath).toBe(webcamPath);
// The renderer saves what it is handed, so a rewrite must be traceable.
expect(logged.join("\n")).toContain(screenPath);
});

it("leaves the paths alone when the document recorded no file size", async () => {
// Every v1.7-migrated document is in this state: only addAsset records a
// size. Matching on the basename alone would hand this project a
// different recording — and that recording's webcam — without a word.
const projectId = await writeStaleProject(undefined);
const loaded = await service.getProject(projectId);
expect(loaded.assets[0]?.originalPath).toBe(stalePath);
expect(loaded.assets[0]?.cameraTrack?.sourcePath).toBe(staleWebcamPath);
expect(logged.join("\n")).toContain(stalePath);
});
});
});

describe("listProjects", () => {
Expand Down Expand Up @@ -91,7 +163,7 @@ describe("DocumentService", () => {

// A fresh service (new process) must still surface and load it, renaming
// the file across in the process.
const fresh = new DocumentService(tempDir);
const fresh = new DocumentService(tempDir, mediaDir);
const summaries = await fresh.listProjects();
expect(summaries.map((s) => s.id)).toEqual([created.project.id]);
await expect(fresh.getProject(created.project.id)).resolves.toMatchObject({
Expand Down
22 changes: 20 additions & 2 deletions electron/ai-edition/document-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
documentSchema,
migrateRawDocumentToCurrent,
} from "../../src/lib/ai-edition/schema";
import { relinkProjectMedia } from "../media/projectMediaRelinker";

const PROJECT_FILE_EXTENSION = ".openscreen";
// Older builds stored these same v3/v4 AxcutDocuments under `.axcut`. We read
Expand Down Expand Up @@ -86,6 +87,8 @@ function safeProjectId(raw: string): string {
// `documentSchema.parse` is now a pure v6 validator — every JSON-read path
// (list, get, future bulk-export) must run the upgrader chain first via this
// helper so the in-memory parse is a single `z.literal(6)` + shape check.
// `getProject` spells the same two steps out inline because it relinks moved
// media between them; keep the order (upgrade, then validate) in step.
function parseLoadedDocument(raw: string): AxcutDocument {
return documentSchema.parse(migrateRawDocumentToCurrent(JSON.parse(raw)));
}
Expand Down Expand Up @@ -113,12 +116,17 @@ async function renameWithRetry(from: string, to: string): Promise<void> {

export class DocumentService {
private readonly projectsRoot: string;
private readonly mediaRegistryDir: string;
private legacyMigrationDone = false;
/** Tail of the in-flight save chain per project id — see writeProject. */
private readonly writeQueues = new Map<string, Promise<void>>();

constructor(projectsRoot: string) {
// `mediaRegistryDir` is where the media-links registry file lives
// (RECORDINGS_DIR in production) — see getProject. Injected for the same
// reason as `projectsRoot`: this module stays free of any `electron` import.
constructor(projectsRoot: string, mediaRegistryDir: string) {
this.projectsRoot = projectsRoot;
this.mediaRegistryDir = mediaRegistryDir;
}

async ensureProjectsDir(): Promise<void> {
Expand Down Expand Up @@ -223,7 +231,17 @@ export class DocumentService {
);
}
}
return parseLoadedDocument(raw);
// Relink here rather than in the .openscreen import handlers, because this
// is the one place every open funnels through — the project picker, the
// agent, and the auto-load-last-project effect on launch. A document whose
// media moved (or that was authored on another machine, issue #212) is
// otherwise re-read as broken on every subsequent open, and media that
// moves after the import is never noticed at all. The relink is applied to
// the upgraded JSON so `documentSchema.parse` still validates what we hand
// back, and it is not persisted from here: the renderer saves the document
// it was given, as it does for any other load-time repair.
const migrated = migrateRawDocumentToCurrent(JSON.parse(raw));
return documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir));
}

async createProject(title: string): Promise<AxcutDocument> {
Expand Down
21 changes: 17 additions & 4 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
readCursorTelemetryFile as readCursorTelemetryFileFrom,
} from "../media/cursorSidecar";
import { findMediaLinksByFingerprint, registerMediaLinks } from "../media/mediaLinksRegistry";
import { relinkProjectMedia } from "../media/projectMediaRelinker";
import {
type LinuxCaptureSourceKind,
LinuxNativeCaptureSession,
Expand Down Expand Up @@ -3544,9 +3545,18 @@ export function registerIpcHandlers(

const filePath = result.filePaths[0];
const content = await fs.readFile(filePath, "utf-8");
const project = JSON.parse(content);
const project = await relinkProjectMedia(JSON.parse(content), RECORDINGS_DIR);
Comment thread
EtienneLescot marked this conversation as resolved.
currentProjectPath = filePath;
setCurrentRecordingSessionState(await getApprovedProjectSession(project, filePath));
let session: RecordingSession | null = null;
try {
session = await getApprovedProjectSession(project, filePath);
} catch (sessionError) {
console.warn(
"[loadProjectFile] Could not approve session paths, proceeding without session:",
sessionError,
);
}
setCurrentRecordingSessionState(session);

return {
success: true,
Expand Down Expand Up @@ -3581,7 +3591,7 @@ export function registerIpcHandlers(
return { success: false, message: "File not found" };
}
const content = await fs.readFile(filePath, "utf-8");
const project = JSON.parse(content);
const project = await relinkProjectMedia(JSON.parse(content), RECORDINGS_DIR);
currentProjectPath = filePath;

// Approve session paths but tolerate failures (e.g. video moved outside trusted
Expand Down Expand Up @@ -3816,7 +3826,10 @@ export function registerIpcHandlers(
// race destroyed two real project files), so a second instance means a second
// queue racing for the same path: temp+rename still keeps the file valid, but
// a save can land under a concurrent one and be silently lost.
const aiEditionDocuments = new DocumentService(path.join(app.getPath("userData"), "projects"));
const aiEditionDocuments = new DocumentService(
path.join(app.getPath("userData"), "projects"),
RECORDINGS_DIR,
);

// LlmConfigStore is single-instance for a duller reason — its constructor does
// two sync readFileSync plus a safeStorage decrypt, and it was running on every
Expand Down
53 changes: 53 additions & 0 deletions electron/media/mediaLinksRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
computeFingerprint,
findMediaLinksByFingerprint,
findRelocatedMediaByStoredPath,
registerMediaLinks,
} from "./mediaLinksRegistry";

Expand Down Expand Up @@ -84,6 +85,58 @@ describe("mediaLinksRegistry", () => {
});

describe("resolution via fingerprint (moved/imported-elsewhere)", () => {
it("finds a registry-known recording from a stale cross-platform path", async () => {
const currentDir = path.join(tempDir, "current-machine");
await fs.mkdir(currentDir, { recursive: true });
const currentScreenPath = path.join(currentDir, "recording-42.mp4");
const webcamPath = path.join(currentDir, "recording-42-webcam.mp4");
await writeFileOfSize(currentScreenPath, 5_000, "s");
await writeFileOfSize(webcamPath, 3_000, "w");
await registerMediaLinks(tempDir, currentScreenPath, { webcamVideoPath: webcamPath });

const resolved = await findRelocatedMediaByStoredPath(
tempDir,
"C:\\Users\\demo\\recording-42.mp4",
5_000,
);
expect(resolved).toMatchObject({
screenVideoPath: currentScreenPath,
webcamVideoPath: webcamPath,
});
});

it("refuses to guess when multiple existing recordings match the stored name and size", async () => {
for (const [folder, fill] of [
["first", "a"],
["second", "b"],
] as const) {
const currentDir = path.join(tempDir, folder);
await fs.mkdir(currentDir, { recursive: true });
const screenPath = path.join(currentDir, "recording.mp4");
await writeFileOfSize(screenPath, 5_000, fill);
await registerMediaLinks(tempDir, screenPath, {
webcamVideoPath: `${screenPath}.webcam`,
});
}

await expect(
findRelocatedMediaByStoredPath(tempDir, "C:\\Users\\demo\\recording.mp4", 5_000),
).resolves.toBeNull();
});

it("rejects a registry candidate whose contents changed after registration", async () => {
const screenPath = path.join(tempDir, "recording-changed.mp4");
await writeFileOfSize(screenPath, 5_000, "a");
await registerMediaLinks(tempDir, screenPath, {
webcamVideoPath: `${screenPath}.webcam`,
});
await writeFileOfSize(screenPath, 5_001, "b");

await expect(
findRelocatedMediaByStoredPath(tempDir, "C:\\Users\\demo\\recording-changed.mp4", 5_000),
).resolves.toBeNull();
});

it("re-links a copy of the screen video at a brand new path with no sidecars", async () => {
const originalDir = await makeTempDir();
try {
Expand Down
57 changes: 57 additions & 0 deletions electron/media/mediaLinksRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,63 @@ export interface MediaLinksLookup {
cursorCaptureMode?: CursorCaptureMode;
}

export interface RelocatedMediaLookup extends MediaLinksLookup {
screenVideoPath: string;
}

function portableBasename(filePath: string): string {
return filePath.split(/[\\/]/).filter(Boolean).pop() ?? "";
}

/**
* Resolves a stored media path that no longer exists on this machine through
* the registry's last-known path. This is intentionally stricter than a plain
* basename lookup: `sizeBytes` — the size the project recorded for that file —
* must match the registered fingerprint, the candidate on disk must still match
* that fingerprint size, and ambiguous matches are rejected rather than guessing
* at the user's media.
*
* `sizeBytes` is not optional on purpose. A name-only match is worthless as a
* safety check — `recording.mp4` is the least distinctive name a screen recorder
* can produce — and repointing a project at unrelated footage (plus whatever
* webcam that footage was recorded with) is worse than leaving it visibly
* broken. A caller that has no recorded size has nothing to match on and must
* not relink at all.
*/
export async function findRelocatedMediaByStoredPath(
baseDir: string,
stalePath: string,
sizeBytes: number,
): Promise<RelocatedMediaLookup | null> {
const basename = portableBasename(stalePath).toLowerCase();
if (!basename) return null;
if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return null;

const registry = await readRegistry(baseDir);
const matches: MediaLinkEntry[] = [];

for (const entry of registry.entries) {
if (portableBasename(entry.lastKnownPath).toLowerCase() !== basename) continue;
if (entry.fingerprint.sizeBytes !== sizeBytes) continue;
try {
const current = await fs.stat(entry.lastKnownPath);
if (current.isFile() && current.size === entry.fingerprint.sizeBytes) matches.push(entry);
} catch {
// A stale registry entry is not a usable relocation candidate.
}
}

if (matches.length !== 1) return null;
const match = matches[0];
return {
screenVideoPath: match.lastKnownPath,
...(match.webcamVideoPath ? { webcamVideoPath: match.webcamVideoPath } : {}),
...(typeof match.webcamOffsetMs === "number" ? { webcamOffsetMs: match.webcamOffsetMs } : {}),
...(match.cursorTelemetryPath ? { cursorTelemetryPath: match.cursorTelemetryPath } : {}),
...(match.cursorCaptureMode ? { cursorCaptureMode: match.cursorCaptureMode } : {}),
};
}

/**
* Looks up `videoPath` in the registry by content fingerprint — used as the
* fallback when the file has no (or a stale) sidecar sitting next to it,
Expand Down
Loading
Loading