From 5975dd7579bfaaeff3db2e1b94549a0bfe296ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 18 Oct 2026 17:34:56 -0400 Subject: [PATCH] feat(studio): the desktop-app button says Install the desktop app and never dead-ends (#5854) * feat(studio): the header's desktop-app button says Install the desktop app and never dead-ends The button now reads "Install the desktop app" beside Framey drawn as the app draws it. Where the app is installed, a press opens this project in it; a folder preview serves from a bare index.html gets its hyperframes.json first, so the app no longer refuses it. Without the app, the button links straight to hyperframes.dev's download, and a failed open lands there too. Platforms with no build of the app show no button. Telemetry now records eligibility, one impression, the click and the press outcome, with OS, app installed and download available. * feat(studio): the desktop-app button records eligibility, impression, click and outcome * feat(studio): a desktop-app press carries its page id to the download and the app hand-off * chore(studio): describe Framey's drawing without internal names * feat(studio): the desktop-app button opens a guide that walks download, install and open Without the app, the header button opens a card where Framey walks three steps: the download opens in a new tab, the install is found by asking the preview server while the card is open, and the project opens through the existing hand-off. A failed hand-off shows the folder to copy and Try again. With the app ready the press opens the project directly. Each step change is recorded through trackStudioEvent; a download is never counted as an install. * fix(studio): the desktop-app guide counts one install per page The app removed and found again on the same page recorded install_detected twice; a page latch keeps it to one, as the telemetry contract says. --- packages/cli/src/server/desktopRoutes.test.ts | 58 ++- packages/cli/src/server/desktopRoutes.ts | 25 +- packages/cli/src/utils/desktopApp.test.ts | 53 ++- packages/cli/src/utils/desktopApp.ts | 33 +- .../src/components/DesktopGuideCard.tsx | 292 ++++++++++++ .../studio/src/components/FrameyGlyph.tsx | 104 +++-- .../OpenInDesktopButton.dom.test.tsx | 424 +++++++++++++++++- .../src/components/OpenInDesktopButton.tsx | 325 ++++++++++---- packages/studio/src/styles/components.css | 162 ++++++- scripts/studio-runtime-smoke.mjs | 2 +- 10 files changed, 1332 insertions(+), 146 deletions(-) create mode 100644 packages/studio/src/components/DesktopGuideCard.tsx 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 ( +