mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Merge branch 'fix/cu-download-page-20260913' into feat/pet-mode-20260913
This commit is contained in:
commit
a0b81f619b
5 files changed
+170
-29
No files matched your search
@@ -4,6 +4,7 @@ import type { LocalizedText } from "@/lib/content/vocabulary";
|
||||
import { pickText } from "@/lib/i18n/dictionaries";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
import { COMPUTER_USE_REPO, getComputerUseRelease } from "@/lib/computer-use-release";
|
||||
import { getEnv } from "@/lib/kv";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
@@ -16,7 +17,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
export default async function ComputerUsePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = (copy: LocalizedText) => pickText(copy, locale);
|
||||
const release = await getComputerUseRelease();
|
||||
const release = await getComputerUseRelease((await getEnv()).GITHUB_TOKEN);
|
||||
return (
|
||||
<div>
|
||||
<section className="hero">
|
||||
|
||||
@@ -7,15 +7,21 @@ export const alt = OG_ALT;
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
const mark = readFile(join(process.cwd(), "public/brand/mark.svg")).then((svg) =>
|
||||
svg.toString().replace("currentColor", "#ffffff"),
|
||||
);
|
||||
const wordmark = readFile(join(process.cwd(), "public/brand/wordmark-inverted.svg")).then(
|
||||
(svg) => svg.toString(),
|
||||
);
|
||||
// Read the brand SVGs when the image is generated, never at import time. This
|
||||
// route is prerendered at build on Node, but its module is still evaluated
|
||||
// inside the Cloudflare Worker whenever a page regenerates, and the Workers
|
||||
// Node shim throws on fs.readFile. A top-level read here made every ISR
|
||||
// regeneration on the site fail with a 500 and serve its build snapshot forever.
|
||||
async function brandSvgs(): Promise<[string, string]> {
|
||||
const [mark, wordmark] = await Promise.all([
|
||||
readFile(join(process.cwd(), "public/brand/mark.svg")),
|
||||
readFile(join(process.cwd(), "public/brand/wordmark-inverted.svg")),
|
||||
]);
|
||||
return [mark.toString().replace("currentColor", "#ffffff"), wordmark.toString()];
|
||||
}
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const [markSvg, wordmarkSvg] = await Promise.all([mark, wordmark]);
|
||||
const [markSvg, wordmarkSvg] = await brandSvgs();
|
||||
const markDataUrl = `data:image/svg+xml;base64,${Buffer.from(markSvg).toString("base64")}`;
|
||||
const wordmarkDataUrl = `data:image/svg+xml;base64,${Buffer.from(wordmarkSvg).toString("base64")}`;
|
||||
|
||||
|
||||
@@ -11,12 +11,27 @@ const fixture = () => ({ tag_name: "v0.3.0", draft: false, prerelease: false,
|
||||
const receipt = () => ({ version: "0.3.0", platform: "macos", arch: "universal", archive,
|
||||
sha256, size: 80000000, notarized: true });
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
const API_LATEST = "https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest";
|
||||
const WEB_RECEIPT = `${COMPUTER_USE_REPO}/releases/latest/download/release.json`;
|
||||
const OBJECT_URL = "https://objects.githubusercontent.com/github-production-release-asset/1/release.json?X-Amz-Signature=x";
|
||||
const status = (code: number) => new Response(null, { status: code });
|
||||
const redirect = (location: string) => new Response(null, { status: 302, headers: { location } });
|
||||
const stub = (...responses: unknown[]) => {
|
||||
const fetcher = vi.fn();
|
||||
for (const r of responses) {
|
||||
if (r instanceof Error) fetcher.mockRejectedValueOnce(r); else fetcher.mockResolvedValueOnce(r);
|
||||
}
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return fetcher;
|
||||
};
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
|
||||
|
||||
describe("Computer Use download qualification", () => {
|
||||
it("offers the exact archive when the release, receipt and GitHub digest agree", () => {
|
||||
expect(qualifiedComputerUseRelease(fixture(), receipt())).toMatchObject({
|
||||
status: "ready", version: "0.3.0", sha256, downloadUrl: asset(archive, 80000000).browser_download_url,
|
||||
verification: "github-digest",
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
@@ -36,29 +51,86 @@ describe("Computer Use download qualification", () => {
|
||||
}
|
||||
});
|
||||
it("loads only the canonical release and its matching receipt", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json(fixture())).mockResolvedValueOnce(Response.json(receipt()));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
expect((await getComputerUseRelease()).status).toBe("ready");
|
||||
expect(fetcher.mock.calls.map(c => c[0])).toEqual([
|
||||
"https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest",
|
||||
`${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`,
|
||||
]);
|
||||
const fetcher = stub(Response.json(fixture()), Response.json(receipt()));
|
||||
expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "github-digest" });
|
||||
expect(fetcher.mock.calls.map(c => c[0])).toEqual([API_LATEST, `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`]);
|
||||
});
|
||||
it("distinguishes no published installer from a failed availability check", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 })).mockRejectedValueOnce(new Error("offline"));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
it("sends the server-held token to the API exactly when one is passed, and never elsewhere", async () => {
|
||||
const fetcher = stub(status(404), status(404), status(404));
|
||||
await getComputerUseRelease("ghp_secret");
|
||||
await getComputerUseRelease();
|
||||
const headers = (call: number) => fetcher.mock.calls[call][1].headers as Record<string, string>;
|
||||
expect(headers(0).Authorization).toBe("Bearer ghp_secret");
|
||||
expect(headers(1)).not.toHaveProperty("Authorization");
|
||||
await getComputerUseRelease("");
|
||||
expect(headers(2)).not.toHaveProperty("Authorization");
|
||||
});
|
||||
it("reports no published installer when both the API and the release endpoint say so", async () => {
|
||||
const fetcher = stub(status(404));
|
||||
expect((await getComputerUseRelease()).status).toBe("pending");
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("falls back to the release web endpoint when the API refuses, and stays honest when that fails too", async () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
stub(status(403), status(404));
|
||||
expect((await getComputerUseRelease()).status).toBe("pending");
|
||||
expect(error).toHaveBeenCalledWith("computer-use release check", 403);
|
||||
stub(status(503), new Error("offline"));
|
||||
expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable");
|
||||
stub(new Error("offline"), status(404));
|
||||
expect((await getComputerUseRelease("ghp_secret")).status).toBe("pending");
|
||||
expect(error).toHaveBeenCalledWith("computer-use release check failed", "offline");
|
||||
stub(status(403), status(500));
|
||||
expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable");
|
||||
expect(error.mock.calls.flat().join(" ")).not.toContain("ghp_");
|
||||
});
|
||||
it("qualifies the download from the receipt when the API is unreachable", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), redirect(OBJECT_URL));
|
||||
expect(await getComputerUseRelease()).toEqual({
|
||||
status: "ready", version: "0.3.0", sha256, size: 80000000, verification: "receipt",
|
||||
url: `${COMPUTER_USE_REPO}/releases/tag/v0.3.0`,
|
||||
downloadUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`,
|
||||
receiptUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`,
|
||||
});
|
||||
expect(fetcher.mock.calls.map(c => [c[0], c[1].method ?? "GET", c[1].redirect])).toEqual([
|
||||
[API_LATEST, "GET", undefined], [WEB_RECEIPT, "GET", "manual"], [OBJECT_URL, "GET", "manual"],
|
||||
[`${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`, "HEAD", "manual"],
|
||||
]);
|
||||
expect(fetcher.mock.calls.every(c => !("Authorization" in (c[1].headers ?? {})))).toBe(true);
|
||||
});
|
||||
it("accepts a directly served archive and a permanent redirect for the receipt", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
stub(status(403), new Response(null, { status: 301, headers: { location: OBJECT_URL } }), Response.json(receipt()), status(200));
|
||||
expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "receipt" });
|
||||
});
|
||||
it.each([
|
||||
["a disallowed host", redirect("https://example.com/release.json")],
|
||||
["a redirect without a location", new Response(null, { status: 302 })],
|
||||
["plain http", redirect("http://objects.githubusercontent.com/release.json")],
|
||||
])("refuses a receipt redirect onto %s", async (_label, hop) => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const fetcher = stub(status(403), hop, Response.json(receipt()));
|
||||
expect((await getComputerUseRelease()).status).toBe("unavailable");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("withholds the fallback when the receipt is unqualified or the archive is not served", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
stub(status(403), redirect(OBJECT_URL), Response.json({ ...receipt(), notarized: false }));
|
||||
expect((await getComputerUseRelease()).status).toBe("unavailable");
|
||||
stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), status(404));
|
||||
expect((await getComputerUseRelease()).status).toBe("unavailable");
|
||||
const fetcher = stub(status(403), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL));
|
||||
expect((await getComputerUseRelease()).status).toBe("unavailable");
|
||||
expect(fetcher).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
it("bounds malformed or oversized responses and does not fetch an unqualified receipt", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(new Response("x".repeat(128 * 1024 + 1)))
|
||||
.mockResolvedValueOnce(Response.json({ ...fixture(), draft: true }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const fetcher = stub(new Response("x".repeat(128 * 1024 + 1)), new Error("offline"),
|
||||
Response.json({ ...fixture(), draft: true }));
|
||||
expect((await getComputerUseRelease()).status).toBe("unavailable");
|
||||
expect((await getComputerUseRelease()).status).toBe("pending");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it("keeps production builds offline without claiming that a release is available", async () => {
|
||||
vi.stubEnv("NEXT_PHASE", "phase-production-build");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** The helper has its own release lifecycle, independent of the Codewhale CLI. */
|
||||
export const COMPUTER_USE_REPO = "https://github.com/Hmbown/codewhale-cu-plugin";
|
||||
const API = "https://api.github.com/repos/Hmbown/codewhale-cu-plugin";
|
||||
/** Hosts GitHub redirects release downloads through; anything else is refused. */
|
||||
const RELEASE_HOSTS = new Set(["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
function record(value: unknown): RecordValue {
|
||||
@@ -16,6 +18,8 @@ export type ComputerUseRelease = {
|
||||
receiptUrl: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
/** Which independent evidence confirmed the archive: GitHub's asset digest via the API, or the packager's receipt via the release web endpoint. */
|
||||
verification: "github-digest" | "receipt";
|
||||
} | { status: "pending" | "unavailable" };
|
||||
|
||||
function releaseAssets(value: unknown) {
|
||||
@@ -58,6 +62,7 @@ export function qualifiedComputerUseRelease(release: unknown, value: unknown): C
|
||||
url: `${COMPUTER_USE_REPO}/releases/tag/v${version}`,
|
||||
downloadUrl: zip.browser_download_url as string,
|
||||
receiptUrl: assets.receipt.browser_download_url as string,
|
||||
verification: "github-digest",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,17 +83,71 @@ async function boundedJson(response: Response, limit: number): Promise<unknown>
|
||||
} finally { await reader.cancel(); reader.releaseLock(); }
|
||||
}
|
||||
|
||||
export async function getComputerUseRelease(): Promise<ComputerUseRelease> {
|
||||
const WEB_HEADERS = { "User-Agent": "codewhale-web" };
|
||||
|
||||
/** GET a release web endpoint, following at most three 302s and only onto GitHub's release hosts over https. */
|
||||
async function fetchReleaseWeb(url: string): Promise<Response> {
|
||||
for (let hops = 0; ; hops++) {
|
||||
const response = await fetch(url, { redirect: "manual", headers: WEB_HEADERS, signal: AbortSignal.timeout(5000) });
|
||||
if (![301, 302, 307, 308].includes(response.status)) return response;
|
||||
await response.body?.cancel();
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error("Release redirect without a location");
|
||||
const target = new URL(location, url);
|
||||
if (hops >= 3 || target.protocol !== "https:" || !RELEASE_HOSTS.has(target.hostname)) {
|
||||
throw new Error(`Release redirect refused: ${target.hostname}`);
|
||||
}
|
||||
url = target.href;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the download without the GitHub API: read the latest receipt from the
|
||||
* release web endpoint, then confirm GitHub serves the archive the receipt names. */
|
||||
async function receiptQualifiedRelease(): Promise<ComputerUseRelease> {
|
||||
try {
|
||||
const response = await fetchReleaseWeb(`${COMPUTER_USE_REPO}/releases/latest/download/release.json`);
|
||||
if (response.status === 404) return { status: "pending" };
|
||||
if (!response.ok) return { status: "unavailable" };
|
||||
const receipt = record(await boundedJson(response, 16 * 1024));
|
||||
const version = typeof receipt.version === "string" && /^\d+\.\d+\.\d+$/.test(receipt.version) ? receipt.version : null;
|
||||
const archive = `Codewhale-Computer-Use-${version}-macos-universal.zip`;
|
||||
if (!version || receipt.platform !== "macos" || receipt.arch !== "universal" || receipt.notarized !== true
|
||||
|| receipt.archive !== archive || typeof receipt.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(receipt.sha256)
|
||||
|| !Number.isSafeInteger(receipt.size) || (receipt.size as number) <= 0
|
||||
|| (receipt.size as number) > 256 * 1024 * 1024) return { status: "unavailable" };
|
||||
const downloadUrl = `${COMPUTER_USE_REPO}/releases/download/v${version}/${archive}`;
|
||||
const head = await fetch(downloadUrl, { method: "HEAD", redirect: "manual", headers: WEB_HEADERS, signal: AbortSignal.timeout(5000) });
|
||||
await head.body?.cancel();
|
||||
if (head.status !== 302 && head.status !== 200) return { status: "unavailable" };
|
||||
return {
|
||||
status: "ready", version, sha256: receipt.sha256, size: receipt.size as number,
|
||||
url: `${COMPUTER_USE_REPO}/releases/tag/v${version}`, downloadUrl,
|
||||
receiptUrl: `${COMPUTER_USE_REPO}/releases/download/v${version}/release.json`,
|
||||
verification: "receipt",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("computer-use release fallback failed", error instanceof Error ? error.message : String(error));
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getComputerUseRelease(token?: string): Promise<ComputerUseRelease> {
|
||||
// Match the site's offline build policy; ISR resolves availability after deployment.
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") return { status: "unavailable" };
|
||||
try {
|
||||
const options = { next: { revalidate: 300 }, signal: AbortSignal.timeout(5000) };
|
||||
const response = await fetch(`${API}/releases/latest`, {
|
||||
...options,
|
||||
headers: { Accept: "application/vnd.github+json", "User-Agent": "codewhale-web", "X-GitHub-Api-Version": "2022-11-28" },
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json", "User-Agent": "codewhale-web", "X-GitHub-Api-Version": "2022-11-28",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (response.status === 404) return { status: "pending" };
|
||||
if (!response.ok) return { status: "unavailable" };
|
||||
if (!response.ok) {
|
||||
console.error("computer-use release check", response.status);
|
||||
return receiptQualifiedRelease();
|
||||
}
|
||||
const release = await boundedJson(response, 128 * 1024);
|
||||
const assets = releaseAssets(release);
|
||||
if (!assets) return { status: "pending" };
|
||||
@@ -97,5 +156,8 @@ export async function getComputerUseRelease(): Promise<ComputerUseRelease> {
|
||||
});
|
||||
if (!receipt.ok) return { status: "unavailable" };
|
||||
return qualifiedComputerUseRelease(release, await boundedJson(receipt, 16 * 1024));
|
||||
} catch { return { status: "unavailable" }; }
|
||||
} catch (error) {
|
||||
console.error("computer-use release check failed", error instanceof Error ? error.message : String(error));
|
||||
return receiptQualifiedRelease();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const COMPUTER_USE_COPY = {
|
||||
{ title: { en: "Open the app", zh: "打开应用" }, body: { en: "Expand the download, move Codewhale Computer Use to Applications, and open it. Choose Computer Use from the whale icon in your menu bar.", zh: "解压下载文件,将 Codewhale Computer Use 移入“应用程序”并打开。点击菜单栏中的鲸鱼图标,选择 Computer Use。" } },
|
||||
{ title: { en: "Review permissions", zh: "检查权限" }, body: { en: "Use the setup buttons to open Accessibility and Screen Recording in System Settings. You choose which permissions to grant.", zh: "通过设置按钮打开系统设置中的“辅助功能”和“屏幕录制”。由你决定授予哪些权限。" } },
|
||||
{ title: { en: "Run the background check", zh: "运行后台检查" }, body: { en: "The helper opens a disposable practice window, enters text, and captures that window. It checks whether the pointer or active app changed during the run.", zh: "助手会打开一个临时练习窗口,输入文本并截取该窗口,同时检查运行期间指针或前台应用是否发生变化。" } },
|
||||
{ title: { en: "Connect it to Codewhale", zh: "连接 Codewhale" }, body: { en: "Review, trust, and enable Computer Use in Codewhale’s plugin marketplace. Use plugin 0.3.0 or later so local actions go through the helper’s Pause and Stop controls.", zh: "在 Codewhale 插件市场中审查、信任并启用 Computer Use。请使用 0.3.0 或更高版本,确保本地操作受助手的暂停和停止控制。" } },
|
||||
{ title: { en: "Connect it to Codewhale", zh: "连接 Codewhale" }, body: { en: "Review, trust, and enable Computer Use in Codewhale’s plugin marketplace. Use plugin 0.3.1 or later so local actions go through the helper’s Pause and Stop controls.", zh: "在 Codewhale 插件市场中审查、信任并启用 Computer Use。请使用 0.3.1 或更高版本,确保本地操作受助手的暂停和停止控制。" } },
|
||||
],
|
||||
controlsTitle: { en: "Keep working. Keep control.", zh: "继续工作,掌握控制权。" },
|
||||
controlsBody: { en: "Supported actions operate on the selected app in the background. Apps and gestures that need foreground control require your authorization. The menu shows the target and input mode; Pause suspends helper input, and Stop ends its existing sessions.", zh: "受支持的操作可在选定应用的后台完成。需要前台控制的应用或手势必须得到你的授权。菜单会显示目标应用与输入模式;Pause 暂停助手输入,Stop 结束助手的现有会话。" },
|
||||
|
||||
Reference in new issue
Block a user