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
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,185 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports"
}
});

test("HTML Artifact materializes and opens with the operating system default app", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-artifact-ipc-"));
const presentationRoot = join(root, "presentations");
const content = Buffer.from("<!doctype html><button>Run interaction</button>");
const handlers = new Map<string, Handler>();
const openedPaths: string[] = [];
const artifact = previewArtifact({
name: "interactive.html",
kind: "html",
mimeType: "text/html",
sizeBytes: content.byteLength,
});

try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as Handler),
},
client: {
hostEpoch: "host-1",
async getArtifact() {
return artifact;
},
async streamArtifact(
_sessionId: string,
_artifactId: string,
writeChunk: (chunk: Uint8Array) => Promise<void>,
) {
await writeChunk(content);
return content.byteLength;
},
} as never,
mainWindowController: {} as never,
showItemInFolder: () => {
throw new Error("HTML artifacts must use openPath");
},
openPath: async (path) => {
openedPaths.push(path);
return "";
},
presentationRoot,
});

const open = handlers.get("app:openArtifactPath");
assert.ok(open);
assert.deepEqual(await open({}, "session-1", "artifact-1"), {
ok: true,
opened: "interactive.html",
});
assert.equal(openedPaths.length, 1);
assert.equal(await readFile(openedPaths[0]!, "utf8"), content.toString("utf8"));
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("HTML Artifact reveal uses Finder without invoking the default app", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-reveal-ipc-"));
const presentationRoot = join(root, "presentations");
const content = Buffer.from("<!doctype html><button>Run interaction</button>");
const handlers = new Map<string, Handler>();
const revealedPaths: string[] = [];
const artifact = previewArtifact({
name: "interactive.html",
kind: "html",
mimeType: "text/html",
sizeBytes: content.byteLength,
});

try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as Handler),
},
client: {
hostEpoch: "host-1",
async getArtifact() {
return artifact;
},
async streamArtifact(_sessionId: string, _artifactId: string, writeChunk: (chunk: Uint8Array) => Promise<void>) {
await writeChunk(content);
return content.byteLength;
},
} as never,
mainWindowController: {} as never,
showItemInFolder: (path) => revealedPaths.push(path),
openPath: async () => {
throw new Error("reveal must not invoke openPath");
},
presentationRoot,
});

const reveal = handlers.get("app:showArtifactInFolder");
assert.ok(reveal);
assert.deepEqual(await reveal({}, "session-1", "artifact-1"), {
ok: true,
opened: "interactive.html",
});
assert.equal(revealedPaths.length, 1);
assert.equal(await readFile(revealedPaths[0]!, "utf8"), content.toString("utf8"));
} finally {
await rm(root, { recursive: true, force: true });
}
});

for (const [label, openPath] of [
["returns an error", async () => "default app unavailable"],
["rejects", async () => { throw new Error("launcher unavailable"); }],
] as const) {
test(`HTML Artifact reports default-app failure when opener ${label}`, async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-open-failure-"));
const presentationRoot = join(root, "presentations");
const content = Buffer.from("<!doctype html><p>failure test</p>");
const handlers = new Map<string, Handler>();
let revealCalls = 0;
const artifact = previewArtifact({
name: "interactive.html",
kind: "html",
mimeType: "text/html",
sizeBytes: content.byteLength,
});
try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) },
client: {
hostEpoch: "host-1",
async getArtifact() { return artifact; },
async streamArtifact(_sessionId: string, _artifactId: string, writeChunk: (chunk: Uint8Array) => Promise<void>) {
await writeChunk(content);
return content.byteLength;
},
} as never,
mainWindowController: {} as never,
showItemInFolder: () => { revealCalls += 1; },
openPath,
presentationRoot,
});
const open = handlers.get("app:openArtifactPath");
assert.ok(open);
assert.deepEqual(await open({}, "session-1", "artifact-1"), { ok: false, reason: "open-failed" });
assert.equal(revealCalls, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}

test("HTML Artifact does not invoke an opener when materialization fails", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-html-materialize-failure-"));
const handlers = new Map<string, Handler>();
const content = Buffer.from("<!doctype html><p>failure test</p>");
const artifact = previewArtifact({ name: "interactive.html", kind: "html", mimeType: "text/html", sizeBytes: content.byteLength });
let openCalls = 0;
try {
registerRuntimeHostArtifactsIpc({
uiLocale: () => "en" as const,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) },
client: {
hostEpoch: "host-1",
async getArtifact() { return artifact; },
async streamArtifact() { throw new Error("source unavailable"); },
} as never,
mainWindowController: {} as never,
showItemInFolder: () => { throw new Error("must not reveal failed materialization"); },
openPath: async () => { openCalls += 1; return ""; },
presentationRoot: join(root, "presentations"),
});
const open = handlers.get("app:openArtifactPath");
assert.ok(open);
assert.deepEqual(await open({}, "session-1", "artifact-1"), { ok: false, reason: "open-failed" });
assert.equal(openCalls, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("Attachment byte IPC rejects preview-ineligible metadata before streaming", async () => {
for (const [overrides, reason] of [
[{ id: "artifact-large", sizeBytes: 2 * 1024 * 1024 + 1 }, "too_large"],
Expand Down
43 changes: 37 additions & 6 deletions apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface RuntimeHostArtifactsIpcDeps {
readonly client: DesktopRuntimeHostClient;
readonly mainWindowController: ReturnType<typeof createMainWindowController>;
readonly showItemInFolder: (path: string) => void;
readonly openPath?: (path: string) => Promise<string>;
readonly presentationRoot?: string;
}

Expand Down Expand Up @@ -82,6 +83,20 @@ export function registerRuntimeHostArtifactsIpc(
deps.client.deleteArtifact(sessionId, artifactId),
);
registerRuntimeHostAttachmentPreviewIpc(deps);
const materializePresentationArtifact = async (
sessionId: string,
artifactId: string,
artifact: Awaited<ReturnType<DesktopRuntimeHostClient['getArtifact']>>,
): Promise<string> => {
if (!artifact) throw new Error('Artifact is missing');
const path = join(
presentationRoot,
sessionId,
`${artifactId}-${sanitizeArtifactName(artifact.name)}`,
);
await materializeArtifact(deps.client, sessionId, artifactId, path, artifact.sizeBytes);
return path;
};
deps.ipcMain.handle(
"app:openArtifactPath",
async (_event, sessionId: string, artifactId: string) => {
Expand All @@ -90,12 +105,28 @@ export function registerRuntimeHostArtifactsIpc(
return { ok: false as const, reason: "missing" as const };
}
try {
const path = join(
presentationRoot,
sessionId,
`${artifactId}-${sanitizeArtifactName(artifact.name)}`,
);
await materializeArtifact(deps.client, sessionId, artifactId, path, artifact.sizeBytes);
const path = await materializePresentationArtifact(sessionId, artifactId, artifact);
if (artifact.kind === 'html' && deps.openPath) {
const error = await deps.openPath(path);
if (error) return { ok: false as const, reason: "open-failed" as const };
} else {
deps.showItemInFolder(path);
}
return { ok: true as const, opened: artifact.name };
} catch {
return { ok: false as const, reason: "open-failed" as const };
}
},
);
deps.ipcMain.handle(
"app:showArtifactInFolder",
async (_event, sessionId: string, artifactId: string) => {
const artifact = await deps.client.getArtifact(sessionId, artifactId);
if (!artifact) {
return { ok: false as const, reason: "missing" as const };
}
try {
const path = await materializePresentationArtifact(sessionId, artifactId, artifact);
deps.showItemInFolder(path);
return { ok: true as const, opened: artifact.name };
} catch {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,7 @@ function registerHostClientIpc(
client,
mainWindowController,
showItemInFolder: (path) => shell.showItemInFolder(path),
openPath: (path) => shell.openPath(path),
});
registerExternalAgentSetupIpc({ ipcMain: scopedIpc, client, presentation: oauthPresentation,
selectExecutable: async () => {
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,21 @@ export interface MakaBridge {
| 'open-failed';
}
>;
showArtifactInFolder(
sessionId: string,
artifactId: string,
): Promise<
| { ok: true; opened: string }
| {
ok: false;
reason:
| 'unknown-key'
| 'not-allowed'
| 'missing'
| 'not-a-directory'
| 'open-failed';
}
>;
saveArtifactAs(sessionId: string, artifactId: string): Promise<ArtifactSaveResult>;
};
diagnostics: {
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3665,6 +3665,18 @@ const makaBridge = {
> {
return invokeSessionRuntimeHost('app:openArtifactPath', sessionId, artifactId);
},
showArtifactInFolder(
sessionId: string,
artifactId: string,
): Promise<
| { ok: true; opened: string }
| {
ok: false;
reason: 'unknown-key' | 'not-allowed' | 'missing' | 'not-a-directory' | 'open-failed';
}
> {
return invokeSessionRuntimeHost('app:showArtifactInFolder', sessionId, artifactId);
},
saveArtifactAs(sessionId: string, artifactId: string): Promise<ArtifactSaveResult> {
return invokeSessionRuntimeHost('app:saveArtifactAs', sessionId, artifactId);
},
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/features/workbar/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export interface WorkbarArtifactsService {
sessionId: string,
artifactId: string,
): Promise<WorkbarOpenArtifactResult>;
showInFolder(
sessionId: string,
artifactId: string,
): Promise<WorkbarOpenArtifactResult>;
saveAs(sessionId: string, artifactId: string): Promise<ArtifactSaveResult>;
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/features/workbar/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function createFakeWorkbarServices(
readBinary: async () => ({ ok: false, reason: 'not_found' }),
delete: async () => undefined,
openPath: async () => ({ ok: false, reason: 'missing' }),
showInFolder: async () => ({ ok: false, reason: 'missing' }),
saveAs: async () => ({ ok: false, reason: 'canceled' }),
},
inspector: {
Expand Down
Loading