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
28 changes: 28 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { registerDiagnosticIpc } from "./ipc/diagnosticIpc";
import { DiagnosticLog } from "./services/DiagnosticLog";
import { UpdateController, type UpdateAdapter } from "./services/updates/UpdateController";
import { ManualReleaseAdapter } from "./services/updates/ManualReleaseAdapter";
import { registerMaterialIpc } from "./ipc/registerMaterialIpc";
import { textResponse } from "./services/fileResponse";
import { SettingsStore } from "./services/SettingsStore";
import { SkinRegistry } from "./services/SkinRegistry";
import { PixelSkinPackRegistry } from "./services/PixelSkinPackRegistry";
Expand All @@ -47,6 +49,8 @@ import { PluginSessions } from "./services/PluginSessions";
import { PluginCards } from "./services/PluginCards";
import { GithubAuthService } from "./services/GithubAuthService";
import { PluginMediaService } from "./services/PluginMediaService";
import { MaterialService } from "./services/materials/MaterialService";
import { MATERIAL_SCHEME } from "../shared/materials.ts";
import { PluginSecretsService } from "./services/PluginSecretsService";
import { ProviderSecretsService } from "./services/ProviderSecretsService";
import { listProviderDirectory, type ProviderDirectorySources } from "./services/providerDirectory";
Expand Down Expand Up @@ -126,6 +130,14 @@ protocol.registerSchemesAsPrivileged([
corsEnabled: true,
stream: true
}
},
{
scheme: MATERIAL_SCHEME,
privileges: {
standard: true,
secure: true,
stream: true
}
}
]);

Expand Down Expand Up @@ -161,6 +173,7 @@ let pluginSessions: PluginSessions | null = null;
let pluginCards: PluginCards | null = null;
let githubAuth: GithubAuthService | null = null;
let pluginMediaService: PluginMediaService | null = null;
let materialService: MaterialService | null = null;
let pluginSecretsService: PluginSecretsService | null = null;
let providerSecretsService: ProviderSecretsService | null = null;
let hermesHudService: HermesHudService | null = null;
Expand Down Expand Up @@ -761,6 +774,17 @@ async function initializeServices(ipc: IpcRegistrar): Promise<void> {
await Promise.all([browserReady, storesLoaded]);
pluginManager.registerTokenProvider(() => githubAuth!.getToken());
protocol.handle("canvastty-media", (request) => pluginMediaService!.protocolResponse(request));
materialService = new MaterialService({
userDataPath: app.getPath("userData"),
persist: () => settings.get().persistMaterials,
emit: (snapshot) => {
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send(IPC.materialsChanged, snapshot);
}
}
});
await materialService.load();
registerMaterialIpc(ipc, { materials: materialService, getMainWindow: () => mainWindow });
registerIpc(ipc, {
settings,
recheckProviderClis: async () => {
Expand Down Expand Up @@ -806,6 +830,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise<void> {
navigationBinding: next.canvasNavigationOverride
});
await terminalManager?.setSessionRestoreMode(next.sessionRestoreMode);
await materialService?.flush();
},
setCanvasNavigationShortcutCapture: (active) => {
if (active) browserService?.cancelCanvasNavigationGesture();
Expand Down Expand Up @@ -1128,6 +1153,8 @@ if (hasSingleInstanceLock) {
void app.whenReady()
.then(() => {
markMainBoot("appReady");
protocol.handle(MATERIAL_SCHEME, (request) => materialService?.protocolResponse(request)
?? Promise.resolve(textResponse("Materials are starting.", 503)));
return startApplication();
})
.catch((error) => {
Expand Down Expand Up @@ -1206,6 +1233,7 @@ async function shutdownServices(): Promise<void> {
if (browserService) await Promise.allSettled([browserService.dispose()]);
if (pluginServices) await Promise.allSettled([pluginServices.dispose()]);
if (pluginManager) await Promise.allSettled([pluginManager.dispose()]);
if (materialService) await Promise.allSettled([materialService.dispose()]);
await ptyExits;
diagnostics.record("info", "application", "shutdown.completed");
await diagnostics.flush();
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,7 @@ function isMainRenderer(
}
}

function assertMainRenderer(
export function assertMainRenderer(
event: IpcMainEvent | IpcMainInvokeEvent,
getMainWindow: () => BrowserWindow | null
): void {
Expand Down
144 changes: 144 additions & 0 deletions src/main/ipc/registerMaterialIpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { BrowserWindow, clipboard, dialog, shell } from "electron";
import type { IpcMainInvokeEvent, OpenDialogOptions } from "electron";
import type { MaterialsAddResult, Point } from "../../shared/contracts.ts";
import { IPC } from "../../shared/contracts.ts";
import type { MaterialService } from "../services/materials/MaterialService";
import { captureRejection, fileUrlPaths, plistPaths, textPaths, windowsFileNames } from "../services/materials/materialClipboard.ts";
import { isId } from "../services/materials/materialState.ts";
import { assertMainRenderer } from "./registerIpc";
import type { IpcRegistrar } from "./IpcReadinessGate";

const MAX_CLIPBOARD_PATHS = 16;

interface MaterialIpcDependencies {
materials: MaterialService;
getMainWindow(): BrowserWindow | null;
}

export function registerMaterialIpc(ipcMain: IpcRegistrar, { materials, getMainWindow }: MaterialIpcDependencies): void {

ipcMain.handle(IPC.materialsSnapshot, (event) => {
assertMainRenderer(event, getMainWindow);
return materials.snapshot();
});

ipcMain.handle(IPC.materialsAddPaths, (event, paths: unknown, point: unknown) => {
assertMainRenderer(event, getMainWindow);
if (!Array.isArray(paths)) throw new Error("File paths are required.");
return materials.addPaths(paths, point);
});

ipcMain.handle(IPC.materialsPick, async (event, point: unknown) => {
assertMainRenderer(event, getMainWindow);
const paths = await pickFiles(event, true);
return paths.length === 0 ? emptyResult() : materials.addPaths(paths, point);
});

ipcMain.handle(IPC.materialsPaste, (event, point: unknown) => {
assertMainRenderer(event, getMainWindow);
return pasteFromClipboard(materials, point);
});

ipcMain.on(IPC.materialsSetBounds, (event, id: unknown, bounds: unknown) => {
try {
assertMainRenderer(event, getMainWindow);
} catch {
return;
}
if (typeof id === "string") materials.setBounds(id, bounds);
});

ipcMain.on(IPC.materialsSetBoundsBatch, (event, entries: unknown) => {
try {
assertMainRenderer(event, getMainWindow);
} catch {
return;
}
if (Array.isArray(entries)) materials.setBoundsBatch(entries);
});

ipcMain.handle(IPC.materialsRemove, (event, id: unknown) => {
assertMainRenderer(event, getMainWindow);
return materials.remove(requireId(id));
});

ipcMain.handle(IPC.materialsReveal, (event, id: unknown) => {
assertMainRenderer(event, getMainWindow);
const location = materials.location(requireId(id));
if (location) shell.showItemInFolder(location);
});

ipcMain.handle(IPC.materialsRelink, async (event, id: unknown) => {
assertMainRenderer(event, getMainWindow);
const materialId = requireId(id);
const [path] = await pickFiles(event, false);
return path ? materials.relink(materialId, path) : { ok: false, reason: "cancelled" };
});

ipcMain.handle(IPC.materialsAcceptMove, (event, id: unknown) => {
assertMainRenderer(event, getMainWindow);
return materials.acceptMove(requireId(id));
});
}

async function pickFiles(event: IpcMainInvokeEvent, multiple: boolean): Promise<string[]> {
const owner = BrowserWindow.fromWebContents(event.sender);
const options: OpenDialogOptions = {
properties: multiple ? ["openFile", "multiSelections"] : ["openFile"]
};
const result = owner ? await dialog.showOpenDialog(owner, options) : await dialog.showOpenDialog(options);
return result.canceled ? [] : result.filePaths;
}

async function pasteFromClipboard(materials: MaterialService, point: unknown): Promise<MaterialsAddResult> {
const paths = clipboardPaths();
if (paths.length > 0) return materials.addPaths(paths, point);
const image = clipboard.readImage();
if (image.isEmpty()) return { added: [], existing: [], rejected: [{ name: "clipboard", reason: "empty-clipboard" }] };
const created = await materials.addCapture({
bytes: image.toPNG(),
name: `clipboard-${timestamp(new Date())}.png`,
mimeType: "image/png",
origin: { kind: "clipboard" },
point: point as Point,
natural: image.getSize()
});
return created.ok
? { added: [created.materialId], existing: [], rejected: [] }
: { added: [], existing: [], rejected: [{ name: "clipboard", reason: captureRejection(created.reason) }] };
}

function clipboardPaths(): string[] {
const listed = process.platform === "darwin"
? orElse(plistPaths(safeRead(() => clipboard.read("NSFilenamesPboardType"))), () => fileUrlPaths(safeRead(() => clipboard.read("public.file-url"))))
: process.platform === "win32"
? windowsFileNames(safeRead(() => clipboard.readBuffer("FileNameW"), Buffer.alloc(0)))
: fileUrlPaths(safeRead(() => clipboard.read("text/uri-list")));
return listed.length > 0 ? listed : textPaths(safeRead(() => clipboard.readText()), process.platform);
}

function orElse(paths: string[], fallback: () => string[]): string[] {
return paths.length > 0 ? paths : fallback();
}

function safeRead<T = string>(read: () => T, fallback = "" as T): T {
try {
return read() ?? fallback;
} catch {
return fallback;
}
}

function timestamp(date: Date): string {
const pad = (value: number): string => String(value).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}

function requireId(value: unknown): string {
if (!isId(value)) throw new Error("Material id is required.");
return value;
}

function emptyResult(): MaterialsAddResult {
return { added: [], existing: [], rejected: [] };
}
65 changes: 3 additions & 62 deletions src/main/services/PluginMediaService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createReadStream } from "node:fs";
import {
lstat,
mkdir,
Expand All @@ -12,8 +11,8 @@ import {
} from "node:fs/promises";
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import { isPathInside } from "../../agent-runtime/path-inside.mjs";
import { Readable } from "node:stream";
import { randomUUID } from "node:crypto";
import { streamFile, textResponse } from "./fileResponse.ts";
import type {
PluginMediaLibrary,
PluginMediaTrack,
Expand All @@ -39,6 +38,7 @@ const AUDIO_MIME: Record<string, string> = {
};

const PLAYLIST_EXTENSIONS = new Set([".json", ".m3u", ".m3u8", ".pls"]);
const STREAM_HEADERS = { "access-control-allow-origin": "*" };

interface StoredLibrary {
id: string;
Expand Down Expand Up @@ -232,7 +232,7 @@ export class PluginMediaService {
const relativePath = safeRelativePath(parts.join("/"));
if (!(extname(relativePath).toLowerCase() in AUDIO_MIME)) return textResponse("Track is unavailable.", 404);
const path = await containedExistingFile(library.rootPath, relativePath);
return streamFile(request, path, AUDIO_MIME[extname(relativePath).toLowerCase()]);
return streamFile(request, path, AUDIO_MIME[extname(relativePath).toLowerCase()], STREAM_HEADERS);
} catch {
return textResponse("Track is unavailable.", 404);
}
Expand Down Expand Up @@ -341,65 +341,6 @@ function mediaUrl(pluginId: string, libraryId: string, relativePath: string): st
return `canvastty-media://${encodeURIComponent(pluginId)}/${encodeURIComponent(libraryId)}/${relativePath.split("/").map(encodeURIComponent).join("/")}`;
}

async function streamFile(request: Request, path: string, mimeType: string): Promise<Response> {
const metadata = await stat(path);
if (metadata.size === 0) {
return new Response(null, {
status: 200,
headers: {
"accept-ranges": "bytes",
"access-control-allow-origin": "*",
"cache-control": "no-store",
"content-length": "0",
"content-type": mimeType
}
});
}
const range = parseRange(request.headers.get("range"), metadata.size);
if (range === "invalid") {
return new Response(null, { status: 416, headers: { "content-range": `bytes */${metadata.size}` } });
}
const start = range?.start ?? 0;
const end = range?.end ?? metadata.size - 1;
const headers = new Headers({
"accept-ranges": "bytes",
"access-control-allow-origin": "*",
"cache-control": "no-store",
"content-length": String(Math.max(0, end - start + 1)),
"content-type": mimeType
});
if (range) headers.set("content-range", `bytes ${start}-${end}/${metadata.size}`);
if (request.method === "HEAD") return new Response(null, { status: range ? 206 : 200, headers });
const stream = Readable.toWeb(createReadStream(path, { start, end })) as ReadableStream<Uint8Array>;
return new Response(stream, { status: range ? 206 : 200, headers });
}

function parseRange(value: string | null, size: number): { start: number; end: number } | "invalid" | null {
if (!value) return null;
const match = value.match(/^bytes=(\d*)-(\d*)$/);
if (!match || (match[1] === "" && match[2] === "")) return "invalid";
let start: number;
let end: number;
if (match[1] === "") {
const suffix = Number(match[2]);
if (!Number.isInteger(suffix) || suffix <= 0) return "invalid";
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] === "" ? size - 1 : Number(match[2]);
}
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || start >= size || end < start) return "invalid";
return { start, end: Math.min(end, size - 1) };
}

function textResponse(message: string, status: number): Response {
return new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }
});
}

function isStoredLibrary(value: unknown): value is StoredLibrary {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<StoredLibrary>;
Expand Down
5 changes: 5 additions & 0 deletions src/main/services/SettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export class SettingsStore {
|| !("terminalLinkOpenMode" in source)
|| !("persistCanvasRegions" in source)
|| !("persistStickyNotes" in source)
|| !("persistMaterials" in source)
|| !("canvasRegions" in source)
|| !("stickyNotes" in source)
|| !("apiProfiles" in source)
Expand Down Expand Up @@ -358,6 +359,7 @@ function createDefaults(systemLocale: string, platform: string): AppSettings {
sessionRestoreMode: "off",
persistCanvasRegions: true,
persistStickyNotes: true,
persistMaterials: true,
palette: "sage",
homeAccentPreset: "classic",
homeAccentColors: { ...DEFAULT_HOME_ACCENT_COLORS },
Expand Down Expand Up @@ -578,6 +580,9 @@ export function normalizeSettings(
persistStickyNotes: typeof source.persistStickyNotes === "boolean"
? source.persistStickyNotes
: fallback.persistStickyNotes ?? true,
persistMaterials: typeof source.persistMaterials === "boolean"
? source.persistMaterials
: fallback.persistMaterials ?? true,
palette,
homeAccentPreset: HOME_ACCENT_PRESETS.has(source.homeAccentPreset as HomeAccentPresetId)
? source.homeAccentPreset as HomeAccentPresetId
Expand Down
Loading
Loading