Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions packages/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,33 @@ import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { setCommandExitCode } from "../utils/commandResult.js";
import { DOWNLOAD_HINT, openInDesktop, type DesktopOpenResult } from "../utils/desktopApp.js";
import { downloadHint, openInDesktop, type DesktopOpenResult } from "../utils/desktopApp.js";
import { resolveProject, resolveProjectOrThrow, type ProjectDir } from "../utils/project.js";

export const examples: Example[] = [
["Open this project in HyperFrames Studio", "hyperframes open"],
["Open this project in the HyperFrames desktop app", "hyperframes open"],
["Open another project", "hyperframes open ./my-video"],
["For agents", "hyperframes open --json"],
];

const WHY_NOT: Record<Extract<DesktopOpenResult, { opened: false }>["reason"], string | null> = {
"handoff-unavailable": null,
"unsupported-platform": "Opening a project from the CLI works on macOS only.",
"not-installed": "HyperFrames Studio isn't installed on this Mac.",
"open-failed": "macOS couldn't open HyperFrames Studio.",
"unsupported-platform": "Opening a project from the CLI works on macOS, Windows and Linux only.",
"not-installed": "The HyperFrames desktop app isn't installed on this computer.",
"open-failed": "The HyperFrames desktop app couldn't be started.",
};

function printResult(project: ProjectDir, result: DesktopOpenResult): void {
if (result.opened) {
console.log(`${c.success("◇")} Opening ${c.accent(project.name)} in HyperFrames Studio`);
console.log(`${c.success("◇")} Opening ${c.accent(project.name)} in ${result.app}`);
if (result.handedOver) {
const agent = result.handedOver.engine === "claude" ? "Claude Code" : "Codex";
console.log(` ${c.dim(`Its chat picks up this ${agent} conversation.`)}`);
}
return;
}
const why = WHY_NOT[result.reason];
if (!why) return console.log(`${c.warn("◇")} ${DOWNLOAD_HINT}`);
if (!why) return console.log(`${c.warn("◇")} ${downloadHint(result.downloadUrl)}`);
console.log(`${c.warn("◇")} ${why}`);
console.log(` Download it: ${c.accent(result.downloadUrl)}`);
}
Expand All @@ -46,7 +46,7 @@ function projectForJson(dir: string | undefined): ProjectDir | null {
}

export default defineCommand({
meta: { name: "open", description: "Open a project in HyperFrames Studio, the desktop app" },
meta: { name: "open", description: "Open a project in the HyperFrames desktop app" },
args: {
dir: {
type: "positional",
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1905,9 +1905,9 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
}
}

/** A delivered render points to the desktop app; a draft or a batch row does not. */
export function wantsDesktopHint(options: Pick<RenderOptions, "desktopHint" | "quality">): boolean {
return options.desktopHint !== false && options.quality !== "draft";
/** A render points to the desktop app; a batch row does not. Drafts do too: music-to-video delivers one. */
export function wantsDesktopHint(options: Pick<RenderOptions, "desktopHint">): boolean {
return options.desktopHint !== false;
}

function printRenderComplete(input: {
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/commands/render/execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ describe("renderOptionsFromPlan", () => {
});

describe("the desktop-app line after a render", () => {
it("comes after a delivered render, never after a draft or a batch row", () => {
expect(wantsDesktopHint({ quality: "high" })).toBe(true);
expect(wantsDesktopHint({ quality: "draft" })).toBe(false);
expect(wantsDesktopHint({ quality: "high", desktopHint: false })).toBe(false);
it("comes after every render but a batch row", () => {
expect(wantsDesktopHint({})).toBe(true);
expect(wantsDesktopHint({ desktopHint: false })).toBe(false);
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-batch-"));
try {
writeFileSync(join(projectDir, "index.html"), '<main data-composition-id="main"></main>');
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +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"],
["open", "Open a project in the HyperFrames desktop app"],
],
},
{
Expand Down
29 changes: 23 additions & 6 deletions packages/cli/src/server/desktopRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import type { DesktopOpenResult } from "../utils/desktopApp.js";
import { desktopDownloadUrl, type DesktopOpenResult } from "../utils/desktopApp.js";
import { mountDesktopRoutes, sameOriginPost } from "./desktopRoutes.js";

const STUDIO = {
Expand All @@ -10,23 +10,40 @@ const STUDIO = {
};
const OPENED: DesktopOpenResult = {
opened: true,
bundleId: "dev.hyperframes.desktop",
app: "the HyperFrames desktop app",
handedOver: null,
};

function server(ready: boolean) {
function server(
ready: boolean,
place: { platform?: NodeJS.Platform; installed?: () => boolean } = {},
) {
const app = new Hono();
const open = vi.fn((_dir: string): DesktopOpenResult => OPENED);
mountDesktopRoutes(app, "/films/a", { ready, open });
mountDesktopRoutes(app, "/films/a", { ready, open, ...place });
const post = (headers: Record<string, string>) =>
app.request("/api/open-in-desktop", { method: "POST", headers });
return { app, open, post };
}

describe("open-in-desktop routes", () => {
it("tells Studio whether to show the button and whether the app can take the project", async () => {
const body = await (await server(false).app.request("/api/open-in-desktop")).json();
expect(body).toEqual({ available: process.platform === "darwin", handoff: false });
const body = await (
await server(false, { installed: () => false }).app.request("/api/open-in-desktop")
).json();
const downloadUrl = desktopDownloadUrl();
expect(body).toEqual({ available: downloadUrl !== null, handoff: false, downloadUrl });
});

it("shows the button where the app is installed even without a download (Windows)", async () => {
const get = async (installed: boolean) =>
(
await server(true, { platform: "win32", installed: () => installed }).app.request(
"/api/open-in-desktop",
)
).json();
expect(await get(true)).toEqual({ available: true, handoff: true, downloadUrl: null });
expect(await get(false)).toMatchObject({ available: false });
});

it("refuses a POST from another site, or through a rebound host name", async () => {
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/src/server/desktopRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Hono } from "hono";
import { DESKTOP_DOWNLOAD_URL, HANDOFF_READY, openInDesktop } from "../utils/desktopApp.js";
import {
HANDOFF_READY,
desktopDownloadUrl,
desktopInstalled,
openInDesktop,
} from "../utils/desktopApp.js";
import { identityAllowed } from "./telemetryIdentity.js";

/** A bodiless POST is a simple request, so any page can send one to localhost: it has to come from this Studio. */
Expand All @@ -14,14 +19,20 @@ export function sameOriginPost(headers: {
return origin === undefined || origin === `http://${host}`;
}

/** Studio's Edit with Framey: the GET says whether to show it (macOS) and whether the app can take the project. */
/** Edit with Framey's route: whether to show it, where to download, and whether the app takes the project. */
export function mountDesktopRoutes(
app: Hono,
projectDir: string,
{ ready = HANDOFF_READY, open = openInDesktop } = {},
{
ready = HANDOFF_READY,
open = openInDesktop,
platform = process.platform,
installed = () => desktopInstalled({ platform }),
} = {},
): void {
const downloadUrl = desktopDownloadUrl(platform);
app.get("/api/open-in-desktop", (c) =>
c.json({ available: process.platform === "darwin", handoff: ready }),
c.json({ available: downloadUrl !== null || installed(), handoff: ready, downloadUrl }),
);
app.post("/api/open-in-desktop", (c) => {
const allowed = sameOriginPost({
Expand All @@ -34,7 +45,7 @@ export function mountDesktopRoutes(
return c.json({
opened: false,
reason: "handoff-unavailable",
downloadUrl: DESKTOP_DOWNLOAD_URL,
downloadUrl,
});
return c.json(open(projectDir));
});
Expand Down
Loading
Loading