diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 11cff863c..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"1676c542-49ae-4b07-80db-808ac138cb4b","pid":24538,"procStart":"Fri Apr 24 04:52:14 2026","acquiredAt":1777006545124} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 705f05441..4e7d78caa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules *.png !docs/**/*.png !apps/web/public/**/*.png +!apps/ios/**/*.png # Python cache __pycache__/ @@ -45,6 +46,7 @@ release-stable/ # Xcode user data & derived data xcuserdata/ *.xcuserstate +/.derived-data/ apps/ios/.dry-run-derived-data/ apps/ios/build/ ios-signing/ diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 882c5a8d5..c8e509f5b 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -2902,8 +2902,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", - "dev": true, - "requires": {} + "dev": true }, "@types/estree": { "version": "1.0.8", @@ -3150,8 +3149,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "requires": {} + "dev": true }, "fix-dts-default-cjs-exports": { "version": "1.0.1", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 8a53a1ab4..7bb97d72a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -50,6 +50,7 @@ import { upsertProjectRow, } from "./services/projects/projectService"; import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary"; +import { resolveProjectIcon } from "./services/projects/projectIconResolver"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; @@ -955,6 +956,10 @@ app.whenReady().then(async () => { }); if (activeProjectRoot) { projectLastActivatedAt.set(activeProjectRoot, Date.now()); + const activeCtx = projectContexts.get(activeProjectRoot); + if (activeCtx) { + persistRecentProject(activeCtx.project, { recordLastProject: false }); + } try { adeArtifactAllowedDir = resolveAdeLayout(activeProjectRoot).artifactsDir; @@ -3938,6 +3943,7 @@ app.whenReady().then(async () => { rootPath: ctx.project.rootPath, defaultBaseRef: ctx.project.baseRef, lastOpenedAt: recent?.summary.lastOpenedAt ?? null, + iconDataUrl: mobileProjectIconDataUrl(ctx.project.rootPath), laneCount, isAvailable: fs.existsSync(ctx.project.rootPath), isCached: false, @@ -3953,6 +3959,7 @@ app.whenReady().then(async () => { rootPath: recent.summary.rootPath, defaultBaseRef: recent.defaultBaseRef, lastOpenedAt: recent.summary.lastOpenedAt, + iconDataUrl: mobileProjectIconDataUrl(recent.summary.rootPath), laneCount: recent.summary.laneCount ?? 0, isAvailable: recent.summary.exists, isCached: false, @@ -3960,6 +3967,22 @@ app.whenReady().then(async () => { }; } + function mobileProjectIconDataUrl(projectRoot: string): string | null { + try { + const icon = resolveProjectIcon(projectRoot); + if (!icon.sourcePath) return null; + + const image = nativeImage.createFromPath(icon.sourcePath); + if (!image.isEmpty()) { + return image.resize({ width: 64, height: 64, quality: "best" }).toDataURL(); + } + + return icon.mimeType === "image/png" ? icon.dataUrl : null; + } catch { + return null; + } + } + async function listMobileSyncProjects(): Promise<{ projects: SyncMobileProjectSummary[] }> { const recentProjects = (readGlobalState(globalStatePath).recentProjects ?? []) .map(inspectRecentProject); @@ -3971,9 +3994,11 @@ app.whenReady().then(async () => { byRoot.set(normalizeProjectRoot(recent.summary.rootPath), mobileProjectSummaryForRecent(recent)); } const contextSummaries = await Promise.all( - [...projectContexts.entries()].map(async ([root, ctx]) => - [root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const - ), + [...projectContexts.entries()] + .filter(([root]) => recentByRoot.has(root)) + .map(async ([root, ctx]) => + [root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const + ), ); for (const [root, summary] of contextSummaries) { byRoot.set(root, summary); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 5c138d938..9cac407c9 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, nativeImage, shell } from "electron"; import { createEmptyAutoUpdateSnapshot, type createAutoUpdateService } from "../updates/autoUpdateService"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -13,6 +13,7 @@ import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../ import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; import { browseProjectDirectories } from "../projects/projectBrowserService"; import { getProjectDetail } from "../projects/projectDetailService"; +import { resolveProjectIcon } from "../projects/projectIconResolver"; import { runGit } from "../git/git"; import type { AdeCleanupResult, AdeProjectSnapshot } from "../../../shared/types"; import { toRecentProjectSummary } from "../projects/recentProjectSummary"; @@ -258,6 +259,7 @@ import type { ProjectBrowseInput, ProjectBrowseResult, ProjectDetail, + ProjectIcon, ProjectInfo, RecentProjectSummary, PtyCreateArgs, @@ -1890,6 +1892,107 @@ export function registerIpc({ return path.resolve(path.isAbsolute(inputPath) ? inputPath : path.join(projectRoot, inputPath)); }; + const resolveAllowedRendererPath = (rawPath: string): string => { + const raw = typeof rawPath === "string" ? rawPath.trim() : ""; + if (!raw) throw new Error("Missing path."); + const ctx = getCtx(); + const normalized = resolveRendererSuppliedPath(raw, ctx.project.rootPath); + const allowedDirs = getAllowedDirs(getCtx); + // resolvePathWithinRoot follows symlinks via fs.realpath while validating + // containment, so we both reject symlinks pointing outside the allowlist + // *and* return the canonical real path for callers to read from. Returning + // the lexical path would still be safe because the check resolved real + // paths, but handing back the realpath avoids any TOCTOU-adjacent surprises + // and keeps file I/O pinned to the validated target. + let resolved: string | null = null; + for (const dir of allowedDirs) { + try { + resolved = resolvePathWithinRoot(dir, normalized); + break; + } catch { + // try next allowed dir + } + } + if (!resolved) { + throw new Error("Path is outside allowed directories."); + } + return resolved; + }; + + /** + * Sniff the first bytes of a buffer for known image magic numbers and + * return the corresponding MIME type. Returns null if the buffer doesn't + * match any supported image format. + * + * We deliberately do NOT trust the file extension here — extension-only + * inference would let a renderer hand us any allow-listed file (text, + * binary, etc.) and get it back as a base64 `image/png` data URL. + */ + const sniffImageMimeType = (buffer: Buffer): string | null => { + if (buffer.length >= 8 + && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47 + && buffer[4] === 0x0D && buffer[5] === 0x0A && buffer[6] === 0x1A && buffer[7] === 0x0A) { + return "image/png"; + } + if (buffer.length >= 3 + && buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { + return "image/jpeg"; + } + if (buffer.length >= 6 + && buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38 + && (buffer[4] === 0x37 || buffer[4] === 0x39) && buffer[5] === 0x61) { + return "image/gif"; + } + if (buffer.length >= 12 + && buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 + && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { + return "image/webp"; + } + if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4D) { + return "image/bmp"; + } + if (buffer.length >= 4 + && buffer[0] === 0x00 && buffer[1] === 0x00 + && buffer[2] === 0x01 && buffer[3] === 0x00) { + return "image/x-icon"; + } + // SVG/XML: scan a small prefix as text so leading whitespace, BOM, or an + // declaration before are tolerated. + const head = buffer.slice(0, Math.min(buffer.length, 1024)).toString("utf8"); + const stripped = head.replace(/^/, "").trimStart(); + if (/^<\?xml\b/i.test(stripped) && / => { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) { + throw new Error("Path is not a file."); + } + if (stat.size > MAX_IMAGE_BYTES) { + throw new Error("Image must be 10 MB or smaller."); + } + const data = await fs.promises.readFile(filePath); + const mimeType = sniffImageMimeType(data); + if (!mimeType) { + throw new Error("Path is not an image."); + } + return { data, mimeType }; + }; + ipcMain.handle(IPC.appRevealPath, async (_event, arg: { path: string }): Promise => { const raw = typeof arg?.path === "string" ? arg.path.trim() : ""; if (!raw) return; @@ -1940,6 +2043,35 @@ export function registerIpc({ clipboard.writeText(text); }); + ipcMain.handle(IPC.appGetImageDataUrl, async (_event, arg: { path: string }): Promise<{ dataUrl: string }> => { + const filePath = resolveAllowedRendererPath(arg?.path); + // Use async fs APIs and a size pre-check so a 10 MB image read never + // blocks the main process event loop (input dispatch, IPC, window + // animations all share that loop). The MIME type is derived from the + // file's *bytes*, not its extension, so a renderer can't smuggle + // arbitrary text/binary back as a base64 `image/png` data URL. + const { data, mimeType } = await readImageFileAndSniffMime(filePath); + return { + dataUrl: `data:${mimeType};base64,${data.toString("base64")}`, + }; + }); + + ipcMain.handle(IPC.appWriteClipboardImage, async (_event, arg: { path: string }): Promise => { + const filePath = resolveAllowedRendererPath(arg?.path); + // Apply the same size + magic-byte preflight as `appGetImageDataUrl` so + // we can't hand `nativeImage.createFromPath` a giant or non-image file + // (which would otherwise silently produce an empty image, or worse, + // attempt a sync read of a 100 MB binary on the main process). We then + // hand the already-read buffer to `nativeImage.createFromBuffer` so the + // file isn't read a second time off the main thread. + const { data } = await readImageFileAndSniffMime(filePath); + const image = nativeImage.createFromBuffer(data); + if (image.isEmpty()) { + throw new Error("Unable to read image."); + } + clipboard.writeImage(image); + }); + ipcMain.handle( IPC.appOpenPathInEditor, async ( @@ -2146,6 +2278,54 @@ export function registerIpc({ } ); + // Project-root allowlist for icon resolution. Tab/catalog icons are + // resolved for the *current* project root and any *recently opened* + // project root — including ones that live outside Downloads/Documents/Temp + // (the generic `getAllowedDirs` set). Using `resolveAllowedRendererPath` + // here would silently strip icons for any project in `~/code/*` etc. + const getAllowedProjectRoots = (): string[] => { + const state = readGlobalState(globalStatePath); + return Array.from(new Set([ + getCtx().project.rootPath, + ...(state.recentProjects ?? []) + .map((entry) => entry.rootPath) + .filter((root): root is string => typeof root === "string" && root.trim().length > 0), + ])); + }; + + const resolveAllowedProjectRoot = (rawPath: string): string => { + const raw = typeof rawPath === "string" ? rawPath.trim() : ""; + if (!raw) throw new Error("Missing root path."); + const normalized = resolveRendererSuppliedPath(raw, getCtx().project.rootPath); + for (const dir of getAllowedProjectRoots()) { + try { + return resolvePathWithinRoot(dir, normalized); + } catch { + // try next known project root + } + } + throw new Error("rootPath is outside known project roots."); + }; + + ipcMain.handle( + IPC.projectResolveIcon, + async (_event, args: { rootPath: string }): Promise => { + const rootPath = typeof args?.rootPath === "string" ? args.rootPath.trim() : ""; + if (!rootPath) return { dataUrl: null, sourcePath: null, mimeType: null }; + // Validate the renderer-supplied root against the project-root + // allowlist (current + recent projects) so a compromised renderer + // can't probe arbitrary directories for icons, while still serving + // icons for projects that live outside the generic file allowlist. + let validatedRoot: string; + try { + validatedRoot = resolveAllowedProjectRoot(rootPath); + } catch { + return { dataUrl: null, sourcePath: null, mimeType: null }; + } + return resolveProjectIcon(validatedRoot); + }, + ); + ipcMain.handle(IPC.projectOpenAdeFolder, async (): Promise => { const ctx = getCtx(); await shell.openPath(ctx.adeDir); diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index def36e16f..c550559dd 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -3278,9 +3278,11 @@ describe("aiOrchestratorService", () => { }) as typeof fixture.orchestratorService.onTrackedSessionEnded; const firstSweep = fixture.aiOrchestratorService.runHealthSweep("overlap-owner"); - // CI runners can be heavily loaded; give the first sweep up to ~5s to reach - // the gated `onTrackedSessionEnded` call before we assert it was invoked. - for (let tries = 0; tries < 200 && reconcileCalls === 0; tries += 1) { + // CI runners can be heavily loaded; give the first sweep up to ~15s to + // reach the gated `onTrackedSessionEnded` call before we assert it was + // invoked. The test passes immediately when the callback fires, so the + // generous ceiling only kicks in for slow/contended runners. + for (let tries = 0; tries < 600 && reconcileCalls === 0; tries += 1) { await new Promise((resolve) => setTimeout(resolve, 25)); } expect(reconcileCalls).toBe(1); @@ -3295,7 +3297,7 @@ describe("aiOrchestratorService", () => { releaseFirstSweep(); fixture.dispose(); } - }, 15_000); + }, 30_000); it("skips background health sweeps for runs blocked on open interventions", async () => { const fixture = await createFixture(); diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts new file mode 100644 index 000000000..c5d6633a4 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { resolveProjectIcon, resolveProjectIconPath } from "./projectIconResolver"; + +function makeProjectRoot(): string { + // Resolve through realpath so the assertions still hold on platforms + // (macOS) where the system tmpdir is itself a symlink (e.g. `/var` -> + // `/private/var`). The resolver returns canonical realpaths for callers. + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-icon-"))); +} + +function writeFile(root: string, relativePath: string, contents: string | Buffer): string { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); + return filePath; +} + +describe("projectIconResolver", () => { + it("prefers well-known favicon files", () => { + const root = makeProjectRoot(); + const iconPath = writeFile(root, "favicon.svg", "favicon"); + + expect(resolveProjectIconPath(root)).toBe(iconPath); + }); + + it("resolves icon hrefs from project source files", () => { + const root = makeProjectRoot(); + writeFile(root, "index.html", '
@@ -190,20 +191,64 @@ function PersistentWorkSurface({ active }: { active: boolean }) { const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); const project = useAppStore((s) => s.project); + const workSurfaceRef = React.useRef(null); + + // Only fire the reveal once the surface is *actually* mounted with a + // project. On a cold `/work` boot the route renders before `projectHydrated` + // / `project.rootPath` settle; firing the reveal here would notify + // listeners about a surface that's still showing the loading fallback. + const hasActiveProject = Boolean(project?.rootPath); + const shouldReveal = active && projectHydrated && hasActiveProject && !showWelcome; + React.useEffect(() => { + if (!shouldReveal) return; + const raf = window.requestAnimationFrame(() => { + dispatchWorkSurfaceRevealed(); + }); + const settleTimer = window.setTimeout(() => { + dispatchWorkSurfaceRevealed(); + }, 120); + return () => { + window.cancelAnimationFrame(raf); + window.clearTimeout(settleTimer); + }; + }, [shouldReveal]); + + React.useEffect(() => { + const node = workSurfaceRef.current; + // The `
` below is gated by the `projectHydrated` + // / `hasActiveProject` / `showWelcome` early returns, so on a cold `/work` + // boot the ref is null on the first run when `active` flips true. Re-run + // once those guards settle so the inert state lands on the real node. + if (!node) return; + if (active) { + node.removeAttribute("inert"); + } else { + node.setAttribute("inert", ""); + } + }, [active, projectHydrated, hasActiveProject, showWelcome]); if (!projectHydrated) { return active ? GuardLoadingFallback : null; } - const hasActiveProject = Boolean(project?.rootPath); if (!hasActiveProject || showWelcome) { return active ? : null; } return (