diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 00a6c4bb0b2..75e78863694 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -129,6 +129,7 @@ const commandLoaders = { preview: () => assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)), publish: () => import("./commands/publish.js").then((m) => m.default), + open: () => import("./commands/open.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), check: () => import("./commands/check.js").then((m) => m.default), diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts new file mode 100644 index 00000000000..db601523191 --- /dev/null +++ b/packages/cli/src/commands/open.ts @@ -0,0 +1,66 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; +import { setCommandExitCode } from "../utils/commandResult.js"; +import { DOWNLOAD_HINT, openInDesktop, type DesktopOpenResult } from "../utils/desktopApp.js"; +import { resolveProject, resolveProjectOrThrow, type ProjectDir } from "../utils/project.js"; + +export const examples: Example[] = [ + ["Open this project in HyperFrames Studio", "hyperframes open"], + ["Open another project", "hyperframes open ./my-video"], + ["For agents", "hyperframes open --json"], +]; + +const WHY_NOT: Record["reason"], string | null> = { + "handoff-unavailable": null, + "unsupported-platform": "Opening a project from the CLI works on macOS only.", + "not-installed": "HyperFrames Studio isn't installed on this Mac.", + "open-failed": "macOS couldn't open HyperFrames Studio.", +}; + +function printResult(project: ProjectDir, result: DesktopOpenResult): void { + if (result.opened) { + console.log(`${c.success("◇")} Opening ${c.accent(project.name)} in HyperFrames Studio`); + if (result.handedOver) { + const agent = result.handedOver.engine === "claude" ? "Claude Code" : "Codex"; + console.log(` ${c.dim(`Its chat picks up this ${agent} conversation.`)}`); + } + return; + } + const why = WHY_NOT[result.reason]; + if (!why) return console.log(`${c.warn("◇")} ${DOWNLOAD_HINT}`); + console.log(`${c.warn("◇")} ${why}`); + console.log(` Download it: ${c.accent(result.downloadUrl)}`); +} + +/** Under --json a bad directory answers in JSON too, not with the human error box. */ +function projectForJson(dir: string | undefined): ProjectDir | null { + try { + return resolveProjectOrThrow(dir); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(JSON.stringify({ ok: false, error: { code: "invalid-project", message } })); + setCommandExitCode(1); + return null; + } +} + +export default defineCommand({ + meta: { name: "open", description: "Open a project in HyperFrames Studio, the desktop app" }, + args: { + dir: { + type: "positional", + description: "Project directory (default: current)", + required: false, + }, + json: { type: "boolean", description: "Output as JSON", default: false }, + }, + run({ args }) { + const project = args.json ? projectForJson(args.dir) : resolveProject(args.dir); + if (!project) return; + const result = openInDesktop(project.dir); + if (!result.opened) setCommandExitCode(1); + if (args.json) console.log(JSON.stringify({ project: project.dir, ...result }, null, 2)); + else printResult(project, result); + }, +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index a134b2615ad..4e29f45d10f 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -39,6 +39,7 @@ import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; +import { desktopHint } from "../utils/desktopApp.js"; import { isDevMode } from "../utils/env.js"; import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js"; import { buildNpxCommand } from "../utils/npxCommand.js"; @@ -365,7 +366,7 @@ export default defineCommand({ ); return; } - printStudioSummary(project.name, previewBaseUrl(status.port), { + printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, { details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`], }); return; @@ -539,7 +540,7 @@ export default defineCommand({ ); } else { clack.intro(c.bold("hyperframes preview")); - printStudioSummary(projectName, url, { + printStudioSummary(projectName, url, dir, { details: [ background.type === "reused" ? "Reusing the background server already running for this project." @@ -1282,6 +1283,7 @@ function openStudioBrowser(url: string, projectName: string, options?: BrowserLa function printStudioSummary( projectName: string, serverUrl: string, + projectDir: string, opts: { details?: string[]; footer?: string } = {}, ): void { const urls = studioSummaryUrls(projectName, serverUrl); @@ -1289,6 +1291,8 @@ function printStudioSummary( console.log(` ${c.dim("Project")} ${c.accent(projectName)}`); console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`); console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`); + const hint = desktopHint(projectDir); + if (hint) console.log(` ${c.dim("Desktop")} ${hint}`); console.log(); for (const detail of opts.details ?? []) { console.log(` ${c.dim(detail)}`); @@ -1395,7 +1399,7 @@ function attachStudioReadyHandler( ); } else { spinner.stop(c.success("Studio running")); - printStudioSummary(projectName, url, { + printStudioSummary(projectName, url, projectDir, { footer: "Press Ctrl+C to stop", }); } @@ -1629,7 +1633,7 @@ async function runEmbeddedMode( ); } else { s.stop(c.success("Already running")); - printStudioSummary(pName, url, { + printStudioSummary(pName, url, dir, { details: ["Reusing existing server. Use --force-new to start a fresh instance."], }); } @@ -1647,7 +1651,7 @@ async function runEmbeddedMode( console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); console.log(); } - printStudioSummary(pName, url, { + printStudioSummary(pName, url, dir, { details: [ "Edit with your AI agent — it has HyperFrames skills installed.", "Changes reload automatically in the studio.", diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index df47984c8a4..e6fed5043ce 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -62,6 +62,7 @@ import { resolve, dirname, join, basename } from "node:path"; import { execFileSync, spawn } from "node:child_process"; import { loadProducer } from "../utils/producer.js"; import { c } from "../ui/colors.js"; +import { desktopHint } from "../utils/desktopApp.js"; import { formatBytes, formatRenderSummaryDetail, @@ -524,6 +525,8 @@ export interface RenderOptions { throwOnError?: boolean; /** Skip the interactive feedback prompt after a successful render. */ skipFeedback?: boolean; + /** False for a batch row: one line about the desktop app per batch is noise, not a pointer. */ + desktopHint?: boolean; /** * OPT IN to managing the DE parallel-router circuit breaker * (`applyDeParallelRouterCircuitBreaker`) for this render. Default OFF — @@ -886,6 +889,8 @@ async function renderDocker( runPostRenderStep("printRenderComplete", () => printRenderComplete({ outputPath, + projectDir, + desktopHint: wantsDesktopHint(options), elapsedMs: elapsed, quiet: options.quiet, format: options.format, @@ -1138,6 +1143,8 @@ async function executeLocalRender( runPostRenderStep("printRenderComplete", () => printRenderComplete({ outputPath, + projectDir, + desktopHint: wantsDesktopHint(options), elapsedMs: elapsed, quiet: options.quiet, format: options.format, @@ -1898,8 +1905,16 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector } } +/** A delivered render points to the desktop app; a draft or a batch row does not. */ +export function wantsDesktopHint(options: Pick): boolean { + return options.desktopHint !== false && options.quality !== "draft"; +} + function printRenderComplete(input: { outputPath: string; + projectDir: string; + /** Print the desktop-app line: a delivered render, never a draft or a batch row. */ + desktopHint: boolean; elapsedMs: number; quiet: boolean; format: RenderFormat; @@ -1920,6 +1935,8 @@ function printRenderComplete(input: { console.log(c.success("\u25C7") + " " + c.accent(outputPath)); console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail)); if (perf) printRenderPipeline(perf, input.requestedGpuMode); + const hint = input.desktopHint ? desktopHint(input.projectDir) : null; + if (hint) console.log(" " + c.dim(hint)); } function printRenderPipeline( diff --git a/packages/cli/src/commands/render/execute.test.ts b/packages/cli/src/commands/render/execute.test.ts index 82a94bb492f..075c3f79a70 100644 --- a/packages/cli/src/commands/render/execute.test.ts +++ b/packages/cli/src/commands/render/execute.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRenderPlan } from "./plan.js"; -import { renderOptionsFromPlan } from "./execute.js"; +import { batchRowRenderOptions, renderOptionsFromPlan } from "./execute.js"; +import { wantsDesktopHint } from "../render.js"; describe("renderOptionsFromPlan", () => { let projectDir: string; @@ -40,3 +41,19 @@ describe("renderOptionsFromPlan", () => { expect(on.variables).toEqual({ title: "x" }); }); }); + +describe("the desktop-app line after a render", () => { + it("comes after a delivered render, never after a draft or a batch row", () => { + expect(wantsDesktopHint({ quality: "high" })).toBe(true); + expect(wantsDesktopHint({ quality: "draft" })).toBe(false); + expect(wantsDesktopHint({ quality: "high", desktopHint: false })).toBe(false); + const projectDir = mkdtempSync(join(tmpdir(), "hf-render-batch-")); + try { + writeFileSync(join(projectDir, "index.html"), '
'); + const row = batchRowRenderOptions(createRenderPlan({ dir: projectDir }), undefined); + expect(wantsDesktopHint(row)).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index a929c16ca7b..f85a55cbe59 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -320,16 +320,12 @@ async function runResolutionPreflight( failCommand(); } -async function executeBatchRender( +export function batchRowRenderOptions( plan: RenderPlan, browserPath: string | undefined, - batchModule: typeof import("../batchRender.js"), - preparedBatch: import("../batchRender.js").PreparedBatchRender, - dependencies: RenderExecutionDependencies, - cancellation?: RenderCancellationScope, -): Promise { +): RenderOptions { const batchQuiet = plan.quiet || plan.batchJson; - const renderOptionsBase: RenderOptions = { + return { fps: plan.fps, quality: plan.quality, authoringSkill: plan.authoringSkill, @@ -365,8 +361,21 @@ async function executeBatchRender( exitAfterComplete: false, throwOnError: true, skipFeedback: true, + desktopHint: false, manageDeParallelRouterBreaker: plan.batchConcurrency <= 1, }; +} + +async function executeBatchRender( + plan: RenderPlan, + browserPath: string | undefined, + batchModule: typeof import("../batchRender.js"), + preparedBatch: import("../batchRender.js").PreparedBatchRender, + dependencies: RenderExecutionDependencies, + cancellation?: RenderCancellationScope, +): Promise { + const batchQuiet = plan.quiet || plan.batchJson; + const renderOptionsBase = batchRowRenderOptions(plan, browserPath); const manifest = await batchModule.runBatchRender({ prepared: preparedBatch, concurrency: plan.batchConcurrency, diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 236c919cd4b..34769aa7a8e 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -27,6 +27,7 @@ const GROUPS: Group[] = [ ["present", "Open a slideshow deck in presenter mode (with audience sync)"], ["publish", "Upload a project to a stable URL (private by default)"], ["render", "Render a composition to MP4 or WebM"], + ["open", "Open a project in HyperFrames Studio, the desktop app"], ], }, { diff --git a/packages/cli/src/server/desktopRoutes.test.ts b/packages/cli/src/server/desktopRoutes.test.ts new file mode 100644 index 00000000000..1fce1879702 --- /dev/null +++ b/packages/cli/src/server/desktopRoutes.test.ts @@ -0,0 +1,64 @@ +import { Hono } from "hono"; +import { describe, expect, it, vi } from "vitest"; +import type { DesktopOpenResult } from "../utils/desktopApp.js"; +import { mountDesktopRoutes, sameOriginPost } from "./desktopRoutes.js"; + +const STUDIO = { + host: "localhost:3002", + origin: "http://localhost:3002", + "sec-fetch-site": "same-origin", +}; +const OPENED: DesktopOpenResult = { + opened: true, + bundleId: "dev.hyperframes.desktop", + handedOver: null, +}; + +function server(ready: boolean) { + const app = new Hono(); + const open = vi.fn((_dir: string): DesktopOpenResult => OPENED); + mountDesktopRoutes(app, "/films/a", { ready, open }); + const post = (headers: Record) => + app.request("/api/open-in-desktop", { method: "POST", headers }); + return { app, open, post }; +} + +describe("open-in-desktop routes", () => { + it("tells Studio whether to show the button and whether the app can take the project", async () => { + const body = await (await server(false).app.request("/api/open-in-desktop")).json(); + expect(body).toEqual({ available: process.platform === "darwin", handoff: false }); + }); + + it("refuses a POST from another site, or through a rebound host name", async () => { + const { open, post } = server(true); + const crossSite = { ...STUDIO, origin: "https://evil.example", "sec-fetch-site": "cross-site" }; + expect((await post(crossSite)).status).toBe(403); + expect( + (await post({ ...STUDIO, host: "evil.example:3002", origin: "http://evil.example:3002" })) + .status, + ).toBe(403); + expect(open).not.toHaveBeenCalled(); + }); + + it("while gated, answers Studio's own POST with the download and opens nothing", async () => { + const { open, post } = server(false); + const res = await post(STUDIO); + expect(await res.json()).toMatchObject({ opened: false, reason: "handoff-unavailable" }); + expect(open).not.toHaveBeenCalled(); + }); + + it("once live, opens this server's project for Studio's own POST", async () => { + const { open, post } = server(true); + expect(await (await post(STUDIO)).json()).toEqual(OPENED); + expect(open).toHaveBeenCalledWith("/films/a"); + }); +}); + +describe("sameOriginPost", () => { + it("lets a local process without browser headers through, never a foreign origin", () => { + expect(sameOriginPost({ host: "127.0.0.1:3002" })).toBe(true); + expect(sameOriginPost({ host: "localhost:3002", origin: "http://localhost:3003" })).toBe(false); + expect(sameOriginPost({ host: "localhost:3002", fetchSite: "same-site" })).toBe(false); + expect(sameOriginPost({})).toBe(false); + }); +}); diff --git a/packages/cli/src/server/desktopRoutes.ts b/packages/cli/src/server/desktopRoutes.ts new file mode 100644 index 00000000000..43a16ebe084 --- /dev/null +++ b/packages/cli/src/server/desktopRoutes.ts @@ -0,0 +1,41 @@ +import type { Hono } from "hono"; +import { DESKTOP_DOWNLOAD_URL, HANDOFF_READY, openInDesktop } from "../utils/desktopApp.js"; +import { identityAllowed } from "./telemetryIdentity.js"; + +/** A bodiless POST is a simple request, so any page can send one to localhost: it has to come from this Studio. */ +export function sameOriginPost(headers: { + host?: string; + origin?: string; + fetchSite?: string; +}): boolean { + const { host, origin, fetchSite } = headers; + if (!host || !identityAllowed(host)) return false; + if (fetchSite && fetchSite !== "same-origin") return false; + return origin === undefined || origin === `http://${host}`; +} + +/** Studio's Edit with Framey: the GET says whether to show it (macOS) and whether the app can take the project. */ +export function mountDesktopRoutes( + app: Hono, + projectDir: string, + { ready = HANDOFF_READY, open = openInDesktop } = {}, +): void { + app.get("/api/open-in-desktop", (c) => + c.json({ available: process.platform === "darwin", handoff: ready }), + ); + app.post("/api/open-in-desktop", (c) => { + const allowed = sameOriginPost({ + host: c.req.header("host"), + origin: c.req.header("origin"), + fetchSite: c.req.header("sec-fetch-site"), + }); + if (!allowed) return c.json({ error: "forbidden" }, 403); + if (!ready) + return c.json({ + opened: false, + reason: "handoff-unavailable", + downloadUrl: DESKTOP_DOWNLOAD_URL, + }); + return c.json(open(projectDir)); + }); +} diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 7a40bae3acd..e1acf8f2ecb 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -32,6 +32,7 @@ import { resolveCliTelemetryDistinctId, } from "./telemetryIdentity.js"; import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js"; +import { mountDesktopRoutes } from "./desktopRoutes.js"; import { isDevMode } from "../utils/env.js"; import { runRenderSetupWorker } from "../utils/cancellableProcess.js"; import type { ProjectLintResult } from "@hyperframes/lint"; @@ -966,6 +967,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }); }); + mountDesktopRoutes(app, projectDir); + // ── Pre-flight checks for render ──────────────────────────────────────── // Intercept render requests before they reach the shared API so we can // fail fast with an actionable hint instead of burning through the entire diff --git a/packages/cli/src/utils/desktopApp.test.ts b/packages/cli/src/utils/desktopApp.test.ts new file mode 100644 index 00000000000..346c4a59738 --- /dev/null +++ b/packages/cli/src/utils/desktopApp.test.ts @@ -0,0 +1,172 @@ +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + AGENT_HANDOFF_FILE, + DESKTOP_DOWNLOAD_URL, + DOWNLOAD_HINT, + HANDOFF_READY, + agentSession, + desktopHint, + desktopInstalled, + openCommandFor, + openInDesktop, +} from "./desktopApp.js"; + +// The live path (ready) is what ships once the app takes handed-over folders; CI exercises it here. +const LIVE = { ready: true, platform: "darwin", env: {}, installed: () => true }; +const FILM = resolve("films", "a"); +const never = () => { + throw new Error("must not run"); +}; + +describe("openInDesktop", () => { + it("opens and writes nothing while the app cannot take a project", () => { + expect(HANDOFF_READY).toBe(false); + expect(openInDesktop(FILM, { open: never })).toEqual({ + opened: false, + reason: "handoff-unavailable", + downloadUrl: DESKTOP_DOWNLOAD_URL, + }); + }); + + it("hands the folder to the released app when it is installed", () => { + const asked: string[] = []; + const result = openInDesktop(FILM, { + ...LIVE, + open: (id, dir) => (asked.push(`${id} ${dir}`), true), + }); + expect(result).toEqual({ opened: true, bundleId: "dev.hyperframes.desktop", handedOver: null }); + expect(asked).toEqual([`dev.hyperframes.desktop ${FILM}`]); + }); + + it("falls back to Canary when only Canary is installed", () => { + const result = openInDesktop(FILM, { ...LIVE, open: (id) => id.endsWith(".canary") }); + expect(result).toMatchObject({ opened: true, bundleId: "dev.hyperframes.desktop.canary" }); + }); + + it("tells a missing app from one macOS could not open", () => { + const missing = openInDesktop(FILM, { ...LIVE, open: () => false, installed: () => false }); + expect(missing).toMatchObject({ opened: false, reason: "not-installed" }); + const failed = openInDesktop(FILM, { ...LIVE, open: () => false, installed: () => true }); + expect(failed).toMatchObject({ opened: false, reason: "open-failed" }); + }); + + it("never runs `open` off macOS", () => { + const result = openInDesktop(FILM, { ...LIVE, platform: "win32", open: never }); + expect(result).toMatchObject({ opened: false, reason: "unsupported-platform" }); + }); +}); + +describe("agent hand-off", () => { + let dir: string | undefined; + afterEach(() => { + if (dir) chmodSync(dir, 0o755); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + const handoff = () => join(dir!, AGENT_HANDOFF_FILE); + + it("names the conversation the command runs in, Claude Code first", () => { + expect(agentSession({ CLAUDE_CODE_SESSION_ID: "c-1", CODEX_THREAD_ID: "x-1" })).toEqual({ + engine: "claude", + sessionId: "c-1", + }); + expect(agentSession({ CODEX_THREAD_ID: "x-1" })).toEqual({ engine: "codex", sessionId: "x-1" }); + expect(agentSession({})).toBeNull(); + }); + + it("leaves the app the conversation once it has the folder", () => { + dir = mkdtempSync(join(tmpdir(), "hf-handoff-")); + const session = "0a8eed95-0869-45bf-83ec-5d71fcc5236c"; + const result = openInDesktop(dir, { + ...LIVE, + open: () => true, + env: { CLAUDE_CODE_SESSION_ID: session }, + }); + expect(result).toMatchObject({ opened: true, handedOver: { engine: "claude" } }); + expect(JSON.parse(readFileSync(handoff(), "utf8"))).toEqual({ + engine: "claude", + sessionId: session, + }); + }); + + it("writes nothing when gated, when no app took the folder, or when no agent runs the command", () => { + dir = mkdtempSync(join(tmpdir(), "hf-handoff-")); + const agent = { CODEX_THREAD_ID: "x-1" }; + openInDesktop(dir, { ready: false, platform: "darwin", open: never, env: agent }); + openInDesktop(dir, { ...LIVE, open: () => false, env: agent }); + openInDesktop(dir, { ...LIVE, open: () => true }); + expect(existsSync(handoff())).toBe(false); + }); + + it.skipIf(process.platform === "win32")( + "still opens a project it cannot write to, with nothing handed over", + () => { + dir = mkdtempSync(join(tmpdir(), "hf-handoff-")); + chmodSync(dir, 0o555); + const result = openInDesktop(dir, { + ...LIVE, + open: () => true, + env: { CODEX_THREAD_ID: "x-1" }, + }); + expect(result).toMatchObject({ opened: true, handedOver: null }); + }, + ); +}); + +describe("openCommandFor", () => { + it("names the project relative to where the person is", () => { + const work = resolve("work"); + expect(openCommandFor(join(work, "films", "a"), join(work, "films", "a"))).toBe( + "hyperframes open .", + ); + expect(openCommandFor(join(work, "films", "a"), work)).toBe( + `hyperframes open ${join("films", "a")}`, + ); + expect(openCommandFor(join(work, "my film"), work)).toBe('hyperframes open "my film"'); + }); +}); + +describe("desktopInstalled", () => { + const home = resolve("Users", "a"); + const nothing = { platform: "darwin", home, exists: () => false, spotlight: () => false }; + + it("finds the app where the DMG and the installer put it, Canary included", () => { + const canary = join("/Applications", "HyperFrames Canary.app"); + expect(desktopInstalled({ ...nothing, exists: (p: string) => p === canary })).toBe(true); + const mine = join(home, "Applications", "HyperFrames.app"); + expect(desktopInstalled({ ...nothing, exists: (p: string) => p === mine })).toBe(true); + }); + + it("asks Spotlight for one installed anywhere else", () => { + expect( + desktopInstalled({ ...nothing, spotlight: (id: string) => id === "dev.hyperframes.desktop" }), + ).toBe(true); + expect(desktopInstalled(nothing)).toBe(false); + }); + + it("is never there off macOS", () => { + expect(desktopInstalled({ ...nothing, platform: "linux", exists: () => true })).toBe(false); + }); +}); + +describe("desktopHint", () => { + it("points to the download while gated, and when the app is missing", () => { + expect(desktopHint(process.cwd(), { env: {}, installed: true })).toBe(DOWNLOAD_HINT); + expect(desktopHint(process.cwd(), { env: {}, ready: true, installed: false })).toBe( + DOWNLOAD_HINT, + ); + }); + + it("points an installed app to `hyperframes open` once the app takes handed-over projects", () => { + expect(desktopHint(process.cwd(), { env: {}, ready: true, installed: true })).toBe( + "Edit it with Framey: hyperframes open .", + ); + }); + + it("says nothing inside the app, whose runs carry HYPERFRAMES_DESKTOP_PROJECT", () => { + const inApp = { env: { HYPERFRAMES_DESKTOP_PROJECT: "/p" }, ready: true, installed: true }; + expect(desktopHint(process.cwd(), inApp)).toBeNull(); + }); +}); diff --git a/packages/cli/src/utils/desktopApp.ts b/packages/cli/src/utils/desktopApp.ts new file mode 100644 index 00000000000..cb6c0d2f978 --- /dev/null +++ b/packages/cli/src/utils/desktopApp.ts @@ -0,0 +1,125 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join, relative } from "node:path"; + +// HyperFrames Studio, the desktop app; macOS hands it a folder through `open -b`. Released app first, then Canary. +const DESKTOP_BUNDLE_IDS = ["dev.hyperframes.desktop", "dev.hyperframes.desktop.canary"] as const; +const DESKTOP_APP_NAMES = ["HyperFrames.app", "HyperFrames Canary.app"]; +export const DESKTOP_DOWNLOAD_URL = "https://hyperframes.dev/studio/download"; +export const DOWNLOAD_HINT = `Edit it by chatting with Framey in HyperFrames Studio → ${DESKTOP_DOWNLOAD_URL}`; + +// ponytail: released apps ignore a handed-over folder until hyperframes-internal#2601 ships. Until then nothing +// opens or writes a hand-off, and every surface (CLI, preview route, Studio's button) offers the download. +export const HANDOFF_READY = false; + +export interface AgentSession { + engine: "claude" | "codex"; + sessionId: string; +} + +export const AGENT_HANDOFF_FILE = join(".hyperframes", "agent-handoff.json"); + +// ponytail: Claude Code first; a Codex started inside a Claude Code shell (or the reverse) carries both, unsorted. +export function agentSession(env: NodeJS.ProcessEnv = process.env): AgentSession | null { + if (env.CLAUDE_CODE_SESSION_ID) + return { engine: "claude", sessionId: env.CLAUDE_CODE_SESSION_ID }; + if (env.CODEX_THREAD_ID) return { engine: "codex", sessionId: env.CODEX_THREAD_ID }; + return null; +} + +export type DesktopOpenResult = + | { opened: true; bundleId: string; handedOver: AgentSession | null } + | { + opened: false; + reason: "handoff-unavailable" | "unsupported-platform" | "not-installed" | "open-failed"; + downloadUrl: string; + }; + +const openWithBundle = (bundleId: string, dir: string): boolean => + spawnSync("open", ["-b", bundleId, dir], { stdio: "ignore" }).status === 0; + +/** The app reads the hand-off at its first chat send, so a project it cannot be written to just opens without it. */ +function leaveHandoff(dir: string, session: AgentSession | null): AgentSession | null { + if (!session) return null; + try { + mkdirSync(join(dir, ".hyperframes"), { recursive: true }); + writeFileSync(join(dir, AGENT_HANDOFF_FILE), JSON.stringify(session)); + return session; + } catch { + return null; + } +} + +// ponytail: macOS only; the Windows app needs its own hand-off (argv on second-instance) before this grows a branch. +export function openInDesktop( + dir: string, + { + ready = HANDOFF_READY, + platform = process.platform, + open = openWithBundle, + installed = () => desktopInstalled({ platform }), + env = process.env, + }: { + ready?: boolean; + platform?: string; + open?: (bundleId: string, dir: string) => boolean; + installed?: () => boolean; + env?: NodeJS.ProcessEnv; + } = {}, +): DesktopOpenResult { + const notOpened = ( + reason: Extract["reason"], + ): DesktopOpenResult => ({ opened: false, reason, downloadUrl: DESKTOP_DOWNLOAD_URL }); + if (!ready) return notOpened("handoff-unavailable"); + if (platform !== "darwin") return notOpened("unsupported-platform"); + const bundleId = DESKTOP_BUNDLE_IDS.find((id) => open(id, dir)); + if (!bundleId) return notOpened(installed() ? "open-failed" : "not-installed"); + return { opened: true, bundleId, handedOver: leaveHandoff(dir, agentSession(env)) }; +} + +/** The `hyperframes open` line for a project, relative to where the person is. */ +export function openCommandFor(dir: string, cwd = process.cwd()): string { + const shown = relative(cwd, dir) || "."; + return `hyperframes open ${/\s/.test(shown) ? JSON.stringify(shown) : shown}`; +} + +/** Spotlight's copies of an app with this bundle id; an updater's hidden leftover (`.X.app.installing-…`) is none. */ +const spotlightFinds = (bundleId: string): boolean => + spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${bundleId}'`], { encoding: "utf8" }) + .stdout?.split("\n") + .some((path) => path.endsWith(".app") && !basename(path).startsWith(".")) ?? false; + +/** Whether this Mac has the app, without launching it: where the DMG and the installer put it, else Spotlight. */ +export function desktopInstalled({ + platform = process.platform, + home = homedir(), + exists = existsSync, + spotlight = spotlightFinds, +}: { + platform?: string; + home?: string; + exists?: (path: string) => boolean; + spotlight?: (bundleId: string) => boolean; +} = {}): boolean { + if (platform !== "darwin") return false; + const folders = ["/Applications", join(home, "Applications")]; + if (folders.some((folder) => DESKTOP_APP_NAMES.some((name) => exists(join(folder, name))))) + return true; + return DESKTOP_BUNDLE_IDS.some(spotlight); +} + +/** The line render and preview print about the app; null inside the app (its agent runs set the variable). */ +export function desktopHint( + dir: string, + { + env = process.env, + ready = HANDOFF_READY, + installed, + }: { env?: NodeJS.ProcessEnv; ready?: boolean; installed?: boolean } = {}, +): string | null { + if (env.HYPERFRAMES_DESKTOP_PROJECT) return null; + return ready && (installed ?? desktopInstalled()) + ? `Edit it with Framey: ${openCommandFor(dir)}` + : DOWNLOAD_HINT; +} diff --git a/packages/studio/src/components/FrameyGlyph.tsx b/packages/studio/src/components/FrameyGlyph.tsx new file mode 100644 index 00000000000..abcde5f219d --- /dev/null +++ b/packages/studio/src/components/FrameyGlyph.tsx @@ -0,0 +1,52 @@ +import type { Ref } from "react"; + +// Framey, the desktop app's cursor mascot, as hyperframes-internal packages/nib draws it: a rounded triangle with a +// white rim and one eye. The viewBox frames the body with its rim; the tip points up and left like a cursor. +const BODY = "M77.55 48.44 L122.77 65.85 L99.3 89.41 Z"; +const EYE = { cx: 111.6, cy: 68.2, r: 11.5 }; + +/** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. */ +export function FrameyGlyph({ + size, + className, + eyeRef, +}: { + size: number; + className?: string; + eyeRef?: Ref; +}) { + return ( + + ); +} diff --git a/packages/studio/src/components/OpenInDesktopButton.dom.test.tsx b/packages/studio/src/components/OpenInDesktopButton.dom.test.tsx new file mode 100644 index 00000000000..92a154c0d90 --- /dev/null +++ b/packages/studio/src/components/OpenInDesktopButton.dom.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom + +/** + * "Edit with Framey": shown only where the preview server answers on macOS; a press opens the project in the app, + * or introduces Framey with the download while the app cannot take it or is missing. + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const showToast = vi.fn(); +vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) })); +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +const { OpenInDesktopButton } = await import("./OpenInDesktopButton"); + +const DOWNLOAD = "https://hyperframes.dev/studio/download"; +let mounted: { root: Root; host: HTMLElement } | null = null; +let posts: number; + +function serve(get: Response | null, post?: unknown) { + posts = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + posts += 1; + return Response.json(post); + } + return get ?? new Response("Not found", { status: 404 }); + }), + ); +} + +async function mount(): Promise { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + mounted = { root, host }; + await act(async () => root.render()); + return host; +} + +const downloadHref = () => + [...document.querySelectorAll("a")] + .find((a) => a.textContent?.includes("Get HyperFrames Studio")) + ?.getAttribute("href"); + +const button = () => + document.querySelector('[data-testid="header-open-in-desktop"]'); + +beforeEach(() => showToast.mockReset()); + +afterEach(() => { + vi.unstubAllGlobals(); + if (!mounted) return; + const { root, host } = mounted; + mounted = null; + act(() => root.unmount()); + host.remove(); +}); + +it("stays hidden where the server has no hand-off (the desktop's own Studio)", async () => { + serve(null); + await mount(); + expect(button()).toBeNull(); +}); + +it("stays hidden when the server says it cannot open the app (not macOS)", async () => { + serve(Response.json({ available: false })); + await mount(); + expect(button()).toBeNull(); +}); + +it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => { + serve(Response.json({ available: true, handoff: false }), { opened: true }); + await mount(); + await act(async () => button()!.click()); + expect(posts).toBe(0); + expect(document.body.textContent).toContain("Meet Framey"); + expect(downloadHref()).toBe(DOWNLOAD); +}); + +it("opens the project in the app and says so", async () => { + serve(Response.json({ available: true, handoff: true }), { + opened: true, + bundleId: "dev.hyperframes.desktop", + }); + await mount(); + await act(async () => button()!.click()); + expect(posts).toBe(1); + expect(showToast).toHaveBeenCalledWith( + "Framey is opening this project in HyperFrames Studio", + "info", + ); + expect(button()!.dataset.opening).toBe("true"); + expect(document.body.textContent).not.toContain("Meet Framey"); +}); + +it("introduces Framey and offers the download when the app is missing", async () => { + serve(Response.json({ available: true, handoff: true }), { + opened: false, + reason: "not-installed", + downloadUrl: DOWNLOAD, + }); + await mount(); + await act(async () => button()!.click()); + expect(showToast).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Meet Framey"); + expect(downloadHref()).toBe(DOWNLOAD); +}); diff --git a/packages/studio/src/components/OpenInDesktopButton.tsx b/packages/studio/src/components/OpenInDesktopButton.tsx new file mode 100644 index 00000000000..78b2d1cd3c4 --- /dev/null +++ b/packages/studio/src/components/OpenInDesktopButton.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from "react"; +import { useStudioShellContext } from "../contexts/StudioContext"; +import { studioApiFetch } from "../utils/studioApiFetch"; +import { trackStudioEvent } from "../utils/studioTelemetry"; +import { FrameyGlyph } from "./FrameyGlyph"; +import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui"; + +// "Edit with Framey": the preview server (`/api/open-in-desktop`) says whether to show it and whether the app can +// take the project yet (its gate is the CLI's HANDOFF_READY); until then a press introduces Framey with the download. +const ROUTE = "/api/open-in-desktop"; +const DOWNLOAD_URL = "https://hyperframes.dev/studio/download"; +/** How far the pupils travel towards the pointer, in the glyph's own units. */ +const GAZE = 2.2; +/** Long enough for the flight to finish before the label comes back. */ +const OPENING_MS = 1600; + +type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string }; + +/** Null hides the button: no such route (the app's own Studio), or not macOS. */ +function useDesktopRoute(): { handoff: boolean } | null { + const [route, setRoute] = useState<{ handoff: boolean } | null>(null); + useEffect(() => { + let live = true; + studioApiFetch(ROUTE) + .then((res) => (res.ok ? res.json() : null)) + .then((body: { available?: unknown; handoff?: unknown } | null) => { + if (live && body?.available === true) setRoute({ handoff: body.handoff === true }); + }) + .catch(() => {}); + return () => { + live = false; + }; + }, []); + return route; +} + +/** Framey looks at the pointer: one listener, one write per frame, no re-render. */ +function useGaze( + anchor: React.RefObject, + eye: React.RefObject, +) { + useEffect(() => { + let frame = 0; + const look = (event: PointerEvent) => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const box = anchor.current?.getBoundingClientRect(); + if (!box || !eye.current) return; + const dx = event.clientX - (box.left + box.height / 2); + const dy = event.clientY - (box.top + box.height / 2); + const distance = Math.hypot(dx, dy) || 1; + eye.current.style.transform = `translate(${(dx / distance) * GAZE}px, ${(dy / distance) * GAZE}px)`; + }); + }; + window.addEventListener("pointermove", look); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("pointermove", look); + }; + }, [anchor, eye]); +} + +export function OpenInDesktopButton() { + const { showToast } = useStudioShellContext(); + const route = useDesktopRoute(); + const [opening, setOpening] = useState(false); + // The card under the button, introducing Framey with the app's download. + const [downloadUrl, setDownloadUrl] = useState(null); + const trigger = useRef(null); + const eye = useRef(null); + useGaze(trigger, eye); + if (!route) return null; + + const open = async () => { + if (!route.handoff) { + trackStudioEvent("toolbar_action", { action: "open_in_desktop" }); + setDownloadUrl(DOWNLOAD_URL); + return; + } + if (opening) return; + setOpening(true); + trackStudioEvent("toolbar_action", { action: "open_in_desktop" }); + try { + const res = await studioApiFetch(ROUTE, { method: "POST" }); + const result = (await res.json()) as OpenResult; + if (result.opened) { + showToast("Framey is opening this project in HyperFrames Studio", "info"); + await new Promise((done) => setTimeout(done, OPENING_MS)); + } else setDownloadUrl(result.downloadUrl); + } catch { + showToast("Couldn't reach the preview server to open the app", "error"); + } finally { + setOpening(false); + } + }; + + return ( + { + if (next) void open(); + else setDownloadUrl(null); + }} + side="bottom" + align="end" + arrow + aria-label="Meet Framey" + className="w-72" + trigger={ + + } + > +
+ +

Meet Framey

+
+

+ I live in HyperFrames Studio, the free desktop app for macOS: +

+
    +
  • Chat to change anything in the video
  • +
  • Draw on a frame to point at it
  • +
  • Watch me act out each edit
  • +
+ +
+ ); +} diff --git a/packages/studio/src/components/StudioHeader.dom.test.tsx b/packages/studio/src/components/StudioHeader.dom.test.tsx index 263433c7a9c..2052404c64b 100644 --- a/packages/studio/src/components/StudioHeader.dom.test.tsx +++ b/packages/studio/src/components/StudioHeader.dom.test.tsx @@ -40,6 +40,8 @@ vi.mock("../contexts/PanelLayoutContext", () => ({ })); vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); +// Probes the preview server on mount; covered in OpenInDesktopButton.dom.test.tsx. +vi.mock("./OpenInDesktopButton", () => ({ OpenInDesktopButton: () => null })); const { StudioHeader } = await import("./StudioHeader"); const { ShowThemeToggle } = await import("./ThemeToggle"); diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index f0e05f6b2b0..b452e61780d 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry"; import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui"; import { Dock } from "./dock/Dock"; import { InspectorIcon } from "./icons/InspectorIcon"; +import { OpenInDesktopButton } from "./OpenInDesktopButton"; import { HyperframesLogo } from "./ui/HyperframesLogo"; import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle"; @@ -151,6 +152,7 @@ export const StudioHeader = memo(function StudioHeader({ {showThemeToggle && } +