diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3e255afdac..1bf022c392 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -152,6 +152,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 0000000000..aee647a0ef --- /dev/null +++ b/packages/cli/src/commands/open.ts @@ -0,0 +1,45 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; +import { openInDesktop } from "../utils/desktopApp.js"; +import { resolveProject } 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"], +]; + +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 = resolveProject(args.dir); + const result = openInDesktop(project.dir); + if (args.json) { + console.log(JSON.stringify({ project: project.dir, ...result }, null, 2)); + return; + } + 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; + } + console.log( + result.reason === "not-installed" + ? `${c.warn("◇")} HyperFrames Studio isn't installed on this Mac.` + : `${c.warn("◇")} Opening a project from the CLI works on macOS only for now.`, + ); + console.log(` Download it: ${c.accent(result.downloadUrl)}`); + }, +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index a134b2615a..4e29f45d10 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 df47984c8a..8f9665f921 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: options.desktopHint !== false && options.quality !== "draft", elapsedMs: elapsed, quiet: options.quiet, format: options.format, @@ -1138,6 +1143,8 @@ async function executeLocalRender( runPostRenderStep("printRenderComplete", () => printRenderComplete({ outputPath, + projectDir, + desktopHint: options.desktopHint !== false && options.quality !== "draft", elapsedMs: elapsed, quiet: options.quiet, format: options.format, @@ -1900,6 +1907,9 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector 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 +1930,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.ts b/packages/cli/src/commands/render/execute.ts index a929c16ca7..e355656701 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -365,6 +365,7 @@ async function executeBatchRender( exitAfterComplete: false, throwOnError: true, skipFeedback: true, + desktopHint: false, manageDeParallelRouterBreaker: plan.batchConcurrency <= 1, }; const manifest = await batchModule.runBatchRender({ diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 236c919cd4..34769aa7a8 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/studioServer.ts b/packages/cli/src/server/studioServer.ts index 7a40bae3ac..bd0764c02e 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -966,6 +966,15 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }); }); + // HyperFrames Studio, the desktop app: the header's "Open in app" asks this server, which runs on the person's Mac, + // to hand the project folder over (`hyperframes open`'s `open -b`). The desktop's own Studio has no such route, so + // the button stays hidden there. + app.get("/api/open-in-desktop", (c) => c.json({ available: process.platform === "darwin" })); + app.post("/api/open-in-desktop", async (c) => { + const { openInDesktop } = await import("../utils/desktopApp.js"); + return c.json(openInDesktop(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 0000000000..46e40fd431 --- /dev/null +++ b/packages/cli/src/utils/desktopApp.test.ts @@ -0,0 +1,157 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + AGENT_HANDOFF_FILE, + DESKTOP_DOWNLOAD_URL, + HANDOFF_READY, + agentSession, + desktopHint, + desktopInstalled, + openCommandFor, + openInDesktop, +} from "./desktopApp.js"; + +const NO_AGENT = {}; + +describe("openInDesktop", () => { + it("hands the folder to the released app when it is installed", () => { + const asked: string[] = []; + const result = openInDesktop("/films/a", { + platform: "darwin", + open: (id, dir) => (asked.push(`${id} ${dir}`), true), + env: NO_AGENT, + }); + expect(result).toEqual({ opened: true, bundleId: "dev.hyperframes.desktop", handedOver: null }); + expect(asked).toEqual(["dev.hyperframes.desktop /films/a"]); + }); + + it("falls back to Canary when only Canary is installed", () => { + const result = openInDesktop("/films/a", { + platform: "darwin", + open: (id) => id.endsWith(".canary"), + env: NO_AGENT, + }); + expect(result).toMatchObject({ opened: true, bundleId: "dev.hyperframes.desktop.canary" }); + }); + + it("points to the download when no app is installed", () => { + const result = openInDesktop("/films/a", { platform: "darwin", open: () => false }); + expect(result).toEqual({ + opened: false, + reason: "not-installed", + downloadUrl: DESKTOP_DOWNLOAD_URL, + }); + }); + + it("never runs `open` off macOS", () => { + const result = openInDesktop("C:\\films\\a", { + platform: "win32", + open: () => { + throw new Error("must not run"); + }, + }); + expect(result).toMatchObject({ opened: false, reason: "unsupported-platform" }); + }); +}); + +describe("agent hand-off", () => { + let dir: string | undefined; + afterEach(() => 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 result = openInDesktop(dir, { + platform: "darwin", + open: () => true, + env: { CLAUDE_CODE_SESSION_ID: "0a8eed95-0869-45bf-83ec-5d71fcc5236c" }, + }); + expect(result).toMatchObject({ opened: true, handedOver: { engine: "claude" } }); + expect(JSON.parse(readFileSync(handoff(), "utf8"))).toEqual({ + engine: "claude", + sessionId: "0a8eed95-0869-45bf-83ec-5d71fcc5236c", + }); + }); + + it("writes nothing when no app took the folder, or no agent runs the command", () => { + dir = mkdtempSync(join(tmpdir(), "hf-handoff-")); + openInDesktop(dir, { platform: "darwin", open: () => false, env: { CODEX_THREAD_ID: "x-1" } }); + openInDesktop(dir, { platform: "darwin", open: () => true, env: {} }); + expect(existsSync(handoff())).toBe(false); + }); +}); + +describe("openCommandFor", () => { + it("names the project relative to where the person is", () => { + expect(openCommandFor("/work/films/a", "/work/films/a")).toBe("hyperframes open ."); + expect(openCommandFor("/work/films/a", "/work")).toBe("hyperframes open films/a"); + expect(openCommandFor("/work/my film", "/work")).toBe('hyperframes open "my film"'); + }); +}); + +describe("desktopInstalled", () => { + const nothing = { + platform: "darwin", + home: "/Users/a", + exists: () => false, + spotlight: () => false, + }; + + it("finds the app where the DMG and the installer put it, Canary included", () => { + expect( + desktopInstalled({ + ...nothing, + exists: (p: string) => p === "/Applications/HyperFrames Canary.app", + }), + ).toBe(true); + expect( + desktopInstalled({ + ...nothing, + exists: (p: string) => p === "/Users/a/Applications/HyperFrames.app", + }), + ).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", () => { + const OUTSIDE = {}; + const download = `Edit it by chatting with Framey in HyperFrames Studio → ${DESKTOP_DOWNLOAD_URL}`; + + it("points to the download when the app is missing", () => { + expect(desktopHint(process.cwd(), { env: OUTSIDE, installed: false })).toBe(download); + }); + + it("points an installed app to `hyperframes open` once the app opens handed-over projects", () => { + expect(desktopHint(process.cwd(), { env: OUTSIDE, installed: true })).toBe( + HANDOFF_READY ? "Edit it with Framey: hyperframes open ." : download, + ); + }); + + it("says nothing inside the app, whose runs carry HYPERFRAMES_DESKTOP_PROJECT", () => { + expect( + desktopHint(process.cwd(), { env: { HYPERFRAMES_DESKTOP_PROJECT: "/p" }, installed: true }), + ).toBeNull(); + }); +}); diff --git a/packages/cli/src/utils/desktopApp.ts b/packages/cli/src/utils/desktopApp.ts new file mode 100644 index 0000000000..14130da44f --- /dev/null +++ b/packages/cli/src/utils/desktopApp.ts @@ -0,0 +1,106 @@ +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`, which the app takes as a project to +// add to Home and show (hyperframes-internal packages/desktop/main/folderOpen.mjs). The released app first, then Canary. +const DESKTOP_BUNDLE_IDS = ["dev.hyperframes.desktop", "dev.hyperframes.desktop.canary"] as const; +export const DESKTOP_DOWNLOAD_URL = "https://hyperframes.dev/studio/download"; + +/** The agent conversation a command runs in: Claude Code and Codex name it to the shells they start. */ +export interface AgentSession { + engine: "claude" | "codex"; + sessionId: string; +} + +// The app's first chat in the project picks this conversation up as context, once (hyperframes-internal +// packages/desktop/main/agentHandoffFile.mjs). +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: "not-installed" | "unsupported-platform"; downloadUrl: string }; + +/** Runs `open -b `; true when macOS found the app and handed it the folder. */ +type OpenWithBundle = (bundleId: string, dir: string) => boolean; + +const openWithBundle: OpenWithBundle = (bundleId, dir) => + spawnSync("open", ["-b", bundleId, dir], { stdio: "ignore" }).status === 0; + +// 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, + { platform = process.platform, open = openWithBundle, env = process.env } = {}, +): DesktopOpenResult { + if (platform !== "darwin") + return { opened: false, reason: "unsupported-platform", downloadUrl: DESKTOP_DOWNLOAD_URL }; + const bundleId = DESKTOP_BUNDLE_IDS.find((id) => open(id, dir)); + if (!bundleId) + return { opened: false, reason: "not-installed", downloadUrl: DESKTOP_DOWNLOAD_URL }; + // Read at the app's first chat send, well after this open, so it is written once the app has the folder. + const handedOver = agentSession(env); + if (handedOver) { + mkdirSync(join(dir, ".hyperframes"), { recursive: true }); + writeFileSync(join(dir, AGENT_HANDOFF_FILE), JSON.stringify(handedOver)); + } + return { opened: true, bundleId, handedOver }; +} + +/** The `hyperframes open` line to print 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}`; +} + +const DESKTOP_APP_NAMES = ["HyperFrames.app", "HyperFrames Canary.app"]; + +/** 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 desktop app, without launching it: where the DMG and the installer put it, else + * Spotlight, which can miss an app for minutes after an update. Off macOS, never. */ +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); +} + +// ponytail: the desktop app opens a handed-over folder only from its next release (hyperframes-internal +// main/folderOpen.mjs); until then every hint points to the download, as Studio's button does. Flip with it. +export const HANDOFF_READY = false; + +/** The one line render and preview print about the desktop app: open the project in it, or get it. Null inside + * the app itself, whose agent runs carry HYPERFRAMES_DESKTOP_PROJECT. */ +export function desktopHint( + dir: string, + { env = process.env, installed }: { env?: NodeJS.ProcessEnv; installed?: boolean } = {}, +): string | null { + if (env.HYPERFRAMES_DESKTOP_PROJECT) return null; + return HANDOFF_READY && (installed ?? desktopInstalled()) + ? `Edit it with Framey: ${openCommandFor(dir)}` + : `Edit it by chatting with Framey in HyperFrames Studio → ${DESKTOP_DOWNLOAD_URL}`; +} diff --git a/packages/studio/src/components/FrameyGlyph.tsx b/packages/studio/src/components/FrameyGlyph.tsx new file mode 100644 index 0000000000..abcde5f219 --- /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 0000000000..e1c2e83131 --- /dev/null +++ b/packages/studio/src/components/OpenInDesktopButton.dom.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom + +/** + * "Open in app": shown only when the preview server can hand the project to the desktop app; a click opens it, or + * offers the download when the app 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 { HANDOFF_READY, 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("before the app opens projects from here, introduces Framey with the download and opens nothing", async () => { + serve(Response.json({ available: true }), { opened: true }); + await mount(); + await act(async () => button()!.click()); + if (HANDOFF_READY) return; + expect(posts).toBe(0); + expect(document.body.textContent).toContain("Meet Framey"); + expect(document.body.textContent).not.toContain("Coming soon"); + expect(downloadHref()).toBe(DOWNLOAD); +}); + +it.skipIf(!HANDOFF_READY)("opens the project in the app and says so", async () => { + serve(Response.json({ available: 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.skipIf(!HANDOFF_READY)( + "introduces Framey and offers the download when the app is missing", + async () => { + serve(Response.json({ available: 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 0000000000..40f06bf9a9 --- /dev/null +++ b/packages/studio/src/components/OpenInDesktopButton.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from "react"; +import { useStudioShellContext } from "../contexts/StudioContext"; +import { trackStudioEvent } from "../utils/studioTelemetry"; +import { FrameyGlyph } from "./FrameyGlyph"; +import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui"; + +// "Edit with Framey": hands this project to HyperFrames Studio, the desktop app, through the preview server +// (`POST /api/open-in-desktop`, the same hand-off as `hyperframes open`). Shown only when that server says it can. +const ROUTE = "/api/open-in-desktop"; +const DOWNLOAD_URL = "https://hyperframes.dev/studio/download"; +// ponytail: the desktop app opens a handed-over folder only from its next release; until then the button +// introduces Framey and links the download. Flip once that release ships. +export const HANDOFF_READY = false; +/** 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: "not-installed" | "unsupported-platform"; downloadUrl: string }; + +function useDesktopHandoffAvailable(): boolean { + const [available, setAvailable] = useState(false); + useEffect(() => { + let live = true; + fetch(ROUTE) + .then((res) => (res.ok ? res.json() : null)) + .then((body: { available?: unknown } | null) => { + if (live) setAvailable(body?.available === true); + }) + .catch(() => {}); + return () => { + live = false; + }; + }, []); + return available; +} + +/** 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 available = useDesktopHandoffAvailable(); + 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 (!available) return null; + + const open = async () => { + if (!HANDOFF_READY) { + 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 fetch(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 263433c7a9..2052404c64 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 f0e05f6b2b..b452e61780 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 && } +