mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Support per-arch desktop builds and prune runtime artifacts
This commit is contained in:
13
.github/workflows/desktop-release.yml
vendored
13
.github/workflows/desktop-release.yml
vendored
@@ -21,10 +21,17 @@ env:
|
||||
|
||||
jobs:
|
||||
publish-macos:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
rust_target: aarch64-apple-darwin
|
||||
- runner: macos-13
|
||||
rust_target: x86_64-apple-darwin
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: macos-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -43,7 +50,7 @@ jobs:
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
@@ -111,7 +118,7 @@ jobs:
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --target universal-apple-darwin
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
publish-linux:
|
||||
permissions:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Image, Pressable, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { ArrowUpRight } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
formatVersionWithPrefix,
|
||||
isVersionMismatch,
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
restartManagedDaemon,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
uninstallManagedCliShim,
|
||||
updateManagedDaemonTcpSettings,
|
||||
type ManagedDaemonLogs,
|
||||
type ManagedPairingOffer,
|
||||
type ManagedDaemonStatus,
|
||||
@@ -36,11 +37,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||
const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
|
||||
const [isTcpModalOpen, setIsTcpModalOpen] = useState(false);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
|
||||
@@ -50,8 +49,6 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
null
|
||||
);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const [tcpHostInput, setTcpHostInput] = useState(DEFAULT_TCP_HOST);
|
||||
const [tcpPortInput, setTcpPortInput] = useState(String(DEFAULT_TCP_PORT));
|
||||
|
||||
const loadManagedStatus = useCallback(() => {
|
||||
if (!showSection) {
|
||||
@@ -62,9 +59,6 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
setManagedStatus(status);
|
||||
setManagedLogs(logs);
|
||||
setStatusError(null);
|
||||
const [tcpHost, tcpPort] = splitTcpListen(status.tcpListen);
|
||||
setTcpHostInput(tcpHost);
|
||||
setTcpPortInput(tcpPort);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -85,12 +79,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
|
||||
const daemonVersionHint =
|
||||
statusError ??
|
||||
(managedStatus?.daemonRunning
|
||||
? managedStatus.transportType === "tcp"
|
||||
? `Running on ${managedStatus.transportPath}.`
|
||||
: "Running."
|
||||
: "Not running.");
|
||||
statusError ?? (managedStatus?.daemonRunning ? "Running." : "Not running.");
|
||||
|
||||
const handleUpdateLocalDaemon = useCallback(() => {
|
||||
if (!showSection) {
|
||||
@@ -246,101 +235,23 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
});
|
||||
}, [pairingOffer?.url]);
|
||||
|
||||
const handleOpenTcpModal = useCallback(() => {
|
||||
if (!managedStatus) {
|
||||
return;
|
||||
}
|
||||
const [tcpHost, tcpPort] = splitTcpListen(managedStatus.tcpListen);
|
||||
setTcpHostInput(tcpHost);
|
||||
setTcpPortInput(tcpPort);
|
||||
setIsTcpModalOpen(true);
|
||||
}, [managedStatus]);
|
||||
|
||||
const handleDisableTcp = useCallback(() => {
|
||||
if (isSavingTcpSettings) {
|
||||
return;
|
||||
}
|
||||
setIsSavingTcpSettings(true);
|
||||
setStatusMessage(null);
|
||||
void updateManagedDaemonTcpSettings({
|
||||
enabled: false,
|
||||
host: tcpHostInput.trim() || DEFAULT_TCP_HOST,
|
||||
port: parseTcpPort(tcpPortInput) ?? DEFAULT_TCP_PORT,
|
||||
})
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setStatusMessage("Network access disabled.");
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusMessage(`Failed to update: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingTcpSettings(false);
|
||||
});
|
||||
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
|
||||
|
||||
const handleSaveTcp = useCallback(() => {
|
||||
if (isSavingTcpSettings) {
|
||||
return;
|
||||
}
|
||||
const host = tcpHostInput.trim();
|
||||
const port = parseTcpPort(tcpPortInput);
|
||||
if (!host) {
|
||||
Alert.alert("Host required", "Enter a TCP bind host.");
|
||||
return;
|
||||
}
|
||||
if (port == null || port <= 0) {
|
||||
Alert.alert("Port required", "Enter a valid TCP port.");
|
||||
return;
|
||||
}
|
||||
if (port === 6767) {
|
||||
Alert.alert("Port unavailable", "Port 6767 is reserved. Choose a different port.");
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Enable network access",
|
||||
message:
|
||||
"This makes the daemon reachable on your local network. Relay connections will continue to work.",
|
||||
confirmLabel: "Enable",
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setIsSavingTcpSettings(true);
|
||||
setStatusMessage(null);
|
||||
void updateManagedDaemonTcpSettings({ enabled: true, host, port })
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setIsTcpModalOpen(false);
|
||||
setStatusMessage(`Network access enabled on ${host}:${port}.`);
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusMessage(`Failed to update: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingTcpSettings(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open TCP confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the confirmation dialog.");
|
||||
});
|
||||
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Built-in daemon</Text>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Built-in daemon</Text>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
style={styles.sectionLink}
|
||||
>
|
||||
<Text style={styles.sectionLinkText}>Advanced settings</Text>
|
||||
<ArrowUpRight size={14} color={styles.sectionLinkText.color} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
@@ -352,9 +263,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Restart daemon</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Restarts the built-in daemon. Your data and external connections are not affected.
|
||||
</Text>
|
||||
<Text style={styles.hintText}>Restarts the built-in daemon.</Text>
|
||||
{statusMessage ? (
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
) : null}
|
||||
@@ -425,39 +334,6 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Network access</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Allow other apps or devices on your network to connect directly.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
{managedStatus?.tcpEnabled
|
||||
? `Enabled on ${managedStatus.tcpListen ?? managedStatus.transportPath}`
|
||||
: "Disabled"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{managedStatus?.tcpEnabled ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={handleDisableTcp}
|
||||
disabled={isSavingTcpSettings}
|
||||
>
|
||||
{isSavingTcpSettings ? "Working..." : "Disable TCP"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={handleOpenTcpModal}
|
||||
disabled={isSavingTcpSettings}
|
||||
>
|
||||
{managedStatus?.tcpEnabled ? "Edit TCP" : "Enable TCP"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
@@ -469,44 +345,6 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isTcpModalOpen}
|
||||
onClose={() => setIsTcpModalOpen(false)}
|
||||
title="Network access"
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
Expose the daemon on a specific address and port so other apps can connect directly.
|
||||
Port 6767 is reserved and cannot be used here.
|
||||
</Text>
|
||||
<AdaptiveTextInput
|
||||
value={tcpHostInput}
|
||||
onChangeText={setTcpHostInput}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="127.0.0.1"
|
||||
style={styles.input}
|
||||
/>
|
||||
<AdaptiveTextInput
|
||||
value={tcpPortInput}
|
||||
onChangeText={setTcpPortInput}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="number-pad"
|
||||
placeholder={String(DEFAULT_TCP_PORT)}
|
||||
style={styles.input}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="secondary" size="sm" onPress={() => setIsTcpModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleSaveTcp} disabled={isSavingTcpSettings}>
|
||||
{isSavingTcpSettings ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliInstallModalOpen}
|
||||
onClose={() => setIsCliInstallModalOpen(false)}
|
||||
@@ -571,21 +409,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_TCP_HOST = "127.0.0.1";
|
||||
const DEFAULT_TCP_PORT = 7771;
|
||||
|
||||
function splitTcpListen(value: string | null): [string, string] {
|
||||
if (!value) {
|
||||
return [DEFAULT_TCP_HOST, String(DEFAULT_TCP_PORT)];
|
||||
}
|
||||
const [host, port] = value.split(":");
|
||||
return [host || DEFAULT_TCP_HOST, port || String(DEFAULT_TCP_PORT)];
|
||||
}
|
||||
|
||||
function parseTcpPort(value: string): number | null {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
||||
|
||||
function PairingOfferDialogContent(input: {
|
||||
isLoading: boolean;
|
||||
@@ -690,12 +514,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -807,15 +645,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
|
||||
@@ -20,6 +20,21 @@ const workspaces = [
|
||||
{ name: "@getpaseo/cli", root: path.join(repoRoot, "packages", "cli") },
|
||||
];
|
||||
|
||||
const ripgrepPlatformDirMap = {
|
||||
darwin: {
|
||||
arm64: "arm64-darwin",
|
||||
x64: "x64-darwin",
|
||||
},
|
||||
linux: {
|
||||
arm64: "arm64-linux",
|
||||
x64: "x64-linux",
|
||||
},
|
||||
win32: {
|
||||
arm64: "arm64-win32",
|
||||
x64: "x64-win32",
|
||||
},
|
||||
};
|
||||
|
||||
async function rmSafe(target) {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
@@ -50,6 +65,12 @@ async function pathExists(target) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeIfExists(target) {
|
||||
if (await pathExists(target)) {
|
||||
await rmSafe(target);
|
||||
}
|
||||
}
|
||||
|
||||
async function readToolVersions() {
|
||||
const raw = await fs.readFile(path.join(repoRoot, ".tool-versions"), "utf8");
|
||||
const entries = new Map();
|
||||
@@ -284,6 +305,92 @@ async function writeRuntimePackageJson(runtimeRoot) {
|
||||
);
|
||||
}
|
||||
|
||||
async function pruneChildrenExcept(parent, keepNames) {
|
||||
if (!(await pathExists(parent))) {
|
||||
return;
|
||||
}
|
||||
const entries = await fs.readdir(parent);
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !keepNames.has(entry))
|
||||
.map((entry) => rmSafe(path.join(parent, entry)))
|
||||
);
|
||||
}
|
||||
|
||||
async function pruneNodeDistribution(runtimeRoot) {
|
||||
const nodeRoot = path.join(runtimeRoot, "node");
|
||||
await Promise.all([
|
||||
removeIfExists(path.join(nodeRoot, "include")),
|
||||
removeIfExists(path.join(nodeRoot, "share")),
|
||||
removeIfExists(path.join(nodeRoot, "CHANGELOG.md")),
|
||||
]);
|
||||
|
||||
const nodeGlobalModulesRoot = path.join(nodeRoot, "lib", "node_modules");
|
||||
if (await pathExists(nodeGlobalModulesRoot)) {
|
||||
const keepNames = new Set(["npm"]);
|
||||
await pruneChildrenExcept(nodeGlobalModulesRoot, keepNames);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneOnnxRuntime(runtimeRoot) {
|
||||
const onnxRoot = path.join(runtimeRoot, "node_modules", "onnxruntime-node", "bin", "napi-v6");
|
||||
if (!(await pathExists(onnxRoot))) {
|
||||
return;
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
await removeIfExists(path.join(onnxRoot, "linux"));
|
||||
await removeIfExists(path.join(onnxRoot, "win32"));
|
||||
await pruneChildrenExcept(path.join(onnxRoot, "darwin"), new Set([process.arch]));
|
||||
return;
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
await removeIfExists(path.join(onnxRoot, "darwin"));
|
||||
await removeIfExists(path.join(onnxRoot, "win32"));
|
||||
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
await removeIfExists(path.join(onnxRoot, "darwin"));
|
||||
await removeIfExists(path.join(onnxRoot, "linux"));
|
||||
await pruneChildrenExcept(path.join(onnxRoot, "win32"), new Set([process.arch]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneNodePty(runtimeRoot) {
|
||||
const prebuildsRoot = path.join(runtimeRoot, "node_modules", "node-pty", "prebuilds");
|
||||
const keepName = `${process.platform}-${process.arch}`;
|
||||
await pruneChildrenExcept(prebuildsRoot, new Set([keepName]));
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
await removeIfExists(path.join(runtimeRoot, "node_modules", "node-pty", "third_party"));
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneClaudeAgentSdk(runtimeRoot) {
|
||||
const ripgrepRoot = path.join(
|
||||
runtimeRoot,
|
||||
"node_modules",
|
||||
"@anthropic-ai",
|
||||
"claude-agent-sdk",
|
||||
"vendor",
|
||||
"ripgrep"
|
||||
);
|
||||
const keepName = ripgrepPlatformDirMap[process.platform]?.[process.arch];
|
||||
if (keepName) {
|
||||
await pruneChildrenExcept(ripgrepRoot, new Set(["COPYING", keepName]));
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneManagedRuntime(runtimeRoot) {
|
||||
await Promise.all([
|
||||
pruneNodeDistribution(runtimeRoot),
|
||||
pruneOnnxRuntime(runtimeRoot),
|
||||
pruneNodePty(runtimeRoot),
|
||||
pruneClaudeAgentSdk(runtimeRoot),
|
||||
]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureWorkspaceBuilds();
|
||||
|
||||
@@ -322,6 +429,7 @@ async function main() {
|
||||
path.join(runtimeRoot, "node"),
|
||||
tarballs.map((entry) => entry.path)
|
||||
);
|
||||
await pruneManagedRuntime(runtimeRoot);
|
||||
|
||||
const nodeRelativePath = process.platform === "win32"
|
||||
? path.join("node", "node.exe")
|
||||
|
||||
@@ -5,7 +5,7 @@ import websitePackage from '../../package.json'
|
||||
import '~/styles.css'
|
||||
|
||||
const desktopVersion = websitePackage.version
|
||||
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_universal.dmg`
|
||||
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_aarch64.dmg`
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
head: () => ({
|
||||
|
||||
Reference in New Issue
Block a user