diff --git a/src/main/index.ts b/src/main/index.ts index b80bc34a..e2fb63e2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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"; @@ -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"; @@ -126,6 +130,14 @@ protocol.registerSchemesAsPrivileged([ corsEnabled: true, stream: true } + }, + { + scheme: MATERIAL_SCHEME, + privileges: { + standard: true, + secure: true, + stream: true + } } ]); @@ -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; @@ -761,6 +774,17 @@ async function initializeServices(ipc: IpcRegistrar): Promise { 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 () => { @@ -806,6 +830,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { navigationBinding: next.canvasNavigationOverride }); await terminalManager?.setSessionRestoreMode(next.sessionRestoreMode); + await materialService?.flush(); }, setCanvasNavigationShortcutCapture: (active) => { if (active) browserService?.cancelCanvasNavigationGesture(); @@ -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) => { @@ -1206,6 +1233,7 @@ async function shutdownServices(): Promise { 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(); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 15d7b6c4..7c2277e9 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -910,7 +910,7 @@ function isMainRenderer( } } -function assertMainRenderer( +export function assertMainRenderer( event: IpcMainEvent | IpcMainInvokeEvent, getMainWindow: () => BrowserWindow | null ): void { diff --git a/src/main/ipc/registerMaterialIpc.ts b/src/main/ipc/registerMaterialIpc.ts new file mode 100644 index 00000000..b9931644 --- /dev/null +++ b/src/main/ipc/registerMaterialIpc.ts @@ -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 { + 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 { + 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(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: [] }; +} diff --git a/src/main/services/PluginMediaService.ts b/src/main/services/PluginMediaService.ts index f7d11491..92ea2136 100644 --- a/src/main/services/PluginMediaService.ts +++ b/src/main/services/PluginMediaService.ts @@ -1,4 +1,3 @@ -import { createReadStream } from "node:fs"; import { lstat, mkdir, @@ -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, @@ -39,6 +38,7 @@ const AUDIO_MIME: Record = { }; const PLAYLIST_EXTENSIONS = new Set([".json", ".m3u", ".m3u8", ".pls"]); +const STREAM_HEADERS = { "access-control-allow-origin": "*" }; interface StoredLibrary { id: string; @@ -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); } @@ -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 { - 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; - 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; diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 39566ca6..0a2a5068 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -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) @@ -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 }, @@ -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 diff --git a/src/main/services/fileResponse.ts b/src/main/services/fileResponse.ts new file mode 100644 index 00000000..e46483c0 --- /dev/null +++ b/src/main/services/fileResponse.ts @@ -0,0 +1,87 @@ +import { constants } from "node:fs"; +import { open, type FileHandle } from "node:fs/promises"; +import { Readable } from "node:stream"; + +export async function streamFile( + request: Request, + path: string, + mimeType: string, + extraHeaders: Readonly> = {} +): Promise { + const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + let streaming = false; + try { + return await respond(request, handle, mimeType, extraHeaders, () => { + streaming = true; + }); + } finally { + if (!streaming) await handle.close().catch(() => undefined); + } +} + +async function respond( + request: Request, + handle: FileHandle, + mimeType: string, + extraHeaders: Readonly>, + streaming: () => void +): Promise { + const metadata = await handle.stat(); + if (!metadata.isFile()) throw new Error("Not a regular file."); + if (metadata.size === 0) { + return new Response(null, { + status: 200, + headers: { + ...extraHeaders, + "accept-ranges": "bytes", + "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({ + ...extraHeaders, + "accept-ranges": "bytes", + "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(handle.createReadStream({ start, end, autoClose: true })) as ReadableStream; + streaming(); + return new Response(stream, { status: range ? 206 : 200, headers }); +} + +export 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 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) }; +} diff --git a/src/main/services/materials/MaterialBlobs.ts b/src/main/services/materials/MaterialBlobs.ts new file mode 100644 index 00000000..6eb7b09e --- /dev/null +++ b/src/main/services/materials/MaterialBlobs.ts @@ -0,0 +1,140 @@ +import { constants, createWriteStream } from "node:fs"; +import { mkdir, open, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +const SHA256_FILE = /^[a-f0-9]{64}$/; +const TEMP_PREFIX = ".tmp-"; + +export type MaterialBlobErrorCode = "too-large" | "quota" | "unreadable"; + +export class MaterialBlobError extends Error { + readonly code: MaterialBlobErrorCode; + + constructor(code: MaterialBlobErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export interface StoredBlob { + sha256: string; + byteSize: number; +} + +export class MaterialBlobs { + private readonly root: string; + + constructor(root: string) { + this.root = root; + } + + pathOf(sha256: string): string { + if (!SHA256_FILE.test(sha256)) throw new MaterialBlobError("unreadable", "Unknown version."); + return join(this.root, sha256); + } + + async ensureRoot(): Promise { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + } + + async has(sha256: string): Promise { + try { + return (await stat(this.pathOf(sha256))).isFile(); + } catch { + return false; + } + } + + async usedBytes(): Promise { + let entries: string[]; + try { + entries = await readdir(this.root); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return 0; + throw error; + } + let bytes = 0; + for (const entry of entries) { + if (!SHA256_FILE.test(entry) && !entry.startsWith(TEMP_PREFIX)) continue; + const info = await stat(join(this.root, entry)); + if (info.isFile()) bytes += info.size; + } + return bytes; + } + + async writeFromFile(source: string, maxBytes: number, available: number): Promise { + const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); + let handle; + try { + handle = await open(source, flags); + } catch { + throw new MaterialBlobError("unreadable", "The file cannot be read."); + } + try { + const info = await handle.stat(); + if (!info.isFile()) throw new MaterialBlobError("unreadable", "The file cannot be read."); + if (info.size > maxBytes) throw new MaterialBlobError("too-large", "The file is too large to keep a version."); + await this.ensureRoot(); + const temporary = join(this.root, `${TEMP_PREFIX}${randomUUID()}`); + const hash = createHash("sha256"); + let byteSize = 0; + const meter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + byteSize += chunk.length; + if (byteSize > maxBytes) { + callback(new MaterialBlobError("too-large", "The file is too large to keep a version.")); + return; + } + hash.update(chunk); + callback(null, chunk); + } + }); + try { + await pipeline(handle.createReadStream({ autoClose: false }), meter, createWriteStream(temporary, { mode: 0o600 })); + } catch (error) { + await rm(temporary, { force: true }); + throw error instanceof MaterialBlobError ? error : new MaterialBlobError("unreadable", "The file cannot be read."); + } + return await this.commit(temporary, hash.digest("hex"), byteSize, available); + } finally { + await handle.close().catch(() => undefined); + } + } + + async writeFromBytes(bytes: Uint8Array, maxBytes: number, available: number): Promise { + if (bytes.byteLength > maxBytes) throw new MaterialBlobError("too-large", "The content is too large to keep."); + await this.ensureRoot(); + const temporary = join(this.root, `${TEMP_PREFIX}${randomUUID()}`); + await writeFile(temporary, bytes, { mode: 0o600 }); + return this.commit(temporary, createHash("sha256").update(bytes).digest("hex"), bytes.byteLength, available); + } + + async collect(referenced: ReadonlySet): Promise { + let entries: string[]; + try { + entries = await readdir(this.root); + } catch { + return; + } + await Promise.all(entries + .filter((entry) => entry.startsWith(TEMP_PREFIX) || (SHA256_FILE.test(entry) && !referenced.has(entry))) + .map((entry) => rm(join(this.root, entry), { force: true }).catch(() => undefined))); + } + + private async commit(temporary: string, sha256: string, byteSize: number, available: number): Promise { + const target = this.pathOf(sha256); + if (await this.has(sha256)) { + await rm(temporary, { force: true }); + return { sha256, byteSize }; + } + if (byteSize > available) { + await rm(temporary, { force: true }); + throw new MaterialBlobError("quota", "Version storage is full."); + } + await rename(temporary, target); + return { sha256, byteSize }; + } +} diff --git a/src/main/services/materials/MaterialService.ts b/src/main/services/materials/MaterialService.ts new file mode 100644 index 00000000..49935db8 --- /dev/null +++ b/src/main/services/materials/MaterialService.ts @@ -0,0 +1,680 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { mkdir, open, readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import type { + CanvasMaterial, + MaterialCreateResult, + MaterialFailure, + MaterialOrigin, + MaterialResult, + MaterialsAddResult, + MaterialsSnapshot, + MaterialState, + Point, + SessionBounds, + Size +} from "../../../shared/contracts.ts"; +import { + clampSize, + MATERIAL_LIMIT, + MATERIAL_SCHEME, + materialCardSize, + materialsAtPoint, + materialType +} from "../../../shared/materials.ts"; +import { streamFile, textResponse } from "../fileResponse.ts"; +import { IMAGE_HEADER_BYTES, imageDimensions } from "./imageDimensions.ts"; +import { + emptyMaterialState, + MATERIAL_STATE_VERSION, + restoreMaterialState, + type StoredMaterial +} from "./materialState.ts"; +import { DirectoryWatchSet, nodeWatchFactory, type WatchFactory } from "./materialWatch.ts"; +import { MaterialBlobError, MaterialBlobs } from "./MaterialBlobs.ts"; + +const MAX_PATHS_PER_ADD = 64; +const MAX_NAME = 255; +const MATERIAL_CAPTURE_MAX_BYTES = 32 * 1024 * 1024; +const MATERIAL_STORAGE_LIMIT_BYTES = 1024 * 1024 * 1024; +const PERSIST_DELAY_MS = 250; +const REFRESH_DELAY_MS = 150; +const POLL_INTERVAL_MS = 10_000; +const RENAME_SCAN_LIMIT = 5_000; +const RESPONSE_HEADERS = { + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "x-content-type-options": "nosniff" +}; + +export interface MaterialServiceOptions { + userDataPath: string; + persist(): boolean; + emit(snapshot: MaterialsSnapshot): void; + watchFactory?: WatchFactory; + now?(): number; + pollIntervalMs?: number; + storageLimitBytes?: number; +} + +interface LiveState { + state: MaterialState; + signature: string | null; + byteSize: number | null; + modifiedAt: number | null; + revision: number; + movedTo: string | null; +} + +interface MaterialCaptureInput { + bytes: Uint8Array; + name: string; + mimeType: string; + origin: MaterialOrigin; + point: Point; + natural?: Size | null; +} + +export class MaterialService { + private readonly options: MaterialServiceOptions; + private readonly root: string; + private readonly statePath: string; + private readonly blobs: MaterialBlobs; + private readonly materials = new Map(); + private readonly live = new Map(); + private readonly watchers: DirectoryWatchSet; + private readonly pendingRefresh = new Set(); + private revision = 0; + private queue: Promise = Promise.resolve(); + private writeQueue: Promise = Promise.resolve(); + private persistTimer: ReturnType | null = null; + private refreshTimer: ReturnType | null = null; + private pollTimer: ReturnType | null = null; + private loading: Promise | null = null; + private disposing: Promise | null = null; + private writable = false; + private loadError: "unreadable" | undefined; + private disposed = false; + + constructor(options: MaterialServiceOptions) { + this.options = options; + this.root = join(options.userDataPath, "materials"); + this.statePath = join(this.root, "state.json"); + this.blobs = new MaterialBlobs(join(this.root, "versions")); + this.watchers = new DirectoryWatchSet(options.watchFactory ?? nodeWatchFactory, (ids) => this.scheduleRefresh(ids)); + } + + load(): Promise { + this.loading ??= this.restore(); + return this.loading; + } + + private async restore(): Promise { + let state = emptyMaterialState(); + try { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + state = restoreMaterialState(JSON.parse(await readFile(this.statePath, "utf8"))); + } catch (error) { + const newStore = Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT") + && await this.blobs.usedBytes().then((bytes) => bytes === 0, () => false); + if (!newStore) { + console.warn("CanvasTTY materials could not be loaded and are left on disk as they are.", error); + this.loadError = "unreadable"; + this.changed(false); + return; + } + } + if (!this.options.persist()) state = emptyMaterialState(); + for (const material of state.materials) { + this.materials.set(material.id, material); + } + this.writable = true; + await this.collect(); + if (this.disposed) return; + for (const material of this.materials.values()) { + await this.refreshLive(material); + if (this.disposed) return; + this.watchers.track(material.id, material.path); + } + const interval = this.options.pollIntervalMs ?? POLL_INTERVAL_MS; + if (interval > 0) { + this.pollTimer = setInterval(() => { + this.watchers.retry(); + this.scheduleRefresh([...this.materials.keys()]); + }, interval); + this.pollTimer.unref?.(); + } + this.changed(false); + } + + snapshot(): MaterialsSnapshot { + return { + revision: this.revision, + ...(this.loadError ? { loadError: this.loadError } : {}), + materials: [...this.materials.values()].map((material) => this.publicMaterial(material)) + }; + } + + location(id: string): string | null { + return this.materials.get(id)?.path ?? null; + } + + addPaths(paths: readonly unknown[], point: unknown): Promise { + return this.serial(async () => { + const result: MaterialsAddResult = { added: [], existing: [], rejected: [] }; + if (!this.writable) { + result.rejected = paths.slice(0, MAX_PATHS_PER_ADD).map((path) => ({ + name: typeof path === "string" ? displayName(path) : "file", reason: "unreadable" + })); + return result; + } + const fresh: StoredMaterial[] = []; + const infos = new Map(); + for (const candidate of paths.slice(0, MAX_PATHS_PER_ADD)) { + const name = typeof candidate === "string" ? displayName(candidate) : "file"; + if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) { + result.rejected.push({ name, reason: "unreadable" }); + continue; + } + let resolved: string; + let info: Stats; + try { + resolved = await realpath(candidate); + info = await stat(resolved); + } catch { + result.rejected.push({ name, reason: "unreadable" }); + continue; + } + if (!info.isFile()) { + result.rejected.push({ name, reason: "not-a-file" }); + continue; + } + const existing = this.findByPath(resolved) ?? fresh.find((material) => material.path === resolved); + if (existing) { + if (!result.existing.includes(existing.id)) result.existing.push(existing.id); + continue; + } + if (this.materials.size + fresh.length >= MATERIAL_LIMIT) { + result.rejected.push({ name, reason: "limit" }); + continue; + } + const type = materialType(basename(resolved)); + const kind = type.kind === "image" ? "image" : "file"; + const natural = kind === "image" ? await readImageDimensions(resolved) : null; + const material: StoredMaterial = { + id: randomUUID(), + kind, + name, + mimeType: kind === "image" ? type.mimeType : "application/octet-stream", + position: { x: 0, y: 0 }, + size: materialCardSize(kind, natural), + path: resolved, + identity: { dev: Number(info.dev), ino: Number(info.ino) }, + origin: null, + createdAt: this.now(), + versions: [], + nextVersion: 1 + }; + fresh.push(material); + infos.set(material.id, info); + } + const placed = materialsAtPoint(fresh.map((material) => material.size), finitePoint(point)); + fresh.forEach((material, index) => { + material.position = placed[index].position; + this.materials.set(material.id, material); + const info = infos.get(material.id)!; + this.live.set(material.id, { + state: "ready", + signature: signatureOf(info), + byteSize: Number(info.size), + modifiedAt: Number(info.mtimeMs), + revision: 1, + movedTo: null + }); + this.watchers.track(material.id, material.path); + result.added.push(material.id); + }); + if (fresh.length > 0) this.changed(); + return result; + }); + } + + addCapture(input: MaterialCaptureInput): Promise { + return this.serial(async () => { + if (!this.writable) return failure("unreadable"); + if (this.materials.size >= MATERIAL_LIMIT) return failure("material-limit"); + let blob; + try { + blob = await this.blobs.writeFromBytes(input.bytes, MATERIAL_CAPTURE_MAX_BYTES, await this.availableBytes()); + } catch (error) { + return failure(blobFailure(error)); + } + const type = materialType(input.name); + const createdAt = this.now(); + const size = materialCardSize(type.kind, input.natural ?? null); + const [placed] = materialsAtPoint([size], finitePoint(input.point)); + const material: StoredMaterial = { + id: randomUUID(), + kind: type.kind, + name: displayName(input.name), + mimeType: input.mimeType, + position: placed.position, + size, + path: null, + identity: null, + origin: input.origin, + createdAt, + versions: [{ + id: randomUUID(), + number: 1, + sha256: blob.sha256, + byteSize: blob.byteSize, + mimeType: input.mimeType, + createdAt, + reason: "capture" + }], + nextVersion: 2 + }; + this.materials.set(material.id, material); + await this.refreshLive(material); + this.changed(); + return { ok: true, materialId: material.id }; + }); + } + + setBounds(id: string, bounds: unknown): void { + if (!this.writable || this.disposed) return; + const material = this.materials.get(id); + if (!material || !isBounds(bounds)) return; + material.position = { x: bounds.position.x, y: bounds.position.y }; + material.size = clampSize(bounds.size); + this.changed(); + } + + setBoundsBatch(entries: unknown): void { + if (!this.writable || this.disposed) return; + if (!Array.isArray(entries)) return; + let any = false; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const { id, bounds } = entry as { id?: unknown; bounds?: unknown }; + const material = typeof id === "string" ? this.materials.get(id) : undefined; + if (!material || !isBounds(bounds)) continue; + material.position = { x: bounds.position.x, y: bounds.position.y }; + material.size = clampSize(bounds.size); + any = true; + } + if (any) this.changed(); + } + + remove(id: string): Promise { + return this.serial(async () => { + if (!this.materials.delete(id)) return; + this.live.delete(id); + this.pendingRefresh.delete(id); + this.watchers.untrack(id); + this.changed(); + await this.collect(); + }); + } + + relink(id: string, candidate: unknown): Promise { + return this.serial(async () => { + const material = this.materials.get(id); + if (!material || material.path === null) return failure("unavailable"); + if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) return failure("unreadable"); + let resolved: string; + let info: Stats; + try { + resolved = await realpath(candidate); + info = await stat(resolved); + } catch { + return failure("unreadable"); + } + if (!info.isFile()) return failure("not-a-file"); + const other = this.findByPath(resolved); + if (other && other.id !== id) return failure("already-on-canvas"); + const name = displayName(candidate); + const type = materialType(basename(resolved)); + const kind = type.kind === "image" ? "image" : "file"; + if (kind !== material.kind) return failure("kind-mismatch"); + material.path = resolved; + material.name = name; + material.mimeType = kind === "image" ? type.mimeType : "application/octet-stream"; + material.identity = { dev: Number(info.dev), ino: Number(info.ino) }; + this.watchers.track(id, resolved); + await this.refreshLive(material, true); + this.changed(); + return { ok: true }; + }); + } + + acceptMove(id: string): Promise { + const movedTo = this.live.get(id)?.movedTo; + return movedTo ? this.relink(id, movedTo) : Promise.resolve(failure("unavailable")); + } + + async protocolResponse(request: Request): Promise { + try { + const url = new URL(request.url); + if (url.protocol !== `${MATERIAL_SCHEME}:`) return textResponse("Unsupported protocol.", 400); + const material = this.materials.get(decodeURIComponent(url.hostname)); + if (!material) return textResponse("Material is unavailable.", 404); + const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (parts[0] === "live" && parts.length === 1) { + if (material.path === null) { + const latest = material.versions.at(-1); + return latest + ? await streamFile(request, this.blobs.pathOf(latest.sha256), latest.mimeType, RESPONSE_HEADERS) + : textResponse("Material is unavailable.", 404); + } + const resolved = await realpath(material.path); + const info = await stat(resolved); + if (resolved !== material.path || !info.isFile()) return textResponse("Material is unavailable.", 404); + return await streamFile(request, resolved, material.mimeType, RESPONSE_HEADERS); + } + return textResponse("Material is unavailable.", 404); + } catch { + return textResponse("Material is unavailable.", 404); + } + } + + async flush(strict = false): Promise { + await this.loading; + await this.queue; + if (this.persistTimer !== null) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + await this.writeState(strict); + } + + dispose(): Promise { + this.disposing ??= this.close(); + return this.disposing; + } + + private async close(): Promise { + this.disposed = true; + if (this.pollTimer !== null) clearInterval(this.pollTimer); + if (this.refreshTimer !== null) clearTimeout(this.refreshTimer); + await this.flush(); + this.watchers.close(); + if (this.writable && !this.options.persist()) await this.collect(); + } + + private scheduleRefresh(ids: readonly string[]): void { + if (this.disposed) return; + for (const id of ids) this.pendingRefresh.add(id); + if (this.refreshTimer !== null) return; + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + const pending = [...this.pendingRefresh]; + this.pendingRefresh.clear(); + void this.serial(async () => { + let any = false; + for (const id of pending) { + const material = this.materials.get(id); + if (material && await this.refreshLive(material)) any = true; + } + if (any) this.changed(false); + }); + }, REFRESH_DELAY_MS); + this.refreshTimer.unref?.(); + } + + private async refreshLive(material: StoredMaterial, forceRevision = false): Promise { + const previous = this.live.get(material.id); + const next = material.path === null + ? await this.blobs.has(material.versions.at(-1)?.sha256 ?? "") + ? captureLive(material) + : unavailable("unreadable", previous) + : await inspectWorkingFile(material, previous); + const changedContent = next.signature !== (previous?.signature ?? null); + const revision = previous + ? previous.revision + (changedContent || forceRevision ? 1 : 0) + : Math.max(1, next.revision); + const updated: LiveState = { ...next, revision }; + if (next.state === "ready" && material.path !== null && next.identity) material.identity = next.identity; + this.live.set(material.id, updated); + return !previous + || previous.state !== updated.state + || previous.revision !== updated.revision + || previous.movedTo !== updated.movedTo; + } + + private publicMaterial(material: StoredMaterial): CanvasMaterial { + const live = this.live.get(material.id); + return { + id: material.id, + kind: material.kind, + name: material.name, + mimeType: material.mimeType, + position: { ...material.position }, + size: { ...material.size }, + location: material.path, + state: live?.state ?? "unreadable", + movedTo: live?.movedTo ?? null, + liveRevision: live?.revision ?? 1, + byteSize: live?.byteSize ?? null, + modifiedAt: live?.modifiedAt ?? null, + origin: material.origin ? structuredClone(material.origin) : null, + versions: [], + createdAt: material.createdAt + }; + } + + private findByPath(path: string): StoredMaterial | undefined { + for (const material of this.materials.values()) if (material.path === path) return material; + return undefined; + } + + private changed(persist = true): void { + this.revision += 1; + if (persist) this.schedulePersist(); + this.options.emit(this.snapshot()); + } + + private schedulePersist(): void { + if (this.disposed || !this.writable) return; + if (this.persistTimer !== null) clearTimeout(this.persistTimer); + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + void this.writeState(); + }, PERSIST_DELAY_MS); + this.persistTimer.unref?.(); + } + + private writeState(strict = false): Promise { + if (!this.writable) return strict ? Promise.reject(new Error("CanvasTTY materials state is not writable.")) : this.writeQueue; + const state = this.options.persist() + ? { version: MATERIAL_STATE_VERSION, materials: [...this.materials.values()] } + : emptyMaterialState(); + const snapshot = JSON.stringify(state); + const temporary = `${this.statePath}.tmp`; + const write = this.writeQueue.catch(() => undefined).then(async () => { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await writeFile(temporary, snapshot, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, this.statePath); + }); + this.writeQueue = write.catch((error) => { + console.warn("CanvasTTY materials could not be saved.", error); + }); + return strict ? write : this.writeQueue; + } + + private serial(task: () => Promise): Promise { + if (this.disposed) return Promise.reject(new Error("CanvasTTY materials are closed.")); + const run = this.queue.catch(() => undefined).then(task); + this.queue = run.catch(() => undefined); + return run; + } + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + private async availableBytes(): Promise { + return Math.max(0, (this.options.storageLimitBytes ?? MATERIAL_STORAGE_LIMIT_BYTES) - await this.blobs.usedBytes()); + } + + private async collect(): Promise { + try { + await this.writeState(true); + } catch { + return; + } + await this.blobs.collect(this.disposed && !this.options.persist() ? new Set() : this.referencedHashes()); + } + + private referencedHashes(): Set { + const hashes = new Set(); + for (const material of this.materials.values()) { + for (const version of material.versions) hashes.add(version.sha256); + } + return hashes; + } +} + +interface InspectedLive extends Omit { + revision: number; + identity?: { dev: number; ino: number }; +} + +async function inspectWorkingFile(material: StoredMaterial, previous: LiveState | undefined): Promise { + const path = material.path!; + try { + const resolved = await realpath(path); + const info = await stat(resolved); + if (resolved !== path || !info.isFile()) return unavailable("unreadable", previous); + return { + state: "ready", + signature: signatureOf(info), + byteSize: Number(info.size), + modifiedAt: Number(info.mtimeMs), + revision: 1, + movedTo: null, + identity: { dev: Number(info.dev), ino: Number(info.ino) } + }; + } catch (error) { + if (!isMissing(error)) return unavailable("unreadable", previous); + const movedTo = await renamedTo(material, previous); + return { ...unavailable(movedTo ? "moved" : "missing", previous), movedTo }; + } +} + +async function renamedTo(material: StoredMaterial, previous: LiveState | undefined): Promise { + if (!material.identity) return null; + if (previous?.state === "moved" && previous.movedTo) { + try { + const info = await stat(previous.movedTo); + if (info.isFile() && Number(info.ino) === material.identity.ino && Number(info.dev) === material.identity.dev) { + return previous.movedTo; + } + } catch { + return null; + } + return null; + } + if (previous && previous.state !== "ready") return null; + return findRenamed(dirname(material.path!), material.identity); +} + +function unavailable(state: MaterialState, previous: LiveState | undefined): InspectedLive { + return { + state, + signature: null, + byteSize: null, + modifiedAt: previous?.modifiedAt ?? null, + revision: 1, + movedTo: null + }; +} + +async function findRenamed(directory: string, identity: { dev: number; ino: number }): Promise { + let entries: string[]; + try { + entries = await readdir(directory); + } catch { + return null; + } + for (const entry of entries.slice(0, RENAME_SCAN_LIMIT)) { + const candidate = join(directory, entry); + try { + const info = await stat(candidate); + if (info.isFile() && Number(info.ino) === identity.ino && Number(info.dev) === identity.dev) { + return await realpath(candidate) === candidate ? candidate : null; + } + } catch { + continue; + } + } + return null; +} + +async function readImageDimensions(path: string): Promise { + const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); + let handle; + try { + handle = await open(path, flags); + if (!(await handle.stat()).isFile()) return null; + const buffer = Buffer.alloc(IMAGE_HEADER_BYTES); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + return imageDimensions(buffer.subarray(0, bytesRead)); + } catch { + return null; + } finally { + await handle?.close().catch(() => undefined); + } +} + +function signatureOf(info: Stats): string { + return `${info.size}:${info.mtimeMs}:${info.ino}`; +} + +function displayName(path: string): string { + return (basename(path) || "file").slice(0, MAX_NAME); +} + +function finitePoint(value: unknown): Point { + if (value && typeof value === "object") { + const { x, y } = value as Partial; + if (typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y)) return { x, y }; + } + return { x: 0, y: 0 }; +} + +function isBounds(value: unknown): value is SessionBounds { + if (!value || typeof value !== "object") return false; + const { position, size } = value as Partial; + return Boolean(position && size) + && [position!.x, position!.y, size!.width, size!.height].every((entry) => typeof entry === "number" && Number.isFinite(entry)); +} + +function captureLive(material: StoredMaterial): InspectedLive { + const latest = material.versions.at(-1); + return { + state: latest ? "ready" : "unreadable", + signature: latest?.sha256 ?? null, + byteSize: latest?.byteSize ?? null, + modifiedAt: latest?.createdAt ?? null, + revision: latest?.number ?? 1, + movedTo: null + }; +} + +function blobFailure(error: unknown): MaterialFailure { + if (error instanceof MaterialBlobError) { + if (error.code === "too-large") return "too-large"; + if (error.code === "quota") return "quota"; + } + return "unreadable"; +} + +function failure(reason: MaterialFailure): { ok: false; reason: MaterialFailure } { + return { ok: false, reason }; +} + +function isMissing(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")); +} diff --git a/src/main/services/materials/imageDimensions.ts b/src/main/services/materials/imageDimensions.ts new file mode 100644 index 00000000..9345f641 --- /dev/null +++ b/src/main/services/materials/imageDimensions.ts @@ -0,0 +1,125 @@ +import type { Size } from "../../../shared/contracts.ts"; + +export const IMAGE_HEADER_BYTES = 512 * 1024; + +export function imageDimensions(bytes: Uint8Array): Size | null { + if (bytes.length < 12) return null; + if (matches(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return bytes.length >= 24 ? positive(readUint32BE(bytes, 16), readUint32BE(bytes, 20)) : null; + } + if (ascii(bytes, 0, 4) === "GIF8") return positive(readUint16LE(bytes, 6), readUint16LE(bytes, 8)); + if (ascii(bytes, 0, 2) === "BM") return bmpDimensions(bytes); + if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") return webpDimensions(bytes); + if (bytes[0] === 0xff && bytes[1] === 0xd8) return jpegDimensions(bytes); + return null; +} + +function bmpDimensions(bytes: Uint8Array): Size | null { + if (bytes.length < 26) return null; + const headerSize = readUint32LE(bytes, 14); + if (headerSize === 12) return positive(readUint16LE(bytes, 18), readUint16LE(bytes, 20)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return positive(Math.abs(view.getInt32(18, true)), Math.abs(view.getInt32(22, true))); +} + +function webpDimensions(bytes: Uint8Array): Size | null { + const chunk = ascii(bytes, 12, 4); + if (chunk === "VP8 " && bytes.length >= 30) { + return positive(readUint16LE(bytes, 26) & 0x3fff, readUint16LE(bytes, 28) & 0x3fff); + } + if (chunk === "VP8L" && bytes.length >= 25) { + const bits = readUint32LE(bytes, 21); + return positive((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1); + } + if (chunk === "VP8X" && bytes.length >= 30) { + return positive(readUint24LE(bytes, 24) + 1, readUint24LE(bytes, 27) + 1); + } + return null; +} + +function jpegDimensions(bytes: Uint8Array): Size | null { + let offset = 2; + let orientation = 1; + while (offset + 4 <= bytes.length) { + if (bytes[offset] !== 0xff) return null; + const marker = bytes[offset + 1]; + if (marker === 0xff) { + offset += 1; + continue; + } + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + if (marker === 0xd9 || marker === 0xda) return null; + const length = readUint16BE(bytes, offset + 2); + if (length < 2) return null; + if (marker === 0xe1) orientation = exifOrientation(bytes, offset + 4, length - 2) ?? orientation; + if (isStartOfFrame(marker) && offset + 9 <= bytes.length) { + const height = readUint16BE(bytes, offset + 5); + const width = readUint16BE(bytes, offset + 7); + return orientation >= 5 && orientation <= 8 ? positive(height, width) : positive(width, height); + } + offset += 2 + length; + } + return null; +} + +function isStartOfFrame(marker: number): boolean { + return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; +} + +function exifOrientation(bytes: Uint8Array, start: number, length: number): number | null { + const end = Math.min(bytes.length, start + length); + if (end - start < 14 || ascii(bytes, start, 4) !== "Exif") return null; + const tiff = start + 6; + const order = ascii(bytes, tiff, 2); + if (order !== "II" && order !== "MM") return null; + const little = order === "II"; + const read16 = (at: number): number => (little ? readUint16LE(bytes, at) : readUint16BE(bytes, at)); + const read32 = (at: number): number => (little ? readUint32LE(bytes, at) : readUint32BE(bytes, at)); + const directory = tiff + read32(tiff + 4); + if (directory + 2 > end) return null; + const entries = read16(directory); + for (let index = 0; index < entries; index += 1) { + const entry = directory + 2 + index * 12; + if (entry + 12 > end) return null; + if (read16(entry) === 0x0112) { + const value = read16(entry + 8); + return value >= 1 && value <= 8 ? value : null; + } + } + return null; +} + +function positive(width: number, height: number): Size | null { + return width > 0 && height > 0 ? { width, height } : null; +} + +function matches(bytes: Uint8Array, offset: number, expected: readonly number[]): boolean { + return expected.every((value, index) => bytes[offset + index] === value); +} + +function ascii(bytes: Uint8Array, offset: number, length: number): string { + return String.fromCharCode(...bytes.subarray(offset, offset + length)); +} + +function readUint16LE(bytes: Uint8Array, offset: number): number { + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); +} + +function readUint16BE(bytes: Uint8Array, offset: number): number { + return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0); +} + +function readUint24LE(bytes: Uint8Array, offset: number): number { + return readUint16LE(bytes, offset) | ((bytes[offset + 2] ?? 0) << 16); +} + +function readUint32LE(bytes: Uint8Array, offset: number): number { + return (readUint16LE(bytes, offset) | (readUint16LE(bytes, offset + 2) << 16)) >>> 0; +} + +function readUint32BE(bytes: Uint8Array, offset: number): number { + return ((readUint16BE(bytes, offset) << 16) | readUint16BE(bytes, offset + 2)) >>> 0; +} diff --git a/src/main/services/materials/materialClipboard.ts b/src/main/services/materials/materialClipboard.ts new file mode 100644 index 00000000..5c137011 --- /dev/null +++ b/src/main/services/materials/materialClipboard.ts @@ -0,0 +1,50 @@ +import { isAbsolute, posix, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { MaterialFailure, MaterialRejectionReason } from "../../../shared/contracts.ts"; +import { MATERIAL_LIMIT } from "../../../shared/materials.ts"; + +export const CLIPBOARD_TEXT_PATH_LIMIT = 16; + +export function plistPaths(xml: string): string[] { + return listedPaths([...xml.matchAll(/([^<]*)<\/string>/g)].map((match) => decodeXml(match[1]))); +} + +export function fileUrlPaths(text: string): string[] { + return listedPaths(text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("file://")).flatMap((line) => { + try { + return [fileURLToPath(line)]; + } catch { + return []; + } + })); +} + +export function windowsFileNames(buffer: Buffer): string[] { + return listedPaths(buffer.toString("utf16le").split("\0"), win32.isAbsolute); +} + +export function textPaths(text: string, platform: NodeJS.Platform): string[] { + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (lines.length === 0 || lines.length > CLIPBOARD_TEXT_PATH_LIMIT) return []; + const absolute = platform === "win32" ? win32.isAbsolute : posix.isAbsolute; + return lines.every((line) => absolute(line) && !line.includes("\0") && !isRemote(line, platform)) ? lines : []; +} + +function listedPaths(paths: readonly string[], absolute = isAbsolute): string[] { + return paths.filter((path) => path.length > 0 && absolute(path) && !path.includes("\0")).slice(0, MATERIAL_LIMIT); +} + +function isRemote(path: string, platform: NodeJS.Platform): boolean { + if (platform === "win32") return /^[\\/]{2}/.test(path); + return platform === "darwin" && /^\/(?:net|Network)\//.test(path); +} + +export function captureRejection(reason: MaterialFailure): MaterialRejectionReason { + if (reason === "material-limit") return "limit"; + if (reason === "quota" || reason === "too-large") return reason; + return "unreadable"; +} + +function decodeXml(value: string): string { + return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&"); +} diff --git a/src/main/services/materials/materialState.ts b/src/main/services/materials/materialState.ts new file mode 100644 index 00000000..f2a24a85 --- /dev/null +++ b/src/main/services/materials/materialState.ts @@ -0,0 +1,204 @@ +import { isAbsolute } from "node:path"; +import type { + MaterialKind, + MaterialOrigin, + MaterialVersionReason, + Point, + Size +} from "../../../shared/contracts.ts"; +import { clampSize, MATERIAL_LIMIT, MATERIAL_VERSION_LIMIT } from "../../../shared/materials.ts"; + +export const MATERIAL_STATE_VERSION = 1; + +const KINDS: ReadonlySet = new Set(["image", "text", "video", "audio", "pdf", "file"]); +const VERSION_REASONS: ReadonlySet = new Set(["pinned", "remark", "handoff", "result", "capture"]); +const ID_PATTERN = /^[a-f0-9-]{36}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const MAX_NAME = 255; +const MAX_URL = 2_048; +const MAX_TITLE = 300; +const MIME_PATTERN = /^[a-z]+\/[a-z0-9.+-]+$/; + +export interface StoredVersion { + id: string; + number: number; + sha256: string; + byteSize: number; + mimeType: string; + createdAt: number; + reason: MaterialVersionReason; +} + +export interface StoredFileIdentity { + dev: number; + ino: number; +} + +export interface StoredMaterial { + id: string; + kind: MaterialKind; + name: string; + mimeType: string; + position: Point; + size: Size; + path: string | null; + identity: StoredFileIdentity | null; + origin: MaterialOrigin | null; + createdAt: number; + versions: StoredVersion[]; + nextVersion: number; +} + +export interface StoredMaterialState { + version: typeof MATERIAL_STATE_VERSION; + materials: StoredMaterial[]; +} + +export function emptyMaterialState(): StoredMaterialState { + return { version: MATERIAL_STATE_VERSION, materials: [] }; +} + +export function restoreMaterialState(candidate: unknown): StoredMaterialState { + if (!isRecord(candidate) || candidate.version !== MATERIAL_STATE_VERSION || !Array.isArray(candidate.materials)) { + throw new Error("Unsupported materials state."); + } + const state = normalizeMaterialState(candidate); + if (!preservesStoredFields(candidate, state)) throw new Error("Invalid materials state."); + return state; +} + +function preservesStoredFields(stored: unknown, normalized: unknown): boolean { + if (Array.isArray(stored)) { + if (!Array.isArray(normalized) || stored.length !== normalized.length) return false; + const byId = new Map(normalized.filter(isRecord).map((entry) => [entry.id, entry])); + return stored.every((entry, index) => preservesStoredFields(entry, + isRecord(entry) && typeof entry.id === "string" ? byId.get(entry.id) : normalized[index])); + } + if (isRecord(stored)) { + return isRecord(normalized) && Object.keys(stored).every((key) => + Object.hasOwn(normalized, key) && preservesStoredFields(stored[key], normalized[key])); + } + return normalized !== undefined && (stored === null || normalized !== null); +} + +export function normalizeMaterialState(candidate: unknown): StoredMaterialState { + const state = emptyMaterialState(); + if (!isRecord(candidate) || candidate.version !== MATERIAL_STATE_VERSION || !Array.isArray(candidate.materials)) { + return state; + } + const ids = new Set(); + const paths = new Set(); + for (const value of candidate.materials) { + if (state.materials.length >= MATERIAL_LIMIT) break; + const material = normalizeMaterial(value); + if (!material || ids.has(material.id) || (material.path !== null && paths.has(material.path))) continue; + ids.add(material.id); + if (material.path !== null) paths.add(material.path); + state.materials.push(material); + } + return state; +} + +function normalizeMaterial(value: unknown): StoredMaterial | null { + if (!isRecord(value)) return null; + const { id, kind, name, mimeType, position, size, path, identity, origin, createdAt, versions, nextVersion } = value; + if (typeof id !== "string" || !ID_PATTERN.test(id)) return null; + if (typeof kind !== "string" || !KINDS.has(kind as MaterialKind)) return null; + if (typeof name !== "string" || name.length === 0 || name.length > MAX_NAME) return null; + if (typeof mimeType !== "string" || !MIME_PATTERN.test(mimeType)) return null; + if (!isPoint(position) || !isSize(size)) return null; + if (path !== null && (typeof path !== "string" || !isAbsolute(path) || path.includes("\0"))) return null; + if (!isFiniteNumber(createdAt)) return null; + const storedVersions = normalizeVersions(versions); + if (path === null && storedVersions.length === 0) return null; + const highest = storedVersions.reduce((max, version) => Math.max(max, version.number), 0); + return { + id, + kind: kind as MaterialKind, + name, + mimeType, + position: { x: position.x, y: position.y }, + size: clampSize(size), + path, + identity: normalizeIdentity(identity), + origin: normalizeOrigin(origin), + createdAt, + versions: storedVersions, + nextVersion: Math.max(highest + 1, Number.isInteger(nextVersion) ? nextVersion as number : 1) + }; +} + +function normalizeVersions(value: unknown): StoredVersion[] { + if (!Array.isArray(value)) return []; + const versions: StoredVersion[] = []; + const ids = new Set(); + const numbers = new Set(); + for (const candidate of value) { + if (versions.length >= MATERIAL_VERSION_LIMIT) break; + if (!isRecord(candidate)) continue; + const { id, number, sha256, byteSize, mimeType, createdAt, reason } = candidate; + if (typeof id !== "string" || !ID_PATTERN.test(id) || ids.has(id)) continue; + if (!Number.isInteger(number) || (number as number) < 1 || numbers.has(number as number)) continue; + if (typeof sha256 !== "string" || !SHA256_PATTERN.test(sha256)) continue; + if (!Number.isInteger(byteSize) || (byteSize as number) < 0) continue; + if (typeof mimeType !== "string" || !MIME_PATTERN.test(mimeType)) continue; + if (!isFiniteNumber(createdAt)) continue; + if (typeof reason !== "string" || !VERSION_REASONS.has(reason as MaterialVersionReason)) continue; + ids.add(id); + numbers.add(number as number); + versions.push({ + id, + number: number as number, + sha256, + byteSize: byteSize as number, + mimeType, + createdAt, + reason: reason as MaterialVersionReason + }); + } + return versions.sort((left, right) => left.number - right.number); +} + +function normalizeIdentity(value: unknown): StoredFileIdentity | null { + if (!isRecord(value) || !isFiniteNumber(value.dev) || !isFiniteNumber(value.ino)) return null; + return { dev: value.dev, ino: value.ino }; +} + +export function normalizeOrigin(value: unknown): MaterialOrigin | null { + if (!isRecord(value)) return null; + if (value.kind === "clipboard") return { kind: "clipboard" }; + if (value.kind === "watch" && typeof value.folderName === "string" && value.folderName.length <= MAX_NAME) { + return { kind: "watch", folderName: value.folderName }; + } + if (value.kind === "browser" && typeof value.url === "string" && value.url.length <= MAX_URL + && typeof value.title === "string" && isSize(value.viewport)) { + return { + kind: "browser", + url: value.url, + title: value.title.slice(0, MAX_TITLE), + viewport: { width: value.viewport.width, height: value.viewport.height } + }; + } + return null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isPoint(value: unknown): value is Point { + return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y); +} + +function isSize(value: unknown): value is Size { + return isRecord(value) && isFiniteNumber(value.width) && isFiniteNumber(value.height) + && value.width > 0 && value.height > 0; +} + +export function isId(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9-]{36}$/.test(value); +} diff --git a/src/main/services/materials/materialWatch.ts b/src/main/services/materials/materialWatch.ts new file mode 100644 index 00000000..46f96aaa --- /dev/null +++ b/src/main/services/materials/materialWatch.ts @@ -0,0 +1,103 @@ +import { watch } from "node:fs"; +import { dirname } from "node:path"; + +export interface WatchHandle { + close(): void; +} + +export type WatchFactory = (directory: string, listener: () => void, failed: () => void) => WatchHandle; + +export const MAX_WATCHED_DIRECTORIES = 64; + +export const nodeWatchFactory: WatchFactory = (directory, listener, failed) => { + const watcher = watch(directory, { persistent: false }, () => listener()); + watcher.on("error", () => { + watcher.close(); + failed(); + }); + return watcher; +}; + +interface DirectoryEntry { + handle: WatchHandle | null; + ids: Set; +} + +export class DirectoryWatchSet { + private readonly factory: WatchFactory; + private readonly changed: (ids: string[]) => void; + private readonly directories = new Map(); + private readonly directoryOf = new Map(); + + constructor(factory: WatchFactory, changed: (ids: string[]) => void) { + this.factory = factory; + this.changed = changed; + } + + track(id: string, path: string | null): void { + const next = path === null ? null : dirname(path); + const previous = this.directoryOf.get(id) ?? null; + if (previous === next) return; + if (previous !== null) this.release(id, previous); + if (next === null) return; + this.directoryOf.set(id, next); + const entry = this.directories.get(next); + if (entry) { + entry.ids.add(id); + return; + } + const created: DirectoryEntry = { handle: null, ids: new Set([id]) }; + this.directories.set(next, created); + this.open(next, created); + } + + retry(): void { + for (const [directory, entry] of this.directories) { + if (entry.handle === null) this.open(directory, entry); + } + } + + untrack(id: string): void { + const directory = this.directoryOf.get(id); + if (directory !== undefined) this.release(id, directory); + } + + watched(id: string): boolean { + const directory = this.directoryOf.get(id); + return directory !== undefined && this.directories.get(directory)?.handle !== null; + } + + close(): void { + for (const entry of this.directories.values()) entry.handle?.close(); + this.directories.clear(); + this.directoryOf.clear(); + } + + private release(id: string, directory: string): void { + this.directoryOf.delete(id); + const entry = this.directories.get(directory); + if (!entry) return; + entry.ids.delete(id); + if (entry.ids.size > 0) return; + entry.handle?.close(); + this.directories.delete(directory); + } + + private open(directory: string, entry: DirectoryEntry): void { + if (this.watchedCount() >= MAX_WATCHED_DIRECTORIES) return; + try { + const handle = this.factory(directory, () => this.changed([...entry.ids]), () => { + if (entry.handle === handle) entry.handle = null; + }); + entry.handle = handle; + } catch { + entry.handle = null; + } + } + + private watchedCount(): number { + let count = 0; + for (const entry of this.directories.values()) if (entry.handle !== null) count += 1; + return count; + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 218ddc45..5e1f5c30 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ import type { CanvasTTYApi, CustomTerminalBorderSkinId, CreateSessionRequest, + MaterialsSnapshot, PluginBrowserOpenRequest, PluginBrowserOpenResponse, PluginCanvasRequest, @@ -22,6 +23,7 @@ import type { PluginCardDecorations, PluginStorageChangeEvent, PluginUpdateStatus, + Point, ProviderId, PixelSkinPackInstallRequest, PixelSkinZipInstallRequest, @@ -33,8 +35,8 @@ import type { SessionRemovedEvent, GitRiskReport, TerminalDataEvent -} from "../shared/contracts"; -import { IPC } from "../shared/contracts"; +} from "../shared/contracts.ts"; +import { IPC } from "../shared/contracts.ts"; import { terminalFileDropText } from "../shared/terminalFileDrop"; import { TerminalDataRouter } from "../shared/terminalDataRouter"; @@ -118,6 +120,23 @@ const api: CanvasTTYApi = { media: { read: (path: string) => ipcRenderer.invoke(IPC.mediaRead, path) }, + materials: { + snapshot: () => ipcRenderer.invoke(IPC.materialsSnapshot), + addFiles: (files: File[], point: Point) => ipcRenderer.invoke( + IPC.materialsAddPaths, + files.map((file) => webUtils.getPathForFile(file)), + point + ), + pick: (point: Point) => ipcRenderer.invoke(IPC.materialsPick, point), + paste: (point: Point) => ipcRenderer.invoke(IPC.materialsPaste, point), + setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.materialsSetBounds, id, bounds), + setBoundsBatch: (entries: { id: string; bounds: SessionBounds }[]) => ipcRenderer.send(IPC.materialsSetBoundsBatch, entries), + remove: (id: string) => ipcRenderer.invoke(IPC.materialsRemove, id), + reveal: (id: string) => ipcRenderer.invoke(IPC.materialsReveal, id), + relink: (id: string) => ipcRenderer.invoke(IPC.materialsRelink, id), + acceptMove: (id: string) => ipcRenderer.invoke(IPC.materialsAcceptMove, id), + onChanged: (listener: (snapshot: MaterialsSnapshot) => void) => subscribe(IPC.materialsChanged, listener) + }, limits: { get: () => ipcRenderer.invoke(IPC.limitsGet) }, diff --git a/src/renderer/index.html b/src/renderer/index.html index 3e8d0318..f9f73c39 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ CanvasTTY diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 59634b23..2e8ba2ae 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -15,6 +15,7 @@ import type { HomeWidgetPlacement, InstalledPlugin, LaunchProfileId, + MaterialsAddResult, LaunchRole, PluginLaunchValues, SessionEnvironmentChoice, @@ -81,6 +82,13 @@ import { import { homeGridPixelSize, homeLayoutFitsGrid, placeHomeWidget } from "./features/home/homeLayout"; import { boundsInsideRegion, translateBounds } from "./features/workspace/canvasRegions"; import { DEFAULT_SESSION_SIZE, findNearHomeSessionPosition } from "./features/workspace/sessionPlacement"; +import { useMaterials } from "./features/materials/useMaterials"; +import { + addResultNeedsNotice, + materialFailureKey, + materialRejectionKey, + type MaterialCommand +} from "./features/materials/materialCardModel"; interface HomeEditDraft { homeGridSize: HomeGridSize; @@ -104,6 +112,7 @@ const FALLBACK_SETTINGS: AppSettings = { sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, + persistMaterials: true, palette: "sage", homeAccentPreset: "classic", homeAccentColors: { ...DEFAULT_HOME_ACCENT_COLORS }, @@ -336,6 +345,9 @@ export function App(): React.JSX.Element { }); const showToast = useCallback((message: string): void => setToast(message), []); + const materials = useMaterials(); + const materialsRef = useRef(materials.materials); + materialsRef.current = materials.materials; const openUpdates = useCallback((): void => { setSettingsOpen(true); @@ -594,6 +606,7 @@ export function App(): React.JSX.Element { ...sessionsRef.current, ...currentSettings.pluginCanvas, ...currentSettings.stickyNotes, + ...materialsRef.current, ...(currentSettings.browserCanvas ? [currentSettings.browserCanvas] : []), ...pendingSessionPlacements.current ], @@ -851,6 +864,9 @@ export function App(): React.JSX.Element { const stickyNotes = settingsRef.current.stickyNotes.map((note) => boundsInsideRegion(note, previous) ? { ...note, ...translateBounds(note, delta) } : note); + for (const material of materialsRef.current) { + if (boundsInsideRegion(material, previous)) materials.setBounds(material.id, translateBounds(material, delta)); + } setSessions(movedSessions); patch.pluginCanvas = pluginCanvas; patch.browserCanvas = browserCanvas; @@ -862,7 +878,68 @@ export function App(): React.JSX.Element { settingsRef.current = { ...settingsRef.current, ...patch }; setSettings((current) => ({ ...current, ...patch })); void saveSettings(patch); - }, [saveSettings, sessions]); + }, [materials, saveSettings, sessions]); + + const reportMaterialsAdded = useCallback((result: MaterialsAddResult): void => { + if (!addResultNeedsNotice(result)) return; + const locale = settingsRef.current.locale; + if (result.rejected.length > 0) { + const details = result.rejected + .map((rejection) => `${rejection.name}: ${t(locale, materialRejectionKey(rejection.reason))}`) + .join("; "); + showToast(result.rejected.every((rejection) => rejection.reason === "empty-clipboard") + ? t(locale, "materialsEmptyClipboard") + : `${t(locale, "materialsNotAdded")} — ${details}`); + return; + } + const existing = materialsRef.current.find((material) => material.id === result.existing[0]); + showToast(t(locale, "materialsAlreadyOnCanvas")); + if (existing) { + isHomeCamera.current = false; + setCamera(focusCamera(existing.position, existing.size)); + } + }, [showToast]); + + const reportMaterialsFailure = useCallback((): void => { + showToast(t(settingsRef.current.locale, "materialsFailed")); + }, [showToast]); + + const addMaterialFiles = useCallback((files: File[], point: Point): void => { + void materials.addFiles(files, point).then(reportMaterialsAdded, reportMaterialsFailure); + }, [materials, reportMaterialsAdded, reportMaterialsFailure]); + + const pickMaterials = useCallback((point: Point): void => { + void materials.pick(point).then(reportMaterialsAdded, reportMaterialsFailure); + }, [materials, reportMaterialsAdded, reportMaterialsFailure]); + + const pasteMaterials = useCallback((point: Point): void => { + void materials.paste(point).then(reportMaterialsAdded, reportMaterialsFailure); + }, [materials, reportMaterialsAdded, reportMaterialsFailure]); + + const removeMaterial = useCallback((id: string): void => { + void materials.remove(id).catch(reportMaterialsFailure); + }, [materials, reportMaterialsFailure]); + + const runMaterialCommand = useCallback((id: string, command: MaterialCommand): void => { + const locale = settingsRef.current.locale; + const fail = (reason: Parameters[0]): void => { + const key = materialFailureKey(reason); + if (key) showToast(t(locale, key)); + }; + if (command === "reveal") { + void materials.reveal(id).catch(reportMaterialsFailure); + } else if (command === "copy-path") { + const location = materialsRef.current.find((material) => material.id === id)?.location; + if (!location) return; + window.canvasTTY.clipboard.writeText(location); + showToast(t(locale, "materialPathCopied")); + } else { + const request = command === "relink" ? materials.relink(id) : materials.acceptMove(id); + void request.then((result) => { + if (!result.ok) fail(result.reason); + }, reportMaterialsFailure); + } + }, [materials, reportMaterialsFailure, showToast]); const deleteCanvasRegion = useCallback((id: string): void => { const canvasRegions = settingsRef.current.canvasRegions.filter((region) => region.id !== id); @@ -1470,6 +1547,14 @@ export function App(): React.JSX.Element { onStickyNoteBoundsChange={changeStickyNoteBounds} onStickyNoteTextChange={changeStickyNoteText} onDeleteStickyNote={deleteStickyNote} + materials={materials.materials} + onAddMaterialFiles={addMaterialFiles} + onPickMaterials={pickMaterials} + onPasteMaterials={pasteMaterials} + onMaterialBoundsChange={materials.setBounds} + onMaterialBoundsChangeBatch={materials.setBoundsBatch} + onRemoveMaterial={removeMaterial} + onMaterialCommand={runMaterialCommand} />} @@ -1555,6 +1640,9 @@ export function App(): React.JSX.Element { if (updateStatus.type === "available" || updateStatus.type === "ready") setDismissedUpdateNotice(updateNoticeKey(updateStatus)); }} /> + {materials.snapshot.loadError && ( +
{t(settings.locale, "materialsLoadFailed")}
+ )} ); diff --git a/src/renderer/src/assets/icons/lucide/README.md b/src/renderer/src/assets/icons/lucide/README.md index d554a3de..ba3bcd5c 100644 --- a/src/renderer/src/assets/icons/lucide/README.md +++ b/src/renderer/src/assets/icons/lucide/README.md @@ -3,7 +3,7 @@ These SVG files are unmodified icons downloaded from the official [`lucide-icons/lucide`](https://github.com/lucide-icons/lucide) repository on 2026-08-04, with menu and Settings navigation additions fetched from the same -source on 2026-08-30. +source on 2026-08-30 and canvas material additions on 2026-09-28. They are vendored as local assets so CanvasTTY does not need to execute or fetch an icon package from npm. diff --git a/src/renderer/src/assets/icons/lucide/clipboard-paste.svg b/src/renderer/src/assets/icons/lucide/clipboard-paste.svg new file mode 100644 index 00000000..6ac59335 --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/clipboard-paste.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/renderer/src/assets/icons/lucide/ellipsis.svg b/src/renderer/src/assets/icons/lucide/ellipsis.svg new file mode 100644 index 00000000..68456c39 --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/ellipsis.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/src/renderer/src/assets/icons/lucide/file-search.svg b/src/renderer/src/assets/icons/lucide/file-search.svg new file mode 100644 index 00000000..0a840b82 --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/file-search.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/src/renderer/src/assets/icons/lucide/file-text.svg b/src/renderer/src/assets/icons/lucide/file-text.svg new file mode 100644 index 00000000..67dc5b9d --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/file-text.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/renderer/src/assets/icons/lucide/file.svg b/src/renderer/src/assets/icons/lucide/file.svg new file mode 100644 index 00000000..161be7a2 --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/file.svg @@ -0,0 +1,14 @@ + + + + diff --git a/src/renderer/src/assets/icons/lucide/film.svg b/src/renderer/src/assets/icons/lucide/film.svg new file mode 100644 index 00000000..fae75686 --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/film.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/src/renderer/src/assets/icons/lucide/folder-open.svg b/src/renderer/src/assets/icons/lucide/folder-open.svg new file mode 100644 index 00000000..b01f60fc --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/folder-open.svg @@ -0,0 +1,13 @@ + + + diff --git a/src/renderer/src/assets/icons/lucide/image.svg b/src/renderer/src/assets/icons/lucide/image.svg new file mode 100644 index 00000000..3241134c --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/image.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/src/renderer/src/assets/icons/lucide/music.svg b/src/renderer/src/assets/icons/lucide/music.svg new file mode 100644 index 00000000..dd9faaca --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/music.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/src/renderer/src/components/UiIcon.tsx b/src/renderer/src/components/UiIcon.tsx index 4dd2db9f..69408b0c 100644 --- a/src/renderer/src/components/UiIcon.tsx +++ b/src/renderer/src/components/UiIcon.tsx @@ -9,6 +9,15 @@ import copyIcon from "../assets/icons/lucide/copy.svg"; import downloadIcon from "../assets/icons/lucide/download.svg"; import errorIcon from "../assets/icons/lucide/circle-alert.svg"; import folderIcon from "../assets/icons/lucide/folder.svg"; +import folderOpenIcon from "../assets/icons/lucide/folder-open.svg"; +import clipboardPasteIcon from "../assets/icons/lucide/clipboard-paste.svg"; +import ellipsisIcon from "../assets/icons/lucide/ellipsis.svg"; +import fileIcon from "../assets/icons/lucide/file.svg"; +import fileSearchIcon from "../assets/icons/lucide/file-search.svg"; +import fileTextIcon from "../assets/icons/lucide/file-text.svg"; +import filmIcon from "../assets/icons/lucide/film.svg"; +import imageIcon from "../assets/icons/lucide/image.svg"; +import musicIcon from "../assets/icons/lucide/music.svg"; import browserIcon from "../assets/icons/lucide/globe.svg"; import bringToFrontIcon from "../assets/icons/lucide/bring-to-front.svg"; import homeIcon from "../assets/icons/lucide/house.svg"; @@ -58,6 +67,15 @@ export type UiIconName = | "search" | "sticky-note" | "image-plus" + | "image" + | "file" + | "file-text" + | "file-search" + | "film" + | "music" + | "folder-open" + | "clipboard-paste" + | "ellipsis" | "trash" | "working" | "attention" @@ -98,6 +116,15 @@ const ICONS: Record = { search: searchIcon, "sticky-note": stickyNoteIcon, "image-plus": imagePlusIcon, + image: imageIcon, + file: fileIcon, + "file-text": fileTextIcon, + "file-search": fileSearchIcon, + film: filmIcon, + music: musicIcon, + "folder-open": folderOpenIcon, + "clipboard-paste": clipboardPasteIcon, + ellipsis: ellipsisIcon, trash: trashIcon, working: workingIcon, attention: attentionIcon, diff --git a/src/renderer/src/features/materials/MaterialCard.tsx b/src/renderer/src/features/materials/MaterialCard.tsx new file mode 100644 index 00000000..5eeafbbb --- /dev/null +++ b/src/renderer/src/features/materials/MaterialCard.tsx @@ -0,0 +1,368 @@ +import { useEffect, useRef, useState } from "react"; +import type { CanvasMaterial, LocaleId, Point, SessionBounds } from "../../../../shared/contracts"; +import { constrainMaterialResize, MATERIAL_MAX_SIZE, MATERIAL_MIN_SIZE, materialUrl } from "../../../../shared/materials"; +import { UiIcon } from "../../components/UiIcon"; +import { t } from "../../lib/i18n"; +import { materialLayerId } from "../workspace/canvasSelectionGesture"; +import { SUMMARY_ZOOM, summaryScaleForZoom, useCameraSelector, type CameraStore } from "../workspace/cameraStore"; +import { snapMove, snapResize, type ResizeDirection } from "../workspace/snap"; +import { + formatBytes, + materialFolder, + materialIcon, + materialRemovalLosesData, + type MaterialCommand +} from "./materialCardModel"; + +interface MaterialCardProps { + material: CanvasMaterial; + locale: LocaleId; + camera: CameraStore; + stackIndex: number; + snapEnabled: boolean; + getSnapTargets(): readonly SessionBounds[]; + groupSelected?: boolean; + removeRequest: number; + onBoundsChange(id: string, bounds: SessionBounds): void; + onRemove(id: string): void; + onOpenMenu(id: string, client: Point): void; + onAction(id: string, action: MaterialCommand): void; +} + +interface DragState { + pointerId: number; + startClient: Point; + startBounds: SessionBounds; +} + +interface ResizeState extends DragState { + direction: ResizeDirection; +} + +const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]; + +export function MaterialCard({ + material, + locale, + camera, + stackIndex, + snapEnabled, + getSnapTargets, + groupSelected = false, + removeRequest, + onBoundsChange, + onRemove, + onOpenMenu, + onAction +}: MaterialCardProps): React.JSX.Element { + const dragState = useRef(null); + const resizeState = useRef(null); + const liveBounds = useRef({ position: material.position, size: material.size }); + const [position, setPosition] = useState(material.position); + const [size, setSize] = useState(material.size); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + const summaryMode = useCameraSelector(camera, (current) => current.zoom < SUMMARY_ZOOM); + const summaryScale = useCameraSelector(camera, (current) => summaryScaleForZoom(current.zoom)); + const subtitle = material.origin?.kind === "clipboard" + ? t(locale, "materialFromClipboard") + : material.origin?.kind === "browser" + ? material.origin.url + : materialFolder(material.location) ?? ""; + + useEffect(() => { + const bounds = { position: material.position, size: material.size }; + liveBounds.current = bounds; + setPosition(bounds.position); + setSize(bounds.size); + }, [material.position, material.size]); + + const requestRemoval = (): void => { + if (materialRemovalLosesData(material)) setConfirmingRemoval(true); + else onRemove(material.id); + }; + const requestRemovalRef = useRef(requestRemoval); + requestRemovalRef.current = requestRemoval; + + useEffect(() => { + if (removeRequest > 0) requestRemovalRef.current(); + }, [removeRequest]); + + const applyBounds = (bounds: SessionBounds): void => { + liveBounds.current = bounds; + setPosition(bounds.position); + setSize(bounds.size); + }; + + const startDrag = (event: React.PointerEvent): void => { + if (event.button !== 0 || (event.target as HTMLElement).closest("button")) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + dragState.current = { + pointerId: event.pointerId, + startClient: { x: event.clientX, y: event.clientY }, + startBounds: liveBounds.current + }; + }; + + const drag = (event: React.PointerEvent): void => { + const state = dragState.current; + if (!state || state.pointerId !== event.pointerId || event.buttons === 0) return; + const rawPosition = { + x: state.startBounds.position.x + (event.clientX - state.startClient.x) / camera.get().zoom, + y: state.startBounds.position.y + (event.clientY - state.startClient.y) / camera.get().zoom + }; + applyBounds({ + position: snapEnabled ? snapMove(rawPosition, state.startBounds.size, getSnapTargets()) : rawPosition, + size: state.startBounds.size + }); + }; + + const endDrag = (event: React.PointerEvent): void => { + if (dragState.current?.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + dragState.current = null; + onBoundsChange(material.id, liveBounds.current); + }; + + const cancelDrag = (): void => { + dragState.current = null; + }; + + const startResize = (event: React.PointerEvent, direction: ResizeDirection): void => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + resizeState.current = { + pointerId: event.pointerId, + direction, + startClient: { x: event.clientX, y: event.clientY }, + startBounds: liveBounds.current + }; + }; + + const resize = (event: React.PointerEvent): void => { + const state = resizeState.current; + if (!state || state.pointerId !== event.pointerId || event.buttons === 0) return; + event.preventDefault(); + event.stopPropagation(); + const deltaX = (event.clientX - state.startClient.x) / camera.get().zoom; + const deltaY = (event.clientY - state.startClient.y) / camera.get().zoom; + const constrained = constrainMaterialResize({ + position: { + x: state.startBounds.position.x + (state.direction.includes("w") ? deltaX : 0), + y: state.startBounds.position.y + (state.direction.includes("n") ? deltaY : 0) + }, + size: { + width: state.startBounds.size.width + + (state.direction.includes("e") ? deltaX : 0) + - (state.direction.includes("w") ? deltaX : 0), + height: state.startBounds.size.height + + (state.direction.includes("s") ? deltaY : 0) + - (state.direction.includes("n") ? deltaY : 0) + } + }, state.direction); + applyBounds(snapEnabled + ? snapResize(constrained, state.direction, getSnapTargets(), { min: MATERIAL_MIN_SIZE, max: MATERIAL_MAX_SIZE }) + : constrained); + }; + + const endResize = (event: React.PointerEvent): void => { + if (resizeState.current?.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + resizeState.current = null; + onBoundsChange(material.id, liveBounds.current); + }; + + const cancelResize = (): void => { + resizeState.current = null; + }; + + return ( +
+
+ + + + {material.name} + {subtitle && {subtitle}} + + + + + + +
+
+ {confirmingRemoval ? ( + + + + + ) : ( + + )} +
+
+ + {material.name} + {t(locale, materialKindLabel(material.kind))} + +
+ {RESIZE_DIRECTIONS.map((direction) => ( +
+ ); +} + +function MaterialBody({ + material, + locale, + onAction +}: { + material: CanvasMaterial; + locale: LocaleId; + onAction(id: string, action: MaterialCommand): void; +}): React.JSX.Element { + const [failed, setFailed] = useState(false); + const source = materialUrl(material.id, null, material.liveRevision); + + useEffect(() => setFailed(false), [source]); + + if (material.state === "missing") { + return ( + + + + ); + } + if (material.state === "moved") { + return ( + + + + + ); + } + if (material.state === "unreadable") { + return ( + + {material.location && ( + + )} + + ); + } + if (!failed && material.kind === "image") { + return {material.name} setFailed(true)} />; + } + if (!failed && material.kind === "video") { + return