diff --git a/packages/cli/src/server/desktopRoutes.test.ts b/packages/cli/src/server/desktopRoutes.test.ts index 986ca3cd5a..c583de0aac 100644 --- a/packages/cli/src/server/desktopRoutes.test.ts +++ b/packages/cli/src/server/desktopRoutes.test.ts @@ -19,10 +19,14 @@ function server( place: { platform?: NodeJS.Platform; installed?: () => boolean } = {}, ) { const app = new Hono(); - const open = vi.fn((_dir: string): DesktopOpenResult => OPENED); + const open = vi.fn((_dir: string, _cta?: string): DesktopOpenResult => OPENED); mountDesktopRoutes(app, "/films/a", { ready, open, ...place }); - const post = (headers: Record) => - app.request("/api/open-in-desktop", { method: "POST", headers }); + const post = (headers: Record, body?: unknown) => + app.request("/api/open-in-desktop", { + method: "POST", + headers: body === undefined ? headers : { ...headers, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); return { app, open, post }; } @@ -32,7 +36,13 @@ describe("open-in-desktop routes", () => { await server(false, { installed: () => false }).app.request("/api/open-in-desktop") ).json(); const downloadUrl = desktopDownloadUrl(); - expect(body).toEqual({ available: downloadUrl !== null, handoff: false, downloadUrl }); + expect(body).toEqual({ + available: downloadUrl !== null, + handoff: false, + downloadUrl, + os: process.platform, + installed: false, + }); }); it("shows the button where the app is installed even without a download (Windows)", async () => { @@ -42,10 +52,33 @@ describe("open-in-desktop routes", () => { "/api/open-in-desktop", ) ).json(); - expect(await get(true)).toEqual({ available: true, handoff: true, downloadUrl: null }); + expect(await get(true)).toEqual({ + available: true, + handoff: true, + downloadUrl: null, + os: "win32", + installed: true, + }); expect(await get(false)).toMatchObject({ available: false }); }); + it("offers the hand-off only where the app is installed, so a press without it goes to the download", async () => { + const get = async (installed: boolean) => + ( + await server(true, { platform: "darwin", installed: () => installed }).app.request( + "/api/open-in-desktop", + ) + ).json(); + expect(await get(true)).toMatchObject({ available: true, handoff: true }); + expect(await get(false)).toEqual({ + available: true, + handoff: false, + downloadUrl: desktopDownloadUrl("darwin"), + os: "darwin", + installed: 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" }; @@ -67,7 +100,20 @@ describe("open-in-desktop routes", () => { 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"); + expect(open).toHaveBeenCalledWith("/films/a", undefined); + }); + + it("passes the press's page id on to the hand-off, and drops anything that is not one", async () => { + const { open, post } = server(true); + const id = "3f2b8c1e-9a4d-4e7b-8c21-5d6e7f8a9b0c"; + await post(STUDIO, { cta: id }); + await post(STUDIO, { cta: "../../etc/passwd" }); + await post(STUDIO, { cta: 42 }); + expect(open.mock.calls).toEqual([ + ["/films/a", id], + ["/films/a", undefined], + ["/films/a", undefined], + ]); }); }); diff --git a/packages/cli/src/server/desktopRoutes.ts b/packages/cli/src/server/desktopRoutes.ts index 3aa88df89f..54fc22023e 100644 --- a/packages/cli/src/server/desktopRoutes.ts +++ b/packages/cli/src/server/desktopRoutes.ts @@ -7,6 +7,8 @@ import { } from "../utils/desktopApp.js"; import { identityAllowed } from "./telemetryIdentity.js"; +const CTA_ID = /^[A-Za-z0-9-]{1,64}$/; + /** 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; @@ -19,22 +21,29 @@ export function sameOriginPost(headers: { return origin === undefined || origin === `http://${host}`; } -/** Edit with Framey's route: whether to show it, where to download, and whether the app takes the project. */ +/** The desktop-app button's route: show it or not, the download, and whether the app here takes the project. */ export function mountDesktopRoutes( app: Hono, projectDir: string, { ready = HANDOFF_READY, - open = openInDesktop, + open = (dir: string, cta?: string) => openInDesktop(dir, { cta }), platform = process.platform, installed = () => desktopInstalled({ platform }), } = {}, ): void { const downloadUrl = desktopDownloadUrl(platform); - app.get("/api/open-in-desktop", (c) => - c.json({ available: downloadUrl !== null || installed(), handoff: ready, downloadUrl }), - ); - app.post("/api/open-in-desktop", (c) => { + app.get("/api/open-in-desktop", (c) => { + const here = installed(); + return c.json({ + available: downloadUrl !== null || here, + handoff: ready && here, + downloadUrl, + os: platform, + installed: here, + }); + }); + app.post("/api/open-in-desktop", async (c) => { const allowed = sameOriginPost({ host: c.req.header("host"), origin: c.req.header("origin"), @@ -47,6 +56,8 @@ export function mountDesktopRoutes( reason: "handoff-unavailable", downloadUrl, }); - return c.json(open(projectDir)); + const body: unknown = await c.req.json().catch(() => null); + const cta = (body as { cta?: unknown } | null)?.cta; + return c.json(open(projectDir, typeof cta === "string" && CTA_ID.test(cta) ? cta : undefined)); }); } diff --git a/packages/cli/src/utils/desktopApp.test.ts b/packages/cli/src/utils/desktopApp.test.ts index f68639df85..038a6de2be 100644 --- a/packages/cli/src/utils/desktopApp.test.ts +++ b/packages/cli/src/utils/desktopApp.test.ts @@ -1,6 +1,15 @@ -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve, win32 } from "node:path"; +import { isHyperframesProject } from "@hyperframes/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readSeen } from "./appHistory.js"; import { @@ -43,6 +52,48 @@ describe("openInDesktop", () => { expect(asked).toEqual([`dev.hyperframes.desktop ${FILM}`]); }); + it("hands over a folder preview serves from a bare index.html as a project the app accepts", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-bare-")); + try { + writeFileSync(join(dir, "index.html"), ""); + let marked: boolean | null = null; + const result = openInDesktop(dir, { + ...LIVE, + open: (_id, folder) => ((marked = isHyperframesProject(readdirSync(folder))), true), + }); + expect(result).toMatchObject({ opened: true }); + expect(marked).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("leaves the press's page id for the app only when the app took the folder", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-cta-")); + const record = join(dir, ".hyperframes", "desktop-cta.json"); + try { + writeFileSync(join(dir, "index.html"), ""); + openInDesktop(dir, { ...LIVE, cta: "page-1", open: () => false, installed: () => false }); + expect(existsSync(record)).toBe(false); + openInDesktop(dir, { ...LIVE, cta: "page-1", open: () => true }); + expect(JSON.parse(readFileSync(record, "utf8"))).toMatchObject({ cta: "page-1" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("leaves a project's own marker file alone", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-marked-")); + try { + writeFileSync(join(dir, "index.html"), ""); + writeFileSync(join(dir, "meta.json"), "{}"); + openInDesktop(dir, { ...LIVE, open: () => true }); + expect(existsSync(join(dir, "hyperframes.json"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + 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, app: "HyperFrames Canary" }); diff --git a/packages/cli/src/utils/desktopApp.ts b/packages/cli/src/utils/desktopApp.ts index 75d7d49eba..36753756b3 100644 --- a/packages/cli/src/utils/desktopApp.ts +++ b/packages/cli/src/utils/desktopApp.ts @@ -1,8 +1,10 @@ import { spawn, spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, isAbsolute, join, relative, win32 } from "node:path"; +import { isHyperframesProject } from "@hyperframes/core"; import { addCatchUpNote, markSeen, readSeen } from "./appHistory.js"; +import { createProjectConfig } from "./projectConfig.js"; import { writeRecord } from "./projectRecords.js"; // The HyperFrames desktop app (not "Studio"): `open -b` on macOS, its executable elsewhere; released, then Canary. @@ -32,6 +34,8 @@ export interface AgentSession { } export const AGENT_HANDOFF_FILE = join(".hyperframes", "agent-handoff.json"); +/** The Studio page's telemetry `tab_id`, left for the app to report its first open and export against. */ +const DESKTOP_CTA_RECORD = "desktop-cta.json"; // ponytail: Claude Code, then Codex, then Grok; an agent started inside another's shell carries both, unsorted. export function agentSession(env: NodeJS.ProcessEnv = process.env): AgentSession | null { @@ -131,6 +135,17 @@ function leaveHandoff(dir: string, session: AgentSession | null): AgentSession | return session; } +function markBareIndexAsProject(dir: string): void { + try { + if (existsSync(join(dir, "index.html")) && !isHyperframesProject(readdirSync(dir))) + createProjectConfig(dir); + } catch (error) { + console.warn( + `◇ Couldn't add hyperframes.json to ${dir}, so the app may not open it: ${error}`, + ); + } +} + // ponytail: success on Windows and Linux means the spawn started, not that the app took the folder; wait for the // app's own acknowledgement if a broken install ever needs reporting. export function openInDesktop( @@ -142,8 +157,10 @@ export function openInDesktop( launch = launchApp, installed = () => desktopInstalled({ platform }), env = process.env, + cta, ...where }: AppLookup & { + cta?: string; ready?: boolean; open?: (bundleId: string, dir: string) => boolean; launch?: (executable: string, dir: string) => boolean; @@ -157,12 +174,16 @@ export function openInDesktop( reason, downloadUrl: desktopDownloadUrl(platform) ?? STUDIO_PAGE, }); - const opened = (canary: boolean): DesktopOpenResult => ({ - opened: true, - app: canary ? "HyperFrames Canary" : "the HyperFrames desktop app", - handedOver: leaveHandoff(dir, agentSession(env)), - }); + const opened = (canary: boolean): DesktopOpenResult => { + if (cta) writeRecord(dir, DESKTOP_CTA_RECORD, JSON.stringify({ cta, at: Date.now() })); + return { + opened: true, + app: canary ? "HyperFrames Canary" : "the HyperFrames desktop app", + handedOver: leaveHandoff(dir, agentSession(env)), + }; + }; if (!ready) return notOpened("handoff-unavailable"); + markBareIndexAsProject(dir); if (platform === "darwin") { const bundleId = DESKTOP_BUNDLE_IDS.find((id) => open(id, dir)); if (!bundleId) return notOpened(installed() ? "open-failed" : "not-installed"); diff --git a/packages/studio/src/components/DesktopGuideCard.tsx b/packages/studio/src/components/DesktopGuideCard.tsx new file mode 100644 index 0000000000..cc5c76dfde --- /dev/null +++ b/packages/studio/src/components/DesktopGuideCard.tsx @@ -0,0 +1,292 @@ +import { ArrowSquareOut, Check, Copy } from "@phosphor-icons/react"; +import { useEffect, useState, type ReactNode } from "react"; +import { copyTextToClipboard } from "../utils/clipboard"; +import { FrameyGlyph, type FrameyMood } from "./FrameyGlyph"; +import { Button, cn } from "./ui"; + +// The desktop-app guide card under OpenInDesktopButton: Framey and three steps (download, install, open), all drawn +// from one `Progress`. + +export type DesktopRoute = { + handoff: boolean; + downloadUrl: string | null; + context: { os: string; app_installed: boolean; download_available: boolean }; +}; + +/** The OS for the download, and what "this …" calls the machine. */ +const platformName = (os: string): string => + os === "darwin" ? "Mac" : os === "linux" ? "Linux" : "your computer"; +const deviceName = (os: string): string => (os === "darwin" ? "Mac" : "computer"); + +const NOTE = "mt-0.5 text-step-12 text-text-2 text-pretty"; + +/** Re-keys on `value`, so text that changes settles in rather than snapping. */ +export function Swap({ value, children }: { value: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +type StepState = "todo" | "now" | "done" | "bad"; +const TODO: StepState = "todo"; +const NOW: StepState = "now"; +const DONE: StepState = "done"; +const BAD: StepState = "bad"; + +function Step({ + n, + state, + title, + children, +}: { + n: number; + state: StepState; + title: string; + children: ReactNode; +}) { + return ( +
  • + + {state === "done" ? : n} + +
    + + {title} + + {children} +
    +
  • + ); +} + +function Spinner() { + return ( +