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.
This commit is contained in:
Miguel Ángel
2026-10-18 17:34:56 -04:00
committed by GitHub
parent b46ebedeae
commit 5975dd7579
10 changed files with 1332 additions and 146 deletions
+52 -6
View File
@@ -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<string, string>) =>
app.request("/api/open-in-desktop", { method: "POST", headers });
const post = (headers: Record<string, string>, 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],
]);
});
});
+18 -7
View File
@@ -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));
});
}
+52 -1
View File
@@ -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"), "<html></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"), "<html></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"), "<html></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" });
+27 -6
View File
@@ -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");
@@ -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 (
<span key={value} className="hf-guide-swap inline-block">
{children}
</span>
);
}
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 (
<li
className="grid grid-cols-[22px_minmax(0,1fr)] gap-2.5 border-border py-2 not-first:border-t"
data-step={n}
data-state={state}
>
<span
className={cn(
"grid size-[22px] place-items-center rounded-full text-step-11 font-bold tabular-nums",
state === "now" && "bg-accent text-on-accent shadow-[0_0_0_3px_var(--color-on)]",
state === "done" && "bg-on text-accent-ink",
state === "bad" && "bg-danger/15 text-danger-ink",
state === "todo" && "bg-hover text-text-2",
)}
>
{state === "done" ? <Check size={12} weight="bold" className="hf-guide-tick" /> : n}
</span>
<div className="min-w-0">
<b
title={title}
className={cn(
"block truncate text-step-13 font-semibold",
state === "todo" ? "text-text-muted" : "text-text-0",
)}
>
{title}
</b>
{children}
</div>
</li>
);
}
function Spinner() {
return (
<span
aria-hidden="true"
className="size-3 shrink-0 animate-spin rounded-full border-2 border-on-hover border-t-accent motion-reduce:animate-none"
/>
);
}
/** The project folder, with a copy control that confirms for a moment. */
function FolderPath({ dir }: { dir: string }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), 1500);
return () => clearTimeout(timer);
}, [copied]);
return (
<div className="mt-1.5 flex min-w-0 items-center gap-1.5">
<span
title={dir}
data-testid="desktop-guide-folder"
className="min-w-0 flex-1 truncate rounded-md border border-border bg-bg-0 px-2 py-1.5 font-mono text-step-11 text-text-0"
>
{dir}
</span>
<Button
variant="secondary"
aria-label={copied ? "Copied" : "Copy folder path"}
className="w-ctl px-0"
onClick={() => void copyTextToClipboard(dir).then((ok) => ok && setCopied(true))}
>
{copied ? <Check size={14} className="hf-guide-tick" /> : <Copy size={14} />}
</Button>
</div>
);
}
/** Where the person is: what the button says, how Framey looks, and each step's state all derive from this. */
export type Progress = {
route: DesktopRoute;
opening: boolean;
failed: boolean;
downloaded: boolean;
slow: boolean;
/** The app turned up on this page (not already there): Framey hops once. */
arrived: boolean;
};
const isInstalled = (p: Progress) => p.route.context.app_installed;
export const isWaiting = (p: Progress) => p.downloaded && !isInstalled(p);
export function moodOf(p: Progress): FrameyMood {
if (p.opening) return "opening";
if (p.failed) return "error";
if (isWaiting(p)) return "thinking";
return p.arrived ? "found" : "idle";
}
export function labelOf(p: Progress): string {
if (p.opening) return "Opening in the app…";
if (p.failed) return "Couldn't open";
if (p.route.handoff) return "Open in desktop app";
return isWaiting(p) ? "Finish installing" : "Install the desktop app";
}
function subtitleOf(p: Progress): string {
if (p.failed) return "Almost there: the last step needs another try.";
if (!isInstalled(p))
return "Watch it, click a frame, tell Framey what to change. Three steps, about two minutes.";
return p.route.handoff
? "Everything is ready. One press and you're in."
: "Almost there: one last step.";
}
type CardProps = {
progress: Progress;
projectDir: string | null;
onDownload: () => void;
onOpen: () => void;
};
function DownloadStep({ progress, onDownload }: CardProps) {
const { context, downloadUrl } = progress.route;
const where = platformName(context.os);
const installed = isInstalled(progress);
if (!progress.downloaded && !installed)
return (
<Step n={1} state={NOW} title={`Download HyperFrames for ${where}`}>
<p className={NOTE}>Free. Opens hyperframes.dev in a new tab.</p>
<Button
variant="primary"
className="mt-2"
icon={<ArrowSquareOut size={14} />}
onClick={onDownload}
>
Download for {where}
</Button>
</Step>
);
return (
<Step n={1} state={DONE} title={`Download HyperFrames for ${where}`}>
<p className={NOTE}>
{installed ? `Already on this ${deviceName(context.os)}.` : "Started in your browser."}
{!installed && downloadUrl && (
<button
type="button"
className="ml-1.5 cursor-pointer font-medium text-accent-ink hover:underline"
onClick={onDownload}
>
Download again
</button>
)}
</p>
</Step>
);
}
function InstallStep({ progress }: CardProps) {
const { os } = progress.route.context;
if (isInstalled(progress))
return (
<Step n={2} state={DONE} title="Install it">
<p className={NOTE}>
<Swap value="found">Found HyperFrames on this {deviceName(os)}.</Swap>
</p>
</Step>
);
return (
<Step n={2} state={progress.downloaded ? NOW : TODO} title="Install it">
<p className={NOTE}>
{os === "darwin"
? "Open the download and drag HyperFrames into Applications."
: "Open the download to install HyperFrames."}
</p>
{progress.downloaded && (
<p className="mt-1.5 flex items-center gap-2 text-step-12 text-text-2" role="status">
<Spinner />
<Swap value={String(progress.slow)}>
{progress.slow ? "Still looking. Did the download finish?" : "Looking for the app…"}
</Swap>
</p>
)}
</Step>
);
}
function OpenStep({ progress, projectDir, onOpen }: CardProps) {
const title = `Open ${projectDir?.split(/[\\/]/).filter(Boolean).pop() ?? "this project"}`;
const folder = projectDir && <FolderPath dir={projectDir} />;
if (progress.failed)
return (
<Step n={3} state={BAD} title={title}>
<p className={NOTE}>
The app didn't start. Try again, or open HyperFrames and drop this folder in:
</p>
{folder}
<Button variant="primary" className="mt-2" loading={progress.opening} onClick={onOpen}>
Try again
</Button>
</Step>
);
const installed = isInstalled(progress);
// Installed, but this platform's app cannot take a handed-over project yet: the folder is the way in.
if (installed && !progress.route.handoff)
return (
<Step n={3} state={NOW} title={title}>
<p className={NOTE}>Open HyperFrames and drop this folder in:</p>
{folder}
</Step>
);
return (
<Step n={3} state={installed ? NOW : TODO} title={title}>
<p className={NOTE}>Same project, same files, in the desktop app.</p>
<Button
variant="primary"
className="mt-2"
disabled={!progress.route.handoff}
loading={progress.opening}
onClick={onOpen}
>
Open in desktop app
</Button>
</Step>
);
}
export function GuideCard(props: CardProps) {
const { progress } = props;
const subtitle = subtitleOf(progress);
return (
<div data-testid="desktop-guide">
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 bg-linear-to-b from-accent/8 to-transparent px-4 pt-4 pb-3">
<FrameyGlyph size={46} mood={moodOf(progress)} />
<div className="min-w-0">
<p className="text-step-14 font-semibold text-balance text-text-0">
Edit this video in the desktop app
</p>
<p className={NOTE}>
<Swap value={subtitle}>{subtitle}</Swap>
</p>
</div>
</div>
<ol className="m-0 grid list-none px-4 pt-1 pb-4">
<DownloadStep {...props} />
<InstallStep {...props} />
<OpenStep {...props} />
</ol>
</div>
);
}
+77 -27
View File
@@ -1,51 +1,101 @@
import type { Ref } from "react";
import { cn } from "./ui";
// 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.
// Framey, the desktop app's cursor mascot, as the app draws it: a rounded triangle with a white rim and one eye,
// framed square around the body and rim.
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 };
const RIM = 24;
const VIEW_BOX = "65.55 34.315 69.22 69.22";
/** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. */
/** The app's moods: what Framey's eye and body do (styles in components.css, `.hf-framey-mood`). */
export type FrameyMood = "idle" | "thinking" | "found" | "opening" | "error";
const INK = "var(--color-framey-ink)";
function Pupils({ mood }: { mood?: FrameyMood }) {
const { cx, cy } = EYE;
if (mood === "found")
return (
<g transform={`translate(${cx + 1.7} ${cy - 2.3})`}>
<circle r={4.6} fill={INK} />
<circle cx={1.4} cy={-1.6} r={1.3} fill="var(--color-white)" />
</g>
);
if (mood === "thinking")
return (
<g transform={`translate(${cx} ${cy})`}>
<g className="hf-framey-orbit">
<circle cx={-2.8} r={1.9} fill={INK} />
<circle cx={2.8} r={1.9} fill={INK} />
</g>
</g>
);
if (mood === "error")
return (
<path
transform={`translate(${cx} ${cy})`}
d="M-5 -2 L-1.4 1.6 M-1.4 -2 L-5 1.6 M1.4 -2 L5 1.6 M5 -2 L1.4 1.6"
stroke={INK}
strokeWidth={1.7}
strokeLinecap="round"
/>
);
return (
<path
d={`M${cx - 2.6} ${cy - 2.4} V${cy + 2.4} M${cx + 2.6} ${cy - 2.4} V${cy + 2.4}`}
stroke={INK}
strokeWidth={2.4}
strokeLinecap="round"
/>
);
}
/** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. Without `mood` Framey holds still. */
export function FrameyGlyph({
size,
className,
eyeRef,
mood,
}: {
size: number;
className?: string;
eyeRef?: Ref<SVGGElement>;
mood?: FrameyMood;
}) {
return (
<svg
width={size}
height={size}
viewBox="58 30 82 76"
viewBox={VIEW_BOX}
aria-hidden="true"
className={className}
overflow="visible"
className={cn(mood && "hf-framey-mood", className)}
data-mood={mood}
>
<path
d={BODY}
fill="var(--color-white)"
stroke="var(--color-white)"
strokeWidth={24}
strokeLinejoin="round"
/>
<path
d={BODY}
fill="var(--color-framey)"
stroke="var(--color-framey)"
strokeWidth={19}
strokeLinejoin="round"
/>
<circle cx={EYE.cx} cy={EYE.cy} r={EYE.r} fill="var(--color-white)" />
<g ref={eyeRef} className="hf-framey-eye">
<path
d={`M${EYE.cx - 2.6} ${EYE.cy - 2} V${EYE.cy + 2} M${EYE.cx + 2.6} ${EYE.cy - 2} V${EYE.cy + 2}`}
stroke="var(--color-framey-ink)"
strokeWidth={2.4}
strokeLinecap="round"
/>
<g className="hf-framey-whole">
<g className="hf-framey-body">
<path
d={BODY}
fill="var(--color-white)"
stroke="var(--color-white)"
strokeWidth={RIM}
strokeLinejoin="round"
/>
<path
d={BODY}
fill="var(--color-framey)"
stroke="var(--color-framey)"
strokeWidth={19}
strokeLinejoin="round"
/>
<g className="hf-framey-lid">
<circle cx={EYE.cx} cy={EYE.cy} r={EYE.r} fill="var(--color-white)" />
<g ref={eyeRef} className="hf-framey-eye">
<Pupils mood={mood} />
</g>
</g>
</g>
</g>
</svg>
);
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
/**
* "Edit with Framey": shown where the preview server has the app or its download to offer; a press opens the project in the app,
* or introduces Framey with the download while the app cannot take it or is missing.
* "Install the desktop app": shown where the preview server has the app or its download to offer. With the app ready a
* press opens the project in it; otherwise it opens the guide card (download, install, open).
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
@@ -12,21 +12,39 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
const showToast = vi.fn();
vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) }));
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
const track = vi.fn();
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: track }));
const FOLDER = "/work/launch-teaser";
vi.mock("../contexts/FileManagerContext", () => ({
useFileManagerContextOptional: () => ({ projectDir: FOLDER }),
}));
const copied: string[] = [];
vi.mock("../utils/clipboard", () => ({
copyTextToClipboard: async (text: string) => (copied.push(text), true),
}));
const { OpenInDesktopButton } = await import("./OpenInDesktopButton");
const { resolveTabId } = await import("../telemetry/tabId");
const tagged = (url: string) => {
const withId = new URL(url);
withId.searchParams.set("hf_cta", resolveTabId());
return withId.toString();
};
const DOWNLOAD = "https://hyperframes.dev/studio/download";
let mounted: { root: Root; host: HTMLElement } | null = null;
let posts: number;
let postBodies: unknown[];
function serve(get: Response | null, post?: unknown) {
posts = 0;
postBodies = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === "POST") {
posts += 1;
postBodies.push(JSON.parse(String(init.body ?? "null")));
return Response.json(post);
}
return get ?? new Response("Not found", { status: 404 });
@@ -43,15 +61,32 @@ async function mount(): Promise<HTMLElement> {
return host;
}
const downloadHref = () =>
[...document.querySelectorAll("a")]
.find((a) => a.textContent?.includes("Get the desktop app"))
?.getAttribute("href");
const button = () =>
document.querySelector<HTMLButtonElement>('[data-testid="header-open-in-desktop"]');
beforeEach(() => showToast.mockReset());
let reveal: (() => void) | null = null;
beforeEach(() => {
showToast.mockReset();
track.mockReset();
reveal = null;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(private readonly onChange: (entries: { isIntersecting: boolean }[]) => void) {}
observe() {
reveal = () => this.onChange([{ isIntersecting: true }]);
}
disconnect() {
reveal = null;
}
},
);
});
const events = (name: string) =>
track.mock.calls.filter(([event]) => event === name).map(([, props]) => props);
const MAC = { os: "darwin", app_installed: true, download_available: true };
afterEach(() => {
vi.unstubAllGlobals();
@@ -90,16 +125,186 @@ it("opens the project in an installed app that has no download (Windows)", async
expect(posts).toBe(1);
});
it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => {
const linux = `${DOWNLOAD}?os=linux`;
serve(Response.json({ available: true, handoff: false, downloadUrl: linux }), { opened: true });
/** Runs `body` with the poll and slow-download timers under test control. */
async function withFakeTimers(body: () => Promise<void>) {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "setTimeout", "clearTimeout"] });
try {
await body();
} finally {
vi.useRealTimers();
}
}
const guide = () => document.querySelector<HTMLElement>('[data-testid="desktop-guide"]');
const stepState = (n: number) =>
guide()?.querySelector<HTMLElement>(`[data-step="${n}"]`)?.dataset.state ?? null;
const cardButton = (text: string) =>
[...(guide()?.querySelectorAll("button") ?? [])].find((b) => b.textContent === text) ?? null;
const noApp = { os: "darwin", app_installed: false, download_available: true };
const routeBody = (over: Record<string, unknown> = {}) => ({
...noApp,
available: true,
handoff: false,
downloadUrl: DOWNLOAD,
installed: false,
...over,
});
it("reads 'Install the desktop app' beside Framey, and 'Open in desktop app' once the app takes projects", async () => {
serve(Response.json(routeBody()));
await mount();
expect(button()!.textContent).toBe("Install the desktop app");
expect(button()!.querySelector("svg .hf-framey-eye")).not.toBeNull();
act(() => mounted!.root.unmount());
mounted = null;
serve(Response.json(routeBody({ handoff: true, installed: true, app_installed: true })));
await mount();
expect(button()!.textContent).toBe("Open in desktop app");
});
it("without the app, a press opens the guide at step 1 and hands nothing over", async () => {
serve(Response.json(routeBody()), { opened: true });
await mount();
await act(async () => button()!.click());
expect(guide()).not.toBeNull();
expect([stepState(1), stepState(2), stepState(3)]).toEqual(["now", "todo", "todo"]);
expect(cardButton("Open in desktop app")!.disabled).toBe(true);
expect(posts).toBe(0);
expect(document.body.textContent).toContain("Meet Framey");
expect(downloadHref()).toBe(linux);
expect(events("desktop_cta_guide")).toEqual([{ step: "opened", ...noApp }]);
});
it("step 1 opens the tagged download in a new tab, then the button says what is left", async () => {
const opened: string[] = [];
const tab = { opener: {} as unknown };
vi.stubGlobal("open", (url: string) => (opened.push(url), tab));
serve(Response.json(routeBody()));
await mount();
await act(async () => button()!.click());
await act(async () => cardButton("Download for Mac")!.click());
expect(opened).toEqual([tagged(DOWNLOAD)]);
expect(tab.opener).toBeNull();
expect([stepState(1), stepState(2)]).toEqual(["done", "now"]);
expect(guide()!.textContent).toContain("Looking for the app…");
expect(button()!.textContent).toBe("Finish installing2 of 3");
expect(events("desktop_cta_outcome")).toEqual([{ outcome: "sent_to_download", ...noApp }]);
expect(events("desktop_cta_guide").map((e) => (e as { step: string }).step)).toEqual([
"opened",
"download_opened",
]);
});
it("names the download when the browser blocks the new tab, and leaves step 1 to do", async () => {
vi.stubGlobal("open", () => null);
serve(Response.json(routeBody()));
await mount();
await act(async () => button()!.click());
await act(async () => cardButton("Download for Mac")!.click());
expect(showToast).toHaveBeenCalledWith(`Get the HyperFrames desktop app at ${DOWNLOAD}`, "info");
expect(stepState(1)).toBe("now");
expect(events("desktop_cta_outcome")).toEqual([
{ outcome: "failed", reason: "download_tab_blocked", ...noApp },
]);
});
it("finds the install only from the preview server, asking only while the card is open", () =>
withFakeTimers(async () => {
let answer = routeBody();
let gets = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === "POST") return Response.json({ opened: true });
gets += 1;
return Response.json(answer);
}),
);
vi.stubGlobal("open", () => ({ opener: null }));
await mount();
expect(gets).toBe(1);
await act(async () => vi.advanceTimersByTime(10_000));
expect(gets).toBe(1);
await act(async () => button()!.click());
await act(async () => cardButton("Download for Mac")!.click());
await act(async () => vi.advanceTimersByTime(2000));
const asked = gets;
expect(asked).toBeGreaterThan(1);
expect(events("desktop_cta_guide").map((e) => (e as { step: string }).step)).not.toContain(
"install_detected",
);
answer = routeBody({ handoff: true, installed: true });
await act(async () => vi.advanceTimersByTime(2000));
expect([stepState(2), stepState(3)]).toEqual(["done", "now"]);
expect(cardButton("Open in desktop app")!.disabled).toBe(false);
expect(button()!.textContent).toBe("Open in desktop app");
expect(guide()!.querySelector('svg[data-mood="found"]')).not.toBeNull();
const detected = events("desktop_cta_guide").filter(
(e) => (e as { step: string }).step === "install_detected",
);
expect(detected).toEqual([
{ step: "install_detected", os: "darwin", app_installed: true, download_available: true },
]);
const settled = gets;
await act(async () => vi.advanceTimersByTime(10_000));
expect(gets).toBe(settled);
}));
it("records the install once per page, even when the app goes and comes back", () =>
withFakeTimers(async () => {
let answer = routeBody();
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === "POST")
return Response.json({ opened: false, reason: "not-installed", downloadUrl: DOWNLOAD });
return Response.json(answer);
}),
);
await mount();
await act(async () => button()!.click());
answer = routeBody({ handoff: true, installed: true });
await act(async () => vi.advanceTimersByTime(2000));
expect(stepState(2)).toBe("done");
// The app is removed: the hand-off finds it gone, and the server stops reporting it.
answer = routeBody();
await act(async () => cardButton("Open in desktop app")!.click());
expect(stepState(2)).toBe("todo");
// Installed again: the card finds it a second time.
answer = routeBody({ handoff: true, installed: true });
await act(async () => vi.advanceTimersByTime(2000));
expect(stepState(2)).toBe("done");
const detected = events("desktop_cta_guide").filter(
(e) => (e as { step: string }).step === "install_detected",
);
expect(detected).toHaveLength(1);
}));
it("stops asking the preview server when the card closes", () =>
withFakeTimers(async () => {
let gets = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => ((gets += 1), Response.json(routeBody()))),
);
await mount();
await act(async () => button()!.click());
await act(async () => vi.advanceTimersByTime(4000));
expect(gets).toBeGreaterThan(2);
await act(async () => button()!.click());
expect(guide()).toBeNull();
const closedAt = gets;
await act(async () => vi.advanceTimersByTime(10_000));
expect(gets).toBe(closedAt);
expect(events("desktop_cta_guide").map((e) => (e as { step: string }).step)).toEqual([
"opened",
"closed",
]);
}));
it("opens the project in the app and says so", async () => {
serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
opened: true,
@@ -108,23 +313,200 @@ it("opens the project in the app and says so", async () => {
await mount();
await act(async () => button()!.click());
expect(posts).toBe(1);
expect(postBodies).toEqual([{ cta: resolveTabId() }]);
expect(guide()).toBeNull();
expect(showToast).toHaveBeenCalledWith(
"Framey is opening this project in the HyperFrames desktop app",
"Opening this project in the HyperFrames desktop app",
"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, downloadUrl: DOWNLOAD }), {
it("a failed hand-off opens the card at step 3 with the folder to copy and Try again", async () => {
const opened: string[] = [];
vi.stubGlobal("open", (url: string) => (opened.push(url), {}));
copied.length = 0;
serve(Response.json(routeBody({ handoff: true, installed: true })), {
opened: false,
reason: "open-failed",
downloadUrl: DOWNLOAD,
});
await mount();
await act(async () => button()!.click());
expect(opened).toEqual([]);
expect(button()!.textContent).toBe("Couldn't open");
expect(stepState(3)).toBe("bad");
expect(guide()!.textContent).toContain("Almost there: the last step needs another try.");
expect(guide()!.textContent).not.toContain("Everything is ready");
expect(guide()!.querySelector('[data-testid="desktop-guide-folder"]')!.textContent).toBe(FOLDER);
await act(async () =>
guide()!.querySelector<HTMLButtonElement>('[aria-label="Copy folder path"]')!.click(),
);
expect(copied).toEqual([FOLDER]);
await act(async () => cardButton("Try again")!.click());
expect(posts).toBe(2);
});
it("a hand-off that finds the app gone goes back to the download step", async () => {
serve(Response.json(routeBody({ handoff: true, installed: 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);
expect([stepState(1), stepState(3)]).toEqual(["now", "todo"]);
expect(button()!.textContent).toBe("Install the desktop app");
});
it("where the app is installed but cannot take a project (Linux), step 3 shows the folder to drop in", async () => {
const linux = { os: "linux", app_installed: true, download_available: true };
serve(
Response.json({
...linux,
available: true,
handoff: false,
downloadUrl: `${DOWNLOAD}?os=linux`,
installed: true,
}),
{ opened: true },
);
await mount();
await act(async () => button()!.click());
expect([stepState(1), stepState(2), stepState(3)]).toEqual(["done", "done", "now"]);
expect(guide()!.textContent).toContain("Found HyperFrames on this computer.");
expect(guide()!.textContent).toContain("Open HyperFrames and drop this folder in:");
expect(guide()!.querySelector('[data-testid="desktop-guide-folder"]')!.textContent).toBe(FOLDER);
expect(cardButton("Open in desktop app")).toBeNull();
expect(posts).toBe(0);
});
it("counts where the button can show, the app here, and platforms with no app", async () => {
serve(
Response.json({
...MAC,
available: true,
handoff: true,
downloadUrl: DOWNLOAD,
installed: true,
}),
);
await mount();
expect(events("desktop_cta_eligibility")).toEqual([{ eligibility: "open_in_app", ...MAC }]);
act(() => mounted!.root.unmount());
mounted = null;
track.mockReset();
serve(
Response.json({
available: false,
handoff: false,
downloadUrl: null,
os: "win32",
installed: false,
}),
);
await mount();
expect(button()).toBeNull();
expect(events("desktop_cta_eligibility")).toEqual([
{
eligibility: "no_app_for_platform",
os: "win32",
app_installed: false,
download_available: false,
},
]);
expect(events("desktop_cta_impression")).toEqual([]);
});
it("records one impression, only once the button is on screen", async () => {
serve(
Response.json({
...MAC,
available: true,
handoff: true,
downloadUrl: DOWNLOAD,
installed: true,
}),
);
await mount();
expect(events("desktop_cta_impression")).toEqual([]);
act(() => reveal!());
expect(events("desktop_cta_impression")).toEqual([{ eligibility: "open_in_app", ...MAC }]);
expect(reveal).toBeNull();
});
it("records the click and its outcome for each way a hand-off ends", async () => {
const outcomes = async (post: unknown) => {
track.mockReset();
serve(
Response.json({
...MAC,
available: true,
handoff: true,
downloadUrl: DOWNLOAD,
installed: true,
}),
post,
);
await mount();
await act(async () => button()!.click());
const result = { clicks: events("toolbar_action"), outcomes: events("desktop_cta_outcome") };
act(() => mounted!.root.unmount());
mounted = null;
return result;
};
expect(await outcomes({ opened: true })).toEqual({
clicks: [{ action: "open_in_desktop", ...MAC }],
outcomes: [{ outcome: "opened", ...MAC }],
});
expect(
(await outcomes({ opened: false, reason: "open-failed", downloadUrl: DOWNLOAD })).outcomes,
).toEqual([{ outcome: "failed", reason: "open-failed", ...MAC }]);
expect(
(await outcomes({ opened: false, reason: "not-installed", downloadUrl: DOWNLOAD })).outcomes,
).toEqual([{ outcome: "failed", reason: "not-installed", ...MAC }]);
});
it("records a press the preview server never answered as failed", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === "POST") throw new TypeError("network");
return Response.json({
...MAC,
available: true,
handoff: true,
downloadUrl: DOWNLOAD,
installed: true,
});
}),
);
await mount();
await act(async () => button()!.click());
expect(events("desktop_cta_outcome")).toEqual([
{ outcome: "failed", reason: "preview_server_unreachable", ...MAC },
]);
});
it("records the download press as sent to the download, never as an install", async () => {
const linux = { os: "linux", app_installed: false, download_available: true };
vi.stubGlobal("open", () => ({ opener: null }));
serve(
Response.json({
...linux,
available: true,
handoff: false,
downloadUrl: `${DOWNLOAD}?os=linux`,
installed: false,
}),
);
await mount();
await act(async () => button()!.click());
await act(async () => cardButton("Download for Linux")!.click());
expect(events("toolbar_action")).toEqual([{ action: "get_desktop_app", ...linux }]);
expect(events("desktop_cta_outcome")).toEqual([{ outcome: "sent_to_download", ...linux }]);
const sent = JSON.stringify(track.mock.calls);
expect(sent).not.toMatch(/"(outcome|step)":"[a-z_]*install/);
expect(sent).not.toContain("/");
});
@@ -1,41 +1,121 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useStudioShellContext } from "../contexts/StudioContext";
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
import { resolveTabId } from "../telemetry/tabId";
import { studioApiFetch } from "../utils/studioApiFetch";
import { trackStudioEvent } from "../utils/studioTelemetry";
import {
GuideCard,
Swap,
isWaiting,
labelOf,
moodOf,
type DesktopRoute,
type Progress,
} from "./DesktopGuideCard";
import { FrameyGlyph } from "./FrameyGlyph";
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui";
import { Button, Popover, cn } 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.
// "Install the desktop app": the preview server (`/api/open-in-desktop`) says whether to show it, whether the app is
// here and whether it takes the project. With the app ready a press opens the project in it; otherwise the press
// opens a card where Framey walks through download, install and open.
const ROUTE = "/api/open-in-desktop";
/** 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;
/** While the card is open and the app is not here yet, how often to ask the preview server again. */
const POLL_MS = 2000;
/** After this long without the app turning up, the card asks whether the download finished. */
const SLOW_MS = 60_000;
type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string };
type DesktopRoute = { handoff: boolean; downloadUrl: string | null };
type Eligibility = "open_in_app" | "download" | "no_app_for_platform";
/** Null hides the button: no route (the app's own Studio), or nothing here to open or download. */
function useDesktopRoute(): DesktopRoute | null {
/** Every Guide step change, for the funnel: never a download counted as an install. */
type GuideStep = "opened" | "closed" | "download_opened" | "install_detected";
/** The download, tagged with this page's telemetry `tab_id` so a later install can be matched to the press. */
const withCta = (url: string): string => {
const tagged = new URL(url);
tagged.searchParams.set("hf_cta", resolveTabId());
return tagged.toString();
};
const eligibilityOf = (route: DesktopRoute): Eligibility =>
route.handoff ? "open_in_app" : route.downloadUrl ? "download" : "no_app_for_platform";
/** The preview server's answer, or null when there is none (the desktop's own Studio, or no server). */
async function readRoute(): Promise<DesktopRoute | null> {
const res = await studioApiFetch(ROUTE);
if (!res.ok) return null;
const body = (await res.json()) as {
available?: unknown;
handoff?: unknown;
downloadUrl?: unknown;
os?: unknown;
installed?: unknown;
} | null;
if (!body) return null;
const downloadUrl = typeof body.downloadUrl === "string" ? body.downloadUrl : null;
return {
handoff: body.available === true && body.handoff === true,
downloadUrl: body.available === true ? downloadUrl : null,
context: {
os: typeof body.os === "string" ? body.os : "unknown",
app_installed: body.installed === true,
download_available: downloadUrl !== null,
},
};
}
/** Null hides the button: no route, or nothing here to open or download. `setRoute` takes a fresher answer. */
function useDesktopRoute() {
const [route, setRoute] = useState<DesktopRoute | null>(null);
useEffect(() => {
let live = true;
studioApiFetch(ROUTE)
.then((res) => (res.ok ? res.json() : null))
.then((body: { available?: unknown; handoff?: unknown; downloadUrl?: unknown } | null) => {
if (!live || body?.available !== true) return;
const handoff = body.handoff === true;
const downloadUrl = typeof body.downloadUrl === "string" ? body.downloadUrl : null;
if (handoff || downloadUrl) setRoute({ handoff, downloadUrl });
readRoute()
.then((answered) => {
if (!live || !answered) return;
trackStudioEvent("desktop_cta_eligibility", {
eligibility: eligibilityOf(answered),
...answered.context,
});
if (answered.handoff || answered.downloadUrl) setRoute(answered);
})
.catch(() => {});
return () => {
live = false;
};
}, []);
return route;
return [route, setRoute] as const;
}
/** One impression per page, once the button is on screen. */
function useImpression(anchor: React.RefObject<HTMLElement | null>, route: DesktopRoute | null) {
const shown = route !== null;
const routeRef = useRef(route);
routeRef.current = route;
useEffect(() => {
const el = anchor.current;
if (!shown || !el) return;
const record = () => {
const now = routeRef.current!;
trackStudioEvent("desktop_cta_impression", {
eligibility: eligibilityOf(now),
...now.context,
});
};
if (typeof IntersectionObserver === "undefined") return record();
const seen = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
seen.disconnect();
record();
});
seen.observe(el);
return () => seen.disconnect();
}, [anchor, shown]);
}
/** Framey looks at the pointer: one listener, one write per frame, no re-render. */
@@ -64,97 +144,190 @@ function useGaze(
}, [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<string | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const eye = useRef<SVGGElement>(null);
useGaze(trigger, eye);
if (!route) return null;
/** While the card is open and the app is not here, ask the preview server again; stops on close or once found. */
function useInstallWatch(watching: boolean, onAnswer: (route: DesktopRoute) => void): void {
useEffect(() => {
if (!watching) return;
let live = true;
const ask = () =>
readRoute()
.then((answered) => live && answered && onAnswer(answered))
.catch(() => {});
void ask();
const timer = setInterval(ask, POLL_MS);
return () => {
live = false;
clearInterval(timer);
};
}, [watching, onAnswer]);
}
const open = async () => {
if (!route.handoff) {
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
setDownloadUrl(route.downloadUrl);
const LOOK =
"hf-framey-trigger gap-1.5 border border-accent/40 bg-accent/10 pl-1 font-semibold text-text-0 enabled:hover:bg-accent/20 hover:bg-accent/20";
const LOOK_FAILED = "border-danger/45 bg-danger/8 text-danger-ink enabled:hover:bg-danger/14";
/** The guide's state and the two actions that move it: the download and the hand-off. */
function useGuide(route: DesktopRoute | null, setRoute: (route: DesktopRoute) => void) {
const { showToast } = useStudioShellContext();
const [cardOpen, setCardOpen] = useState(false);
const [opening, setOpening] = useState(false);
const [failed, setFailed] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const [slow, setSlow] = useState(false);
const [arrived, setArrived] = useState(false);
const installed = route?.context.app_installed === true;
// The funnel counts one install per page, even if the app is removed and found again.
const installCounted = useRef(false);
const onAnswer = useCallback(
(answered: DesktopRoute) => {
if (!answered.context.app_installed) return;
setRoute(answered);
setArrived(true);
if (installCounted.current) return;
installCounted.current = true;
trackStudioEvent("desktop_cta_guide", { step: "install_detected", ...answered.context });
},
[setRoute],
);
useInstallWatch(cardOpen && route !== null && !installed, onAnswer);
useEffect(() => {
if (!downloaded || installed) return;
setSlow(false);
const timer = setTimeout(() => setSlow(true), SLOW_MS);
return () => clearTimeout(timer);
}, [downloaded, installed]);
if (!route) return null;
const context = route.context;
const guideStep = (step: GuideStep) =>
trackStudioEvent("desktop_cta_guide", { step, ...context });
const outcome = (result: "opened" | "sent_to_download" | "failed", reason?: string) =>
trackStudioEvent("desktop_cta_outcome", {
outcome: result,
...(reason ? { reason } : {}),
...context,
});
const showCard = (next: boolean) => {
if (next === cardOpen) return;
setCardOpen(next);
guideStep(next ? "opened" : "closed");
};
const download = () => {
if (!route.downloadUrl) return;
trackStudioEvent("toolbar_action", { action: "get_desktop_app", ...context });
const tab = window.open(withCta(route.downloadUrl), "_blank");
if (!tab) {
showToast(`Get the HyperFrames desktop app at ${route.downloadUrl}`, "info");
outcome("failed", "download_tab_blocked");
return;
}
tab.opener = null;
setDownloaded(true);
outcome("sent_to_download");
guideStep("download_opened");
};
const landed = async (result: OpenResult) => {
if (result.opened) {
outcome("opened");
showCard(false);
showToast("Opening this project in the HyperFrames desktop app", "info");
await new Promise((done) => setTimeout(done, OPENING_MS));
return;
}
outcome("failed", result.reason);
// The app is gone: back to the download. Anything else: say so where the person is, with a way through.
if (result.reason === "not-installed")
setRoute({ ...route, handoff: false, context: { ...context, app_installed: false } });
else setFailed(true);
showCard(true);
};
const open = async () => {
if (opening) return;
setOpening(true);
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
setFailed(false);
trackStudioEvent("toolbar_action", { action: "open_in_desktop", ...context });
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 the HyperFrames desktop app", "info");
await new Promise((done) => setTimeout(done, OPENING_MS));
} else setDownloadUrl(result.downloadUrl);
const res = await studioApiFetch(ROUTE, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cta: resolveTabId() }),
});
await landed((await res.json()) as OpenResult);
} catch {
outcome("failed", "preview_server_unreachable");
showToast("Couldn't reach the preview server to open the app", "error");
} finally {
setOpening(false);
}
};
const progress: Progress = { route, opening, failed, downloaded, slow, arrived };
return { progress, cardOpen, showCard, download, open };
}
export function OpenInDesktopButton() {
const projectDir = useFileManagerContextOptional()?.projectDir ?? null;
const [route, setRoute] = useDesktopRoute();
const trigger = useRef<HTMLButtonElement>(null);
const eye = useRef<SVGGElement>(null);
useGaze(trigger, eye);
useImpression(trigger, route);
const guide = useGuide(route, setRoute);
if (!guide) return null;
const { progress, cardOpen, showCard, download, open } = guide;
const label = labelOf(progress);
const waiting = isWaiting(progress);
return (
<Popover
open={downloadUrl !== null}
// The popover owns the trigger's press: a press runs the hand-off, and the card shows only when there is
// something to say (the download). A second handler on the button raced this one.
open={cardOpen}
// With the app ready the press is the hand-off itself; everything else opens the card.
onOpenChange={(next) => {
if (next) void open();
else setDownloadUrl(null);
if (next && progress.route.handoff && !progress.failed) return void open();
showCard(next);
}}
side="bottom"
align="end"
arrow
aria-label="Meet Framey"
className="w-72"
aria-label="Get the desktop app"
className="w-[360px] overflow-hidden p-0"
trigger={
<Button
ref={trigger}
variant="ghost"
data-testid="header-open-in-desktop"
data-opening={opening || undefined}
title="Open this project in the HyperFrames desktop app and edit it with Framey"
className="hf-framey-trigger gap-1.5 border border-accent/40 bg-accent/10 pl-1 font-semibold text-text-0 enabled:hover:bg-accent/20"
icon={<FrameyGlyph size={24} className="hf-framey" eyeRef={eye} />}
data-opening={progress.opening || undefined}
aria-busy={progress.opening || waiting || undefined}
title={
progress.route.handoff
? "Open this project in the HyperFrames desktop app"
: "Get the free HyperFrames desktop app"
}
className={cn(LOOK, progress.failed && LOOK_FAILED)}
icon={
<FrameyGlyph size={24} className="hf-framey" eyeRef={eye} mood={moodOf(progress)} />
}
>
{opening ? "Opening in the app…" : "Edit with Framey"}
{!opening && (
<span className="rounded-full bg-accent px-1.5 text-[10px] leading-4 font-bold text-on-accent">
New
<Swap value={label}>{label}</Swap>
{waiting && (
<span className="rounded-full bg-on px-1.5 text-[10px] leading-4 font-bold text-accent-ink tabular-nums">
2 of 3
</span>
)}
</Button>
}
>
<div className="flex items-center gap-2.5">
<FrameyGlyph size={36} />
<p className="text-step-12 font-medium text-text-0">Meet Framey</p>
</div>
<p className="mt-2 text-balance text-text-3">I live in the free HyperFrames desktop app:</p>
<ul className="mt-1.5 grid gap-1 text-text-1">
<li>Chat to change anything in the video</li>
<li>Draw on a frame to point at it</li>
<li>Watch me act out each edit</li>
</ul>
<div className="mt-3 flex items-center justify-end gap-2 whitespace-nowrap">
<Button variant="ghost" onClick={() => setDownloadUrl(null)}>
Not now
</Button>
<a
href={downloadUrl ?? undefined}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
>
Get the desktop app
</a>
</div>
<GuideCard
progress={progress}
projectDir={projectDir}
onDownload={download}
onOpen={() => void open()}
/>
</Popover>
);
}
+161 -1
View File
@@ -956,7 +956,7 @@
}
}
/* Framey on the header's "Edit with Framey" (OpenInDesktopButton.tsx): a pulse as the page opens, a wiggle
/* Framey on the header's "Install the desktop app" (OpenInDesktopButton.tsx): a pulse as the page opens, a wiggle
* on hover, a flight towards the app while it opens. The eye follows the pointer from script. */
@keyframes hf-framey-wiggle {
0%,
@@ -1020,3 +1020,163 @@
transition: none;
}
}
/* Framey's moods, timed as the desktop app times them: it breathes and blinks, its eyes circle while it waits,
* it hops once when the app turns up, flies while a project hands over, and shakes when that fails. */
.hf-framey-mood .hf-framey-body {
transform-box: fill-box;
transform-origin: 50% 60%;
animation: hf-framey-breath 3.27s ease-in-out infinite;
}
.hf-framey-mood .hf-framey-lid,
.hf-framey-mood .hf-framey-whole {
transform-box: fill-box;
transform-origin: 50% 50%;
}
.hf-framey-mood .hf-framey-whole {
transform-origin: 50% 100%;
}
.hf-framey-mood:is([data-mood="idle"], [data-mood="found"]) .hf-framey-lid {
animation: hf-framey-blink 4.6s infinite;
}
.hf-framey-mood[data-mood="found"] .hf-framey-whole {
animation: hf-framey-hop 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) 2;
}
.hf-framey-mood[data-mood="opening"] .hf-framey-whole {
animation: hf-framey-handoff 2.4s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.hf-framey-mood[data-mood="error"] .hf-framey-whole {
animation: hf-framey-shake 0.6s ease-in-out 1;
}
.hf-framey-orbit {
animation: hf-framey-orbit 1.4s linear infinite;
}
@keyframes hf-framey-breath {
50% {
transform: scale(1.018);
}
}
@keyframes hf-framey-blink {
0%,
93%,
100% {
transform: scaleY(1);
}
95.5% {
transform: scaleY(0.12);
}
}
@keyframes hf-framey-hop {
0%,
40%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-7px);
}
}
@keyframes hf-framey-orbit {
0%,
100% {
transform: translate(3px, 0);
}
25% {
transform: translate(0, 3px);
}
50% {
transform: translate(-3px, 0);
}
75% {
transform: translate(0, -3px);
}
}
@keyframes hf-framey-handoff {
0% {
transform: none;
}
45%,
60% {
transform: translate(14px, -4px) rotate(10deg);
opacity: 1;
}
80% {
transform: translate(30px, -8px) rotate(12deg);
opacity: 0;
}
81% {
transform: translate(-10px, 0);
opacity: 0;
}
100% {
transform: none;
opacity: 1;
}
}
@keyframes hf-framey-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-3px);
}
40% {
transform: translateX(3px);
}
60% {
transform: translateX(-2px);
}
80% {
transform: translateX(2px);
}
}
/* The desktop-app guide card: a label or step that changes settles in; a finished step's tick pops in. */
.hf-guide-swap {
animation: hf-guide-swap 180ms var(--ease-out-quint);
}
.hf-guide-tick {
animation: hf-guide-tick 300ms var(--ease-out-quint);
}
@keyframes hf-guide-swap {
from {
opacity: 0;
transform: translateY(3px);
filter: blur(2px);
}
}
@keyframes hf-guide-tick {
from {
opacity: 0;
transform: scale(0.3);
filter: blur(3px);
}
}
@media (prefers-reduced-motion: reduce) {
.hf-framey-mood .hf-framey-body,
.hf-framey-mood .hf-framey-lid,
.hf-framey-mood .hf-framey-whole,
.hf-framey-orbit,
.hf-guide-swap,
.hf-guide-tick {
animation: none;
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ const GET_RESPONSES = new Map([
// interesting assertion is that the shell mounts clean, not that a blocking
// notice renders. The notice has its own tests.
["/api/environment/ffmpeg", json({ ok: true })],
// The header's Edit with Framey asks this on load; shown, so the shell mounts with it.
// The header's "Install the desktop app" asks this on load; shown, so the shell mounts with it.
[
"/api/open-in-desktop",
json({