docs(readme,landing): introduce the desktop app (#201)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,8 +75,8 @@ jobs:
|
||||
name: desktop-${{ runner.os }}
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
packages/desktop/stage/out/*.AppImage
|
||||
packages/desktop/stage/out/*.deb
|
||||
packages/desktop/stage/out/*.dmg
|
||||
packages/desktop/stage/out/*-mac.zip
|
||||
packages/desktop/stage/out/*.exe
|
||||
packages/desktop/stage/out/penguin-desktop-*.AppImage
|
||||
packages/desktop/stage/out/penguin-desktop-*.deb
|
||||
packages/desktop/stage/out/penguin-desktop-*.dmg
|
||||
packages/desktop/stage/out/penguin-desktop-*.zip
|
||||
packages/desktop/stage/out/penguin-desktop-*.exe
|
||||
|
||||
@@ -316,8 +316,8 @@ jobs:
|
||||
|
||||
# Desktop installers built by the desktop job (three-OS matrix): collected here so
|
||||
# they are part of the Release's initial (immutable) asset set. Their checksums go
|
||||
# in a separate SHA256SUMS.desktop — the OSS mirror script's canonical file list
|
||||
# stays untouched (desktop installers are not mirrored yet).
|
||||
# in a separate SHA256SUMS.desktop, which is also how the OSS mirror script verifies
|
||||
# them (its manifest lists the installers by their version-less names).
|
||||
- name: Collect desktop artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -138,6 +138,10 @@ npm install -g @prismshadow/penguin-cli
|
||||
penguin web # start the service and open http://127.0.0.1:7364
|
||||
```
|
||||
|
||||
### 🖥️ Desktop app
|
||||
|
||||
The full Web experience as a standalone application: it embeds the server and opens already signed in — no terminal, no login page, no initial password to copy — and it works on the same `~/.penguin/data` root as a CLI install, so the two can be used interchangeably (a data root only ever runs one server; if a CLI-started instance is already up, the app attaches to it). Installers for macOS (dmg, Apple silicon / Intel), Windows and Linux (AppImage / deb) live on the [download page](https://penguin.ooo/download) — which serves the OSS-accelerated mirror when it is reachable — and are attached to each [GitHub Release](https://github.com/Prism-Shadow/penguin-harness/releases). Current builds are unsigned: on first launch use right-click → Open on macOS, and "More info → Run anyway" past Windows SmartScreen.
|
||||
|
||||
<details>
|
||||
<summary><b>📴 Offline install (air-gapped machines)</b></summary>
|
||||
|
||||
@@ -198,7 +202,7 @@ for await (const output of session.run([userText("Create hello.txt containing hi
|
||||
## Roadmap
|
||||
|
||||
- [ ] Public release of the benchmark suite
|
||||
- [ ] Desktop app
|
||||
- [x] Desktop app
|
||||
- [x] Windows support
|
||||
- [ ] Agent company and templates
|
||||
- [ ] Company-level self evolving
|
||||
|
||||
+5
-1
@@ -138,6 +138,10 @@ npm install -g @prismshadow/penguin-cli
|
||||
penguin web # 启动服务并打开 http://127.0.0.1:7364
|
||||
```
|
||||
|
||||
### 🖥️ 桌面端应用
|
||||
|
||||
完整的 Web 体验打包为独立应用:内嵌服务端,打开即已登录——无需终端、无登录页、也不用抄初始密码——并与 CLI 安装共用同一个 `~/.penguin/data` 数据目录,两者可以混用(一个数据目录同一时刻只运行一个服务端;CLI 已启动实例时,应用会直接接入它)。macOS(dmg,Apple 芯片 / Intel)、Windows 与 Linux(AppImage / deb)的安装包可在[下载页](https://penguin.ooo/download)获取——国内自动走 OSS 镜像加速——也附于每个 [GitHub Release](https://github.com/Prism-Shadow/penguin-harness/releases)。当前构建暂未签名:macOS 首次启动请右键 →「打开」,Windows 在 SmartScreen 提示中选「仍要运行」。
|
||||
|
||||
<details>
|
||||
<summary><b>📴 离线安装(无网环境)</b></summary>
|
||||
|
||||
@@ -198,7 +202,7 @@ for await (const output of session.run([userText("Create hello.txt containing hi
|
||||
## 路线图
|
||||
|
||||
- [ ] Benchmark 套件正式发布
|
||||
- [ ] 桌面端应用
|
||||
- [x] 桌面端应用
|
||||
- [x] Windows 系统支持
|
||||
- [ ] Agent 公司与模板
|
||||
- [ ] 公司级自进化能力
|
||||
|
||||
@@ -26,6 +26,11 @@ mac:
|
||||
category: public.app-category.developer-tools
|
||||
# No Developer ID yet (M4): identity null skips codesign instead of failing.
|
||||
identity: null
|
||||
# Version-less artifact names, following the CLI bundles (penguin-darwin-arm64.tar.gz):
|
||||
# the version lives in the Release tag and SHA256SUMS.desktop, so the download page and
|
||||
# the OSS mirror manifest can address every installer by a stable name — and GitHub's
|
||||
# `releases/latest/download/<name>` stays a working static link across releases.
|
||||
artifactName: penguin-desktop-darwin-${arch}.${ext}
|
||||
target:
|
||||
- target: dmg
|
||||
arch: [arm64, x64]
|
||||
@@ -33,6 +38,7 @@ mac:
|
||||
arch: [arm64, x64]
|
||||
|
||||
win:
|
||||
artifactName: penguin-desktop-win32-${arch}.${ext}
|
||||
target:
|
||||
- target: nsis
|
||||
arch: [x64]
|
||||
@@ -50,6 +56,7 @@ nsis:
|
||||
linux:
|
||||
# The scoped package name is not a valid executable file name.
|
||||
executableName: penguin-harness
|
||||
artifactName: penguin-desktop-linux-${arch}.${ext}
|
||||
category: Development
|
||||
maintainer: Prism Shadow <zheng@prismshadow.com>
|
||||
target:
|
||||
|
||||
@@ -104,6 +104,9 @@ export function Nav({
|
||||
<a href={`${SITE_URL}blog`} className={deskLinkCls(false)} onMouseEnter={slideTo}>
|
||||
{S.nav.blog}
|
||||
</a>
|
||||
<a href={`${SITE_URL}download`} className={deskLinkCls(false)} onMouseEnter={slideTo}>
|
||||
{S.nav.download}
|
||||
</a>
|
||||
{/* "Docs" is this SPA's own root — a router Link, and always the current page. */}
|
||||
<Link to="/" className={deskLinkCls(true)} onMouseEnter={slideTo} aria-current="page">
|
||||
{S.nav.docs}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const en: Strings = {
|
||||
contract: "CONTRACT.md",
|
||||
features: "Features",
|
||||
blog: "Blog",
|
||||
download: "Download",
|
||||
docs: "Docs",
|
||||
github: "GitHub",
|
||||
openMenu: "Open navigation",
|
||||
|
||||
@@ -19,6 +19,7 @@ export const zh = {
|
||||
contract: "CONTRACT.md",
|
||||
features: "功能",
|
||||
blog: "博客",
|
||||
download: "下载",
|
||||
docs: "文档",
|
||||
github: "GitHub",
|
||||
openMenu: "打开目录",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { absoluteUrl, blogRoutes } from "./site-routes.mjs";
|
||||
import { absoluteUrl, blogRoutes, pageRoutes } from "./site-routes.mjs";
|
||||
|
||||
const dist = join(dirname(fileURLToPath(import.meta.url)), "..", "dist");
|
||||
const shell = readFileSync(join(dist, "index.html"), "utf8");
|
||||
@@ -43,7 +43,7 @@ function shellFor(route) {
|
||||
.replace(OG_URL, `<meta property="og:url" content="${url}" />`);
|
||||
}
|
||||
|
||||
const routes = blogRoutes();
|
||||
const routes = [...pageRoutes(), ...blogRoutes()];
|
||||
for (const { route } of routes) {
|
||||
const dir = join(dist, route);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -52,6 +52,4 @@ for (const { route } of routes) {
|
||||
|
||||
writeFileSync(join(dist, "404.html"), shell.replace(CANONICAL, ""));
|
||||
writeFileSync(join(dist, ".nojekyll"), "");
|
||||
console.log(
|
||||
`[postbuild] wrote ${routes.length} blog route shells, dist/404.html and dist/.nojekyll`,
|
||||
);
|
||||
console.log(`[postbuild] wrote ${routes.length} route shells, dist/404.html and dist/.nojekyll`);
|
||||
|
||||
@@ -59,6 +59,13 @@ function newestDate(dir, slug) {
|
||||
* that actually answers 200 — the one canonical tags and the sitemap should name.
|
||||
* The home page is excluded; Vite's own index.html already sits at the dist root.
|
||||
*/
|
||||
|
||||
/** Non-blog landing SPA pages (currently the desktop download page). */
|
||||
export function pageRoutes() {
|
||||
return [{ route: "/download/" }];
|
||||
}
|
||||
|
||||
/** Blog routes, one per post plus the list page. */
|
||||
export function blogRoutes() {
|
||||
return [
|
||||
{ route: "/blog/" },
|
||||
|
||||
@@ -37,6 +37,11 @@ export function Footer() {
|
||||
{S.footer.blog}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/download" className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{S.nav.download}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -108,6 +108,14 @@ export function Nav() {
|
||||
>
|
||||
{S.nav.blog}
|
||||
</Link>
|
||||
<Link
|
||||
to="/download"
|
||||
className={deskLinkCls(activeItem === "download")}
|
||||
onMouseEnter={slideTo}
|
||||
aria-current={activeItem === "download" ? "page" : undefined}
|
||||
>
|
||||
{S.nav.download}
|
||||
</Link>
|
||||
{/* Docs is a sibling SPA under /docs/ — a plain anchor, not a router Link. */}
|
||||
<a href={DOCS_URL} className={deskLinkCls(false)} onMouseEnter={slideTo}>
|
||||
{S.nav.docs}
|
||||
@@ -139,6 +147,14 @@ export function Nav() {
|
||||
>
|
||||
{S.nav.blog}
|
||||
</Link>
|
||||
<Link
|
||||
to="/download"
|
||||
className={mobileLinkCls(activeItem === "download")}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-current={activeItem === "download" ? "page" : undefined}
|
||||
>
|
||||
{S.nav.download}
|
||||
</Link>
|
||||
<a href={DOCS_URL} className={mobileLinkCls(false)} onClick={() => setOpen(false)}>
|
||||
{S.nav.docs}
|
||||
</a>
|
||||
|
||||
@@ -80,3 +80,40 @@ export const blogAssetUrl = (name: string): string => `${COMMUNITY_RAW}/blog-ass
|
||||
/** API key consoles (same URLs the in-app Models page links to). */
|
||||
export const DEEPSEEK_KEYS_URL = "https://platform.deepseek.com/api_keys";
|
||||
export const OPENROUTER_KEYS_URL = "https://openrouter.ai/workspaces/default/keys";
|
||||
|
||||
/**
|
||||
* Desktop app downloads. The installers carry version-less names (see
|
||||
* packages/desktop/electron-builder.yml), which is what makes static links possible on a
|
||||
* Pages-hosted site: GitHub's `releases/latest/download/<name>` always serves the newest
|
||||
* release, no client-side resolution needed. The OSS mirror stores immutable per-tag
|
||||
* directories instead, so mirror links DO need the current tag — the download page reads
|
||||
* it from the bucket's `latest.json` pointer (the same file install.sh resolves) and
|
||||
* falls back to the GitHub links when that fetch fails (e.g. CORS not configured, or the
|
||||
* mirror unreachable).
|
||||
*/
|
||||
export const OSS_ORIGIN = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com";
|
||||
export const OSS_LATEST_JSON_URL = `${OSS_ORIGIN}/latest.json`;
|
||||
export const GITHUB_LATEST_DOWNLOAD = `${REPO_URL}/releases/latest/download`;
|
||||
|
||||
export interface DesktopInstaller {
|
||||
/** Stable asset file name, identical on GitHub Releases and the OSS mirror. */
|
||||
file: string;
|
||||
/** Language-neutral variant tag rendered on the button (arch / package format). */
|
||||
variant: string;
|
||||
}
|
||||
|
||||
/** Installers per platform card, in display order. */
|
||||
export const DESKTOP_INSTALLERS: Record<"mac" | "windows" | "linux", DesktopInstaller[]> = {
|
||||
mac: [
|
||||
{ file: "penguin-desktop-darwin-arm64.dmg", variant: "Apple Silicon" },
|
||||
{ file: "penguin-desktop-darwin-x64.dmg", variant: "Intel" },
|
||||
],
|
||||
windows: [{ file: "penguin-desktop-win32-x64.exe", variant: "x64" }],
|
||||
linux: [
|
||||
{ file: "penguin-desktop-linux-x64.AppImage", variant: "AppImage" },
|
||||
{ file: "penguin-desktop-linux-x64.deb", variant: "deb" },
|
||||
],
|
||||
};
|
||||
|
||||
/** Checksum list covering every desktop installer of a release. */
|
||||
export const DESKTOP_SHA256SUMS = "SHA256SUMS.desktop";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const SECTION_IDS = [
|
||||
] as const;
|
||||
|
||||
export type SectionId = (typeof SECTION_IDS)[number];
|
||||
export type ActiveNavItem = SectionId | "blog" | null;
|
||||
export type ActiveNavItem = SectionId | "blog" | "download" | null;
|
||||
|
||||
function isSectionId(value: string): value is SectionId {
|
||||
return SECTION_IDS.some((id) => id === value);
|
||||
@@ -21,6 +21,7 @@ function isSectionId(value: string): value is SectionId {
|
||||
|
||||
export function getActiveNavItem(pathname: string, hash: string): ActiveNavItem {
|
||||
if (pathname === "/blog" || pathname.startsWith("/blog/")) return "blog";
|
||||
if (pathname === "/download") return "download";
|
||||
if (pathname !== "/") return null;
|
||||
|
||||
const id = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const en: Strings = {
|
||||
contract: "CONTRACT.md",
|
||||
features: "Features",
|
||||
blog: "Blog",
|
||||
download: "Download",
|
||||
docs: "Docs",
|
||||
github: "GitHub",
|
||||
openMenu: "Open menu",
|
||||
@@ -78,6 +79,36 @@ export const en: Strings = {
|
||||
windows: "After unzipping you can also just double-click install.cmd.",
|
||||
},
|
||||
offlineRelease: "Download offline packages from GitHub Releases",
|
||||
desktopNote:
|
||||
"Prefer a standalone app? The desktop app packages the Web interface and the server into a terminal-free installer (macOS / Windows / Linux), sharing the same local data as a CLI install.",
|
||||
desktopPage: "Go to the desktop download page",
|
||||
},
|
||||
|
||||
download: {
|
||||
eyebrow: "Download",
|
||||
title: "Download the desktop app",
|
||||
subtitle:
|
||||
"The full Web experience as a standalone application: it embeds the server and opens already signed in — no terminal, no login page; data lives in the same ~/.penguin/data root as a CLI install.",
|
||||
recommended: "Your system",
|
||||
platforms: {
|
||||
mac: { name: "macOS", require: "macOS 11 or later, dmg disk image (pick your chip)" },
|
||||
windows: { name: "Windows", require: "Windows 10 or later (x64), NSIS installer" },
|
||||
linux: {
|
||||
name: "Linux",
|
||||
require: "x64 — AppImage runs in place, deb goes to your package manager",
|
||||
},
|
||||
},
|
||||
statusOss: (version: string) =>
|
||||
`Connected to the OSS mirror (${version}) — downloads are served from the mirror.`,
|
||||
statusGithub: "Downloads point at the latest GitHub Release.",
|
||||
altGithub: "Download from GitHub instead",
|
||||
altOss: "Use the OSS mirror instead",
|
||||
checksums: "Checksums (SHA256SUMS.desktop)",
|
||||
allReleases: "All releases",
|
||||
unsignedNote:
|
||||
'Current builds are unsigned: on first launch use right-click → Open on macOS, and "More info → Run anyway" past Windows SmartScreen.',
|
||||
cliHint: "Just need the CLI, or the Web UI in a browser?",
|
||||
cliHintLink: "See the quick start",
|
||||
},
|
||||
|
||||
copy: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const zh = {
|
||||
contract: "CONTRACT.md",
|
||||
features: "功能",
|
||||
blog: "博客",
|
||||
download: "下载",
|
||||
docs: "文档",
|
||||
github: "GitHub",
|
||||
openMenu: "打开菜单",
|
||||
@@ -84,6 +85,32 @@ export const zh = {
|
||||
windows: "解压后也可直接双击 install.cmd 完成安装。",
|
||||
},
|
||||
offlineRelease: "前往 GitHub Releases 下载离线安装包",
|
||||
desktopNote:
|
||||
"更喜欢独立应用?桌面端把 Web 界面与服务端打包成免终端的安装程序(macOS / Windows / Linux),与 CLI 安装共用同一份本地数据。",
|
||||
desktopPage: "前往桌面端下载页",
|
||||
},
|
||||
|
||||
download: {
|
||||
eyebrow: "下载",
|
||||
title: "下载桌面端",
|
||||
subtitle:
|
||||
"完整的 Web 体验打包为独立应用:内嵌服务端,打开即已登录——无需终端、无登录页;数据与 CLI 安装共用同一个 ~/.penguin/data 目录。",
|
||||
recommended: "当前系统",
|
||||
platforms: {
|
||||
mac: { name: "macOS", require: "macOS 11 及以上,dmg 安装镜像(按芯片选择)" },
|
||||
windows: { name: "Windows", require: "Windows 10 及以上(x64),NSIS 安装程序" },
|
||||
linux: { name: "Linux", require: "x64,AppImage 免安装运行,或 deb 交给包管理器" },
|
||||
},
|
||||
statusOss: (version: string) => `已连接 OSS 国内镜像(${version}),点击即从镜像高速下载。`,
|
||||
statusGithub: "下载指向 GitHub Releases 的最新版本。",
|
||||
altGithub: "改从 GitHub 下载",
|
||||
altOss: "改用 OSS 镜像下载",
|
||||
checksums: "校验和(SHA256SUMS.desktop)",
|
||||
allReleases: "全部版本",
|
||||
unsignedNote:
|
||||
"当前构建暂未签名:macOS 首次启动请右键 →「打开」;Windows 在 SmartScreen 提示中选「更多信息 → 仍要运行」。",
|
||||
cliHint: "只需要命令行或浏览器里的 Web 界面?",
|
||||
cliHintLink: "参见快速开始",
|
||||
},
|
||||
|
||||
copy: {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Desktop download page. The buttons are static GitHub `releases/latest/download/<name>`
|
||||
* links by default — always valid because installer names are version-less — and swap to
|
||||
* the OSS mirror's immutable per-tag URLs once the bucket's `latest.json` pointer
|
||||
* resolves (validated the same way the installers validate it; any failure, e.g. CORS
|
||||
* not configured on the bucket or the mirror unreachable, silently keeps the GitHub
|
||||
* links). Plain-anchor downloads are never CORS-gated — only this version lookup is.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import {
|
||||
DESKTOP_INSTALLERS,
|
||||
DESKTOP_SHA256SUMS,
|
||||
GITHUB_LATEST_DOWNLOAD,
|
||||
OSS_LATEST_JSON_URL,
|
||||
OSS_ORIGIN,
|
||||
RELEASES_URL,
|
||||
} from "../lib/links";
|
||||
import { Section } from "../components/section";
|
||||
import { DownloadIcon, ExternalLinkIcon } from "../components/icons";
|
||||
|
||||
type Platform = "mac" | "windows" | "linux";
|
||||
const PLATFORMS: Platform[] = ["mac", "windows", "linux"];
|
||||
|
||||
/** Coarse OS detection, used only to badge one card; absent or wrong detection changes nothing else. */
|
||||
function detectPlatform(): Platform | null {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Windows/i.test(ua)) return "windows";
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) return "mac";
|
||||
if (/Linux|X11/.test(ua) && !/Android/i.test(ua)) return "linux";
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Mirror {
|
||||
tag: string;
|
||||
base: string;
|
||||
}
|
||||
|
||||
/** latest.json validated like the installer forwarders validate it: schema 1, safe v-tag, fixed bucket base. */
|
||||
function parseMirror(value: unknown): Mirror | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const manifest = value as { schemaVersion?: unknown; tag?: unknown; releaseBaseUrl?: unknown };
|
||||
if (manifest.schemaVersion !== 1 || typeof manifest.tag !== "string") return null;
|
||||
if (!/^v[0-9A-Za-z][0-9A-Za-z._-]*$/.test(manifest.tag)) return null;
|
||||
if (manifest.releaseBaseUrl !== `${OSS_ORIGIN}/releases/${manifest.tag}`) return null;
|
||||
return { tag: manifest.tag, base: manifest.releaseBaseUrl };
|
||||
}
|
||||
|
||||
export function DownloadPage() {
|
||||
const [mirror, setMirror] = useState<Mirror | null>(null);
|
||||
const [forceGithub, setForceGithub] = useState(false);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
fetch(OSS_LATEST_JSON_URL, { signal: controller.signal })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body: unknown) => {
|
||||
const parsed = parseMirror(body);
|
||||
if (parsed) setMirror(parsed);
|
||||
})
|
||||
.catch(() => {}) // The GitHub links already work; the mirror is a progressive upgrade.
|
||||
.finally(() => clearTimeout(timer));
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const detected = detectPlatform();
|
||||
const viaMirror = mirror !== null && !forceGithub;
|
||||
const hrefFor = (file: string) =>
|
||||
viaMirror ? `${mirror.base}/${file}` : `${GITHUB_LATEST_DOWNLOAD}/${file}`;
|
||||
|
||||
const textLink =
|
||||
"inline-flex items-center gap-1 text-brand-700 underline decoration-brand-300 underline-offset-2 transition-colors hover:text-brand-600 dark:text-brand-300 dark:decoration-brand-700";
|
||||
|
||||
return (
|
||||
<Section eyebrow={S.download.eyebrow} title={S.download.title} subtitle={S.download.subtitle}>
|
||||
<div className="mx-auto grid max-w-4xl gap-4 sm:grid-cols-3">
|
||||
{PLATFORMS.map((platform) => (
|
||||
<div
|
||||
key={platform}
|
||||
className={`flex flex-col rounded-xl border bg-white p-5 dark:bg-gray-900 ${
|
||||
detected === platform
|
||||
? "border-brand-500 ring-1 ring-brand-500"
|
||||
: "border-gray-200 dark:border-gray-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold tracking-tight">
|
||||
{S.download.platforms[platform].name}
|
||||
</h3>
|
||||
{detected === platform && (
|
||||
<span className="rounded-full bg-brand-600/10 px-2 py-0.5 text-xs font-medium text-brand-700 dark:text-brand-300">
|
||||
{S.download.recommended}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 mb-4 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{S.download.platforms[platform].require}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
{DESKTOP_INSTALLERS[platform].map(({ file, variant }) => (
|
||||
<a
|
||||
key={file}
|
||||
href={hrefFor(file)}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-md bg-gray-900 px-3.5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300"
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{variant}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-8 max-w-4xl text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
{viaMirror ? S.download.statusOss(mirror.tag) : S.download.statusGithub}{" "}
|
||||
{mirror !== null && (
|
||||
<button type="button" className={textLink} onClick={() => setForceGithub(!forceGithub)}>
|
||||
{forceGithub ? S.download.altOss : S.download.altGithub}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<a href={hrefFor(DESKTOP_SHA256SUMS)} className={textLink}>
|
||||
{S.download.checksums}
|
||||
</a>
|
||||
<span className="mx-2 text-gray-300 dark:text-gray-700">·</span>
|
||||
<a href={RELEASES_URL} target="_blank" rel="noreferrer" className={textLink}>
|
||||
{S.download.allReleases}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-6 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{S.download.unsignedNote}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{S.download.cliHint}{" "}
|
||||
<Link to="/#quickstart" className={textLink}>
|
||||
{S.download.cliHintLink}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { NeonBackground } from "./components/neon-bg";
|
||||
import { HomePage } from "./pages/home";
|
||||
import { BlogListPage } from "./pages/blog-list";
|
||||
import { BlogPostPage } from "./pages/blog-post";
|
||||
import { DownloadPage } from "./pages/download";
|
||||
|
||||
/**
|
||||
* Deep links into the docs SPA that missed a real file (e.g. an unknown slug) land on
|
||||
@@ -76,6 +77,7 @@ export function AppRouter() {
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="/blog" element={<BlogListPage />} />
|
||||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||
<Route path="/download" element={<DownloadPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
{/* Outside Layout: a pure redirect, no nav/footer flash. */}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* the string dictionaries.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { ReactNode } from "react";
|
||||
import { S } from "../lib/strings";
|
||||
import {
|
||||
@@ -199,6 +200,17 @@ export function Quickstart() {
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{/* Below both install methods: the desktop app replaces this whole flow for
|
||||
users who never want a terminal, so it stays visible on every switch. */}
|
||||
<p className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{S.install.desktopNote}{" "}
|
||||
<Link
|
||||
to="/download"
|
||||
className="inline-flex items-center gap-1 text-brand-700 underline decoration-brand-300 underline-offset-2 transition-colors hover:text-brand-600 dark:text-brand-300 dark:decoration-brand-700"
|
||||
>
|
||||
{S.install.desktopPage}
|
||||
</Link>
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
{mode === "web" ? (
|
||||
|
||||
@@ -14,7 +14,12 @@ import { execSync } from "node:child_process";
|
||||
import { cpSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { absoluteUrl, blogRoutes, docsRoutes } from "../packages/landing/scripts/site-routes.mjs";
|
||||
import {
|
||||
absoluteUrl,
|
||||
blogRoutes,
|
||||
docsRoutes,
|
||||
pageRoutes,
|
||||
} from "../packages/landing/scripts/site-routes.mjs";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const base = process.env.BASE_PATH ?? "/";
|
||||
@@ -40,7 +45,7 @@ cpSync(join(root, "packages", "docs", "dist"), docsTarget, { recursive: true });
|
||||
// that has to name routes from both sites. It is also the only way a crawler learns
|
||||
// those routes — both pages ship an empty <div id="root"> and build their navigation
|
||||
// in JS, so following links is not an option.
|
||||
const routes = [{ route: "/" }, ...blogRoutes(), ...docsRoutes()];
|
||||
const routes = [{ route: "/" }, ...pageRoutes(), ...blogRoutes(), ...docsRoutes()];
|
||||
const sitemap = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
|
||||
@@ -71,6 +71,17 @@ penguin-darwin-arm64.tar.gz
|
||||
penguin-universal.tar.gz
|
||||
penguin-win32-x64.zip
|
||||
"
|
||||
# Desktop installers carry version-less names (see packages/desktop/electron-builder.yml)
|
||||
# and are verified as a set through SHA256SUMS.desktop instead of per-file .sha256 twins.
|
||||
DESKTOP_INSTALLERS="
|
||||
penguin-desktop-darwin-arm64.dmg
|
||||
penguin-desktop-darwin-arm64.zip
|
||||
penguin-desktop-darwin-x64.dmg
|
||||
penguin-desktop-darwin-x64.zip
|
||||
penguin-desktop-linux-x64.AppImage
|
||||
penguin-desktop-linux-x64.deb
|
||||
penguin-desktop-win32-x64.exe
|
||||
"
|
||||
FILES="$BUNDLES
|
||||
penguin-linux-x64.tar.gz.sha256
|
||||
penguin-linux-arm64.tar.gz.sha256
|
||||
@@ -79,6 +90,8 @@ penguin-darwin-arm64.tar.gz.sha256
|
||||
penguin-universal.tar.gz.sha256
|
||||
penguin-win32-x64.zip.sha256
|
||||
SHA256SUMS
|
||||
$DESKTOP_INSTALLERS
|
||||
SHA256SUMS.desktop
|
||||
install.sh
|
||||
install.ps1
|
||||
"
|
||||
@@ -94,6 +107,7 @@ for bundle in $BUNDLES; do
|
||||
(cd "$RELEASE_DIR" && sha256sum -c "$bundle.sha256")
|
||||
done
|
||||
(cd "$RELEASE_DIR" && sha256sum -c SHA256SUMS)
|
||||
(cd "$RELEASE_DIR" && sha256sum -c SHA256SUMS.desktop)
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
Reference in New Issue
Block a user