docs(readme,landing): introduce the desktop app (#201)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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" ? (
|
||||
|
||||
Reference in New Issue
Block a user