chore: finalize electron desktop migration

This commit is contained in:
Mohamed Boudra
2026-03-21 01:50:00 +07:00
parent 77fb74c188
commit 111576e2ca
169 changed files with 6463 additions and 13191 deletions

View File

@@ -3,21 +3,21 @@ name: Desktop Release
on:
push:
tags:
- "v*"
- "desktop-v*"
- "desktop-macos-v*"
- "desktop-linux-v*"
- "desktop-windows-v*"
- 'v*'
- 'desktop-v*'
- 'desktop-macos-v*'
- 'desktop-linux-v*'
- 'desktop-windows-v*'
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build (e.g. v0.1.0)"
description: 'Existing tag to build (e.g. v0.1.0)'
required: true
type: string
platform:
description: "Optional desktop platform to build."
description: 'Optional desktop platform to build.'
required: false
default: "all"
default: 'all'
type: choice
options:
- all
@@ -31,6 +31,8 @@ concurrency:
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
DESKTOP_WORKSPACE: '@getpaseo/desktop-electron'
DESKTOP_PACKAGE_PATH: 'packages/desktop-electron'
jobs:
publish-macos:
@@ -40,9 +42,9 @@ jobs:
matrix:
include:
- runner: macos-14
rust_target: aarch64-apple-darwin
electron_arch: arm64
- runner: macos-15-intel
rust_target: x86_64-apple-darwin
electron_arch: x64
permissions:
contents: write
packages: read
@@ -75,85 +77,39 @@ jobs:
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-macos-${{ matrix.rust_target }}
workspaces: |
.
packages/desktop/src-tauri -> target
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Import Apple code-signing certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Sign bundled managed runtime
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
@@ -162,76 +118,36 @@ jobs:
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_draft="false"
release_type="release"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build and publish macOS Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: tauri_build
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --target ${{ matrix.rust_target }}
- name: Notarize and re-upload DMG
if: env.IS_SMOKE_TAG != 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_NAME: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
if [ -z "$dmg_path" ]; then
echo "::error::No DMG found in tauri build artifacts"
exit 1
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
echo "DMG: $dmg_path"
echo "Signing DMG..."
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
echo "Submitting DMG for notarization..."
xcrun notarytool submit "$dmg_path" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
echo "Stapling notarization ticket..."
xcrun stapler staple "$dmg_path"
echo "Verifying..."
spctl --assess --type install --verbose "$dmg_path"
echo "Replacing release asset with notarized DMG..."
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
- name: Build macOS app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
@@ -267,89 +183,38 @@ jobs:
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-linux
workspaces: |
.
packages/desktop/src-tauri -> target
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Strip CUDA dependencies from onnxruntime
- name: Set desktop package version from tag
shell: bash
run: |
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
echo "Removing needed $lib from $f"
patchelf --remove-needed "$lib" "$f"
done
done
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
@@ -359,141 +224,30 @@ jobs:
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_draft="false"
release_type="release"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build Linux Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: linux_tauri
continue-on-error: true
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NO_STRIP: "1"
APPIMAGE_EXTRACT_AND_RUN: "1"
- name: Build desktop release
shell: bash
run: |
set -euo pipefail
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
- name: Attempt manual Linux AppImage fallback
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
set -euxo pipefail
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
appdir_path="$appimage_dir/Paseo.AppDir"
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
if [ -n "$existing_appimage" ]; then
if [ "$existing_appimage" != "$canonical_appimage" ]; then
mv "$existing_appimage" "$canonical_appimage"
fi
if [ ! -f "$canonical_appimage.sig" ]; then
npx tauri signer sign "$canonical_appimage"
fi
exit 0
fi
if [ ! -d "$appdir_path" ]; then
echo "::error::AppDir was not generated at $appdir_path"
exit 1
fi
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
tools_dir="$(mktemp -d)"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
npx tauri signer sign "$canonical_appimage"
- name: Fail Linux release when AppImage bundling fails
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::Linux AppImage assets are still missing after the manual fallback."
exit 1
fi
- name: Patch AppImage for Wayland compatibility
if: env.IS_SMOKE_TAG != 'true'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPIMAGE_EXTRACT_AND_RUN: "1"
shell: bash
run: |
set -euo pipefail
appimage_dir="$GITHUB_WORKSPACE/packages/desktop/src-tauri/target/release/bundle/appimage"
appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
if [ -z "$appimage" ]; then
echo "::error::No AppImage found to patch"
exit 1
fi
echo "Patching $appimage for Wayland compatibility..."
# Extract the AppImage
workdir="$(mktemp -d)"
cd "$workdir"
"$appimage" --appimage-extract
# Remove GDK_BACKEND=x11 from the GTK plugin hook.
# The bundled libgdk-3.so has Wayland support built in; forcing x11
# prevents the app from starting on Wayland-only systems.
gtk_hook="squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh"
if [ -f "$gtk_hook" ]; then
sed -i 's/^export GDK_BACKEND=x11/#export GDK_BACKEND=x11/' "$gtk_hook"
echo "Patched $gtk_hook — removed GDK_BACKEND=x11"
fi
# Repackage
tools_dir="$(mktemp -d)"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage \
-o "$tools_dir/appimagetool"
chmod +x "$tools_dir/appimagetool"
ARCH=x86_64 "$tools_dir/appimagetool" squashfs-root "$appimage"
# Re-sign
rm -f "$appimage.sig"
cd "$GITHUB_WORKSPACE"
npx tauri signer sign "$appimage"
echo "Repackaged and re-signed $appimage"
- name: Upload Linux release assets
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No Linux AppImage assets were produced."
exit 1
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
- name: Build Linux app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
@@ -529,76 +283,43 @@ jobs:
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-windows
workspaces: |
.
packages/desktop/src-tauri -> target
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
@@ -607,31 +328,27 @@ jobs:
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_draft="false"
release_type="release"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build and publish Windows Tauri release
if: env.IS_SMOKE_TAG != 'true'
uses: tauri-apps/tauri-action@v0
- name: Build desktop release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --bundles nsis
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
- name: Build Windows app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"

3
.gitignore vendored
View File

@@ -46,6 +46,9 @@ test-results/
# Vercel
.vercel/
# Expo
.expo/
# Misc
*.pem
.vercel

View File

@@ -166,7 +166,7 @@
- Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
- Hid empty filter groups in the left sidebar.
### Fixed
@@ -188,7 +188,7 @@
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
- Improved desktop command autocomplete behavior to match combobox interactions.
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
- Improved desktop settings and permissions UX in Tauri.
- Improved desktop settings and permissions UX on desktop.
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed
@@ -227,7 +227,7 @@
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16

View File

@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
- `packages/app` — Mobile + web client (Expo)
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
- `packages/relay` — E2E encrypted relay for remote access
- `packages/desktop`Tauri desktop wrapper
- `packages/desktop`Electron desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Documentation

View File

@@ -44,7 +44,7 @@ Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
- `packages/desktop`: Tauri desktop app
- `packages/desktop`: Electron desktop app
- `packages/relay`: Relay package for remote connectivity
- `packages/website`: Marketing site and documentation (`paseo.sh`)

View File

@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Tauri)
│ (Expo) │ │ (Commander) │ │ (Electron)
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ Managed subprocess
@@ -90,9 +90,9 @@ Enables remote access when the daemon is behind a firewall.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### `packages/desktop` — Desktop app (Tauri)
### `packages/desktop` — Desktop app (Electron)
Tauri wrapper for macOS, Linux, and Windows.
Electron wrapper for macOS, Linux, and Windows.
- Can spawn the daemon as a managed subprocess
- Native file access for workspace integration
@@ -180,5 +180,5 @@ $PASEO_HOME/
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Tauri app spawns daemon as subprocess
2. **Managed desktop**: Electron app spawns daemon as subprocess
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption

View File

@@ -1,192 +0,0 @@
# Electron Migration
## Why switch
Paseo Desktop is a webview that loads an Expo web build plus a Node daemon that manages AI coding agents. Tauri is designed for apps that need a thin native wrapper — it assumes your backend logic lives in Rust. Paseo's backend is entirely Node/TypeScript. The result is that we're reimplementing Electron's built-in Node bundling inside Tauri's resource system.
### Pain points with Tauri today
**Managed runtime bundling is the #1 source of complexity.** `build-managed-runtime.mjs` (590 lines) downloads a Node distribution, packs three workspace tarballs, installs them with the bundled npm, separately installs sherpa-onnx platform packages, prunes ONNX/node-pty/ripgrep binaries per platform, and writes a runtime manifest. This exists solely because Tauri doesn't ship Node.
**Linux is fragile.** We wrote a custom AppImage Wayland patch (`maybePatchLinuxAddonRunpath` in sherpa-onnx-node-loader, plus AppImage patching scripts) because Tauri's Linux webview has Wayland issues. Electron handles Wayland/X11 transparently via Chromium's `--ozone-platform-hint=auto`.
**Native notification callbacks required Objective-C.** We wrote `paseo_notifications.m` (UNUserNotificationCenter delegate bridge) and compiled it into the Tauri build just to get notification click callbacks on macOS. Linux and Windows notification callbacks are unimplemented. Electron's `Notification` API provides click/reply/action callbacks cross-platform with zero native code.
**Windows is undertested.** The Rust code has `#[cfg(windows)]` blocks for named pipes, PowerShell `Expand-Archive`, `CREATE_NO_WINDOW` flags, NSIS installer hooks — all written speculatively with limited testing. Electron's cross-platform abstractions cover all of this.
**IPC is boilerplate-heavy.** Every desktop command requires a Rust `#[tauri::command]` function, a `spawn_blocking` wrapper, serde types, registration in `generate_handler![]`, and a TypeScript `invokeDesktopCommand()` call. There are 22 commands today. In Electron, the main process is Node — these become direct function calls or simple `ipcMain.handle()` handlers in the same language as the rest of the codebase.
**Tauri plugin gaps.** Features like tray menus with dynamic updates, global shortcuts, deep links, system idle detection, auto-launch on login, and rich clipboard access all require Rust plugins (often per-platform). Electron provides these as JS API calls.
### What we gain
- **Delete `build-managed-runtime.mjs`** — Node comes with Electron
- **Delete the Wayland/AppImage patching** — Chromium handles it
- **Delete `paseo_notifications.m`** — `Notification` API works cross-platform
- **Delete `runtime_manager.rs`** (1080 lines) — daemon becomes a `child_process.fork()` call
- **Delete all Rust code** — `lib.rs` (683 lines), `desktop_notifications.rs`, `build.rs`, Cargo.toml dependencies
- **Delete sherpa rpath patching** — `electron-rebuild` handles native addon ABI matching
- **One language** for the entire desktop app (TypeScript)
- **Windows support** works out of the box
- **Mature auto-update** via electron-updater (GitHub Releases, S3, etc.)
- **Mature code signing** for macOS and Windows without custom scripts
### What we lose
- **App size increases** from ~20 MB to ~150-200 MB (ships Chromium)
- **Memory footprint increases** (Chromium process model)
- **Tauri's Rust escape hatch** — though we only use Rust because Tauri requires it, not because we need it
### ONNX / Sherpa native addons
`sherpa-onnx-node` and `onnxruntime-node` ship prebuilt `.node` addons per platform. In Electron, `@electron/rebuild` rebuilds or verifies native addons against Electron's Node ABI. This is standard practice — packages like `better-sqlite3`, `sharp`, and `node-pty` all work this way. The manual sherpa platform package installation and rpath patching goes away entirely.
## What can be collapsed
### Build infrastructure (delete entirely)
| File | Lines | Purpose |
|---|---|---|
| `packages/desktop/scripts/build-managed-runtime.mjs` | 590 | Bundle Node + workspaces into Tauri sidecar |
| `packages/desktop/scripts/validate-managed-runtime.mjs` | ~100 | Validate bundled runtime integrity |
| `packages/desktop/scripts/sign-managed-runtime-macos.mjs` | ~100 | Code-sign macOS sidecar bundles |
| `packages/desktop/scripts/managed-daemon-smoke.mjs` | ~50 | Smoke test for managed daemon |
Electron replacement: None. Node is part of Electron. Workspace code loads directly via `require`/`import`.
### Rust backend (delete entirely)
| File | Lines | Purpose |
|---|---|---|
| `src-tauri/src/lib.rs` | 683 | App setup, IPC commands, menu, zoom |
| `src-tauri/src/runtime_manager.rs` | 1080 | Managed runtime/daemon lifecycle |
| `src-tauri/src/desktop_notifications.rs` | ~80 | Notification bridge |
| `src-tauri/src/main.rs` | ~10 | Entry point |
| `src-tauri/build.rs` | ~30 | Build script (compiles ObjC) |
| `src-tauri/Cargo.toml` | ~30 | Rust dependencies |
| `src-tauri/tauri.conf.json` | ~100 | Tauri configuration |
Electron replacement: A single `main.ts` (~200-300 lines) covering window creation, menu, IPC handlers, and daemon lifecycle.
### Native platform code (delete entirely)
| File | Purpose |
|---|---|
| `src-tauri/macos/paseo_notifications.h` | ObjC notification bridge header |
| `src-tauri/macos/paseo_notifications.m` | ObjC UNUserNotificationCenter delegate |
| `src-tauri/Info.plist` | macOS bundle config |
| `src-tauri/Entitlements.plist` | macOS entitlements |
| `src-tauri/installer-hooks.nsh` | Windows NSIS post-install hooks |
Electron replacement: `Notification` API (2-3 lines per notification). macOS entitlements handled by `electron-builder` config.
### Daemon management (massive simplification)
**Tauri today:** Bundled Node binary → spawn as child process → pipe stdout/stderr → poll `daemon status --json` in a loop → parse JSON → return via Tauri IPC.
**Electron:** `child_process.fork('./packages/server/dist/scripts/daemon-runner.js')` — same Node runtime, same ABI, direct IPC via Node's built-in channel.
### Local daemon transport (simplification)
**Tauri today:** `runtime_manager.rs` implements a full WebSocket client in Rust (tokio-tungstenite) with session management, base64 binary encoding over Tauri events, and manual read/write task spawning. ~300 lines of Rust.
**Electron:** The renderer can connect to Unix sockets / named pipes directly via the main process using Node's `ws` package (already a dependency). Or use `ipcMain` to proxy — still TypeScript, still ~50 lines.
### Attachment file management (simplification)
**Tauri today:** 6 Rust commands (`write_attachment_base64`, `copy_attachment_file`, `read_file_base64`, `delete_attachment_file`, `garbage_collect_attachment_files`) with path validation, base64 encoding/decoding, and directory traversal protection. ~200 lines of Rust.
**Electron:** Same logic in TypeScript using `fs` and `app.getPath('userData')`. ~50 lines.
### Window management (simplification)
**Tauri today:** Custom `tauri-window.ts` with `plugin:window|start_dragging`, `plugin:window|toggle_maximize`, `plugin:window|is_fullscreen` invocations.
**Electron:** `BrowserWindow` API — `win.maximize()`, `win.isFullScreen()`, etc. Frameless window dragging via CSS `-webkit-app-region: drag`.
### App updates (simplification)
**Tauri today:** Rust `check_app_update` / `install_app_update` commands using `tauri_plugin_updater`. Custom update check UI in the renderer.
**Electron:** `electron-updater` with `autoUpdater.checkForUpdatesAndNotify()`. Built-in download progress, restart handling, differential updates.
## Migration plan
### Guiding principle
Electron is mature. We are not doing anything new. Every feature we need has official documentation and established patterns. Never use workarounds — if something doesn't work, find the official way.
### Phase 1: Scaffold in parallel
Create `packages/desktop-electron` alongside `packages/desktop` (Tauri). Both coexist.
- Initialize with `electron-forge` or `electron-builder`
- Configure `BrowserWindow` to load the Expo web build (same as Tauri's webview)
- Set up `electron-builder` for macOS, Linux, Windows packaging
- Get a window rendering the Expo web app with no desktop features
### Phase 2: Introduce `isElectron` branching
Add platform detection infrastructure in `packages/app`:
- Add `isElectron()` / `isElectronMac()` alongside existing `isTauri()` / `isTauriMac()`
- Introduce a `isDesktop()` helper that returns `isTauri() || isElectron()`
- Create `packages/app/src/desktop/electron/invoke-desktop-command.ts` — Electron's equivalent of the Tauri invoke bridge, using `ipcRenderer.invoke()`
- Update `invokeDesktopCommand()` to route to the correct backend based on platform
### Phase 3: Port features incrementally
Work through each Tauri feature, adding the Electron variant. Never break Tauri while doing this.
**Tier 1 — Core (must work for the app to be usable):**
1. **Daemon lifecycle**`child_process.fork()` the daemon runner from the main process. Expose `start/stop/restart/status` via `ipcMain.handle()`.
2. **Local daemon transport** — WebSocket client in the main process using `ws`, proxied to renderer via IPC.
3. **Window management** — Frameless window with overlay title bar, drag regions, zoom controls, fullscreen.
4. **Menu**`Menu.buildFromTemplate()` with zoom controls and macOS app menu.
**Tier 2 — Desktop features:**
5. **Notifications**`new Notification()` with click handlers.
6. **App updates**`electron-updater` with GitHub Releases.
7. **File dialogs**`dialog.showOpenDialog()` for directory picker.
8. **Confirm dialogs**`dialog.showMessageBox()`.
9. **Open external URLs**`shell.openExternal()`.
10. **Attachment file management**`fs` operations in main process via IPC.
**Tier 3 — Polish:**
11. **CLI symlink instructions** — Generate platform-specific instructions in Node.
12. **Logging**`electron-log` or simple file-based logging.
13. **Single instance**`app.requestSingleInstanceLock()`.
14. **Console forwarding** — No bridge needed, main process is already Node.
15. **NSIS installer hooks**`electron-builder` NSIS config for CLI symlink.
### Phase 4: Feature parity validation
- Test all features on macOS, Linux (X11 + Wayland), Windows
- Verify sherpa-onnx-node loads correctly via `@electron/rebuild`
- Verify daemon start/stop/restart lifecycle
- Verify auto-update flow
- Verify notification callbacks on all platforms
- Smoke test CLI passthrough mode
### Phase 5: Cut over
- Update CI to build Electron packages instead of Tauri
- Remove `packages/desktop` (Tauri)
- Remove all `isTauri()` branching, keep only `isElectron()` (rename to `isDesktop()`)
- Delete `src-tauri/` and all Rust code
- Delete `build-managed-runtime.mjs` and related scripts
- Delete sherpa rpath patching code
## Estimated code impact
| Category | Lines removed | Lines added |
|---|---|---|
| Rust backend | ~1,900 | 0 |
| ObjC native code | ~150 | 0 |
| Build scripts | ~850 | 0 |
| Tauri TypeScript bridge | ~300 | ~150 (Electron IPC) |
| Electron main process | 0 | ~300 |
| **Net** | **~3,200 removed** | **~450 added** |

View File

@@ -1,341 +0,0 @@
# Panel Interface Refactor Plan
**Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels.
## The Problem
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in:
- `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status)
- `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators
Every new panel type requires editing 3+ files. This must become a registry where panels self-register.
## Target Architecture
### 1. PanelRegistration Interface
```typescript
// panels/panel-registry.ts
interface PanelDescriptor {
label: string;
subtitle: string;
titleState: "ready" | "loading";
icon: React.ComponentType<{ size: number; color: string }>;
statusBucket: SidebarStateBucket | null;
}
interface PanelRegistration<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
kind: K;
component: React.ComponentType;
useDescriptor(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): PanelDescriptor;
confirmClose?(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): Promise<boolean>;
}
```
### 2. Panel Registry
```typescript
const panelRegistry = new Map<string, PanelRegistration>();
function registerPanel(registration: PanelRegistration): void {
panelRegistry.set(registration.kind, registration);
}
function getPanelRegistration(kind: string): PanelRegistration | undefined {
return panelRegistry.get(kind);
}
```
### 3. PaneContext
Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props.
```typescript
interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
retargetCurrentTab(target: WorkspaceTabTarget): void;
openFileInWorkspace(filePath: string): void;
}
```
### 4. WorkspaceTabTarget stays unchanged
```typescript
type WorkspaceTabTarget =
| { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
```
No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target.
## Panel Implementations
Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly.
### Agent Panel Example
```typescript
// panels/agent-panel.ts
function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const agent = useSessionStore(
(s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null,
);
const provider = agent?.provider ?? "codex";
const label = resolveAgentLabel(agent?.title);
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: agentIconForProvider(provider),
statusBucket: agent ? deriveAgentStatusBucket(agent) : null,
};
}
function AgentPanel() {
const { serverId, target, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentReadyScreen
serverId={serverId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
onOpenWorkspaceFile={openFileInWorkspace}
/>
);
}
export const agentPanelRegistration: PanelRegistration<"agent"> = {
kind: "agent",
component: AgentPanel,
useDescriptor: useAgentPanelDescriptor,
async confirmClose(target, context) {
const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId);
if (agent?.status === "running") {
return confirmDialog({ title: "Agent is still running. Close anyway?" });
}
return true;
},
};
```
### Terminal Panel Example
```typescript
function useTerminalPanelDescriptor(
target: { kind: "terminal"; terminalId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
// read terminal data from appropriate store
return {
label: "Terminal",
subtitle: "Terminal",
titleState: "ready",
icon: TerminalIcon,
statusBucket: null,
};
}
function TerminalPanel() {
const { serverId, workspaceId, target, openTab } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
return (
<TerminalPane
serverId={serverId}
cwd={workspaceId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (terminalId) {
openTab({ kind: "terminal", terminalId });
}
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
);
}
```
### Draft Panel Example
```typescript
function useDraftPanelDescriptor(
_target: { kind: "draft"; draftId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
return {
label: "New Agent",
subtitle: "New Agent",
titleState: "ready",
icon: PencilIcon,
statusBucket: null,
};
}
function DraftPanel() {
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
<WorkspaceDraftAgentTab
serverId={serverId}
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
onOpenWorkspaceFile={openFileInWorkspace}
onCreated={(agentSnapshot) => {
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
}}
/>
);
}
```
### File Panel Example
```typescript
function useFilePanelDescriptor(
target: { kind: "file"; path: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
return {
label: fileName,
subtitle: target.path,
titleState: "ready",
icon: FileTextIcon,
statusBucket: null,
};
}
function FilePanel() {
const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "file", "FilePanel requires file target");
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceId}
filePath={target.path}
/>
);
}
```
## How the Tab Bar Uses It
Each tab chip calls the panel's `useDescriptor` hook:
```typescript
function TabChip({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
invariant(registration, `No panel registration for kind: ${target.kind}`);
const descriptor = registration.useDescriptor(target, { serverId, workspaceId });
return (
<TabChipChrome
tabId={tabId}
label={descriptor.label}
subtitle={descriptor.subtitle}
titleState={descriptor.titleState}
icon={<descriptor.icon size={16} color={theme.colors.foregroundMuted} />}
statusBucket={descriptor.statusBucket}
/>
);
}
```
## How the Workspace Screen Renders Content
Replaces the entire `renderContent()` switch:
```typescript
function PaneContent({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
if (!registration) return null;
const Component = registration.component;
return (
<PaneProvider value={{ serverId, workspaceId, tabId, target, ...actions }}>
<Component />
</PaneProvider>
);
}
```
## Implementation Steps
### Step 1: Create panel registry infrastructure
Create the following new files:
- `packages/app/src/panels/panel-registry.ts``PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()`
- `packages/app/src/panels/pane-context.ts``PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook
### Step 2: Create panel registration files
Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules:
- `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose`
- `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor`
- `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor`
- `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor`
- `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each
### Step 3: Refactor workspace-tab-model.ts
Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`.
Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution.
### Step 4: Refactor workspace-screen.tsx renderContent()
Replace the `renderContent()` switch with `<PaneContent>` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen.
### Step 5: Refactor workspace-tab-presentation.tsx
Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor.
### Step 6: Verify
- `npm run typecheck` must pass
- All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus
- Mobile tab switcher must work unchanged
- No visual regressions in tab bar, icons, status indicators
## Constraints
- **Pure refactor** — zero user-visible behavior changes
- **No new features** — no splits, no new panel types, no new keyboard shortcuts
- **WorkspaceTabTarget stays unchanged** — no store migration
- **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor
- **Do not create index.ts barrel files** — project convention
- **Use `invariant` from `tiny-invariant`** for asserting panel target kinds
- **Use `function` declarations** — project convention (no arrow function components)
- **Use `interface` over `type` where possible** — project convention
- **Run `npm run typecheck` after every change** — project rule

View File

@@ -31,6 +31,7 @@ npm run release:finalize # Publish npm, promote draft to published
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop`
## Fixing a failed release build
@@ -53,6 +54,7 @@ If the fix requires a code change (e.g. a broken build script), commit the fix t
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time

View File

@@ -1,275 +0,0 @@
# Split Panes Plan
**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane).
## Data Model
### Core Types
```typescript
interface SplitPane {
id: string;
tabIds: string[];
focusedTabId: string | null;
}
interface SplitGroup {
id: string;
direction: "horizontal" | "vertical";
children: SplitNode[];
sizes: number[]; // proportional, sum to 1, same length as children
}
type SplitNode =
| { kind: "pane"; pane: SplitPane }
| { kind: "group"; group: SplitGroup };
interface WorkspaceLayout {
root: SplitNode;
focusedPaneId: string;
}
```
### Design Decisions
- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store.
- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane.
- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use.
- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering.
- **Max depth: 4 levels.**
- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%).
### Default State
Every workspace starts with:
```typescript
{
root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } },
focusedPaneId: "main",
}
```
### Migration
Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree.
## Store Actions
### Everyday Operations (pane-agnostic)
These don't take a `paneId`. Mobile code only uses these.
```typescript
openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null;
closeTab(workspaceKey: string, tabId: string): void;
focusTab(workspaceKey: string, tabId: string): void;
retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null;
reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane
getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes
```
- `openTab` creates the tab and adds it to the focused pane.
- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane.
- `focusTab` finds the tab in any pane, focuses it and focuses that pane.
### Split Operations (desktop only)
```typescript
splitPane(workspaceKey: string, input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}): string | null; // new pane ID, or null if depth cap hit
moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void;
focusPane(workspaceKey: string, paneId: string): void;
resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void;
reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void;
```
## Tree Transformations
### splitPane
**Position mapping:**
- `left` / `right``horizontal` direction
- `top` / `bottom``vertical` direction
- `left` / `top` → new pane inserted before target
- `right` / `bottom` → new pane inserted after target
**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat.
```
Before: horizontal([A, B])
Split B right with tab X
Optimized: horizontal([A, B, C]) ← insert into existing group
Naive: horizontal([A, horizontal([B, C])]) ← wastes depth
```
**Steps:**
1. Check depth — reject if would exceed 4 levels
2. Remove `tabId` from source pane (could be same or different pane)
3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }`
4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane
5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position)
6. If source pane is now empty → collapse it
7. Set `focusedPaneId` to new pane
### collapsePane
Triggered when a pane's last tab is removed or moved out.
```
Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3]
B loses last tab
After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized)
```
**Steps:**
1. Remove pane from parent group's children
2. Remove corresponding entry from parent's sizes
3. Renormalize sizes to sum to 1
4. If parent group now has 1 child → unwrap: replace group with its single remaining child
5. Unwrap can cascade up the tree
6. Move focus to nearest sibling
### moveTabToPane
Tab dragged from one pane to another existing pane.
1. Remove `tabId` from source pane's `tabIds`
2. Insert into target pane's `tabIds` at drop position (or end)
3. Set target pane's `focusedTabId` to the moved tab
4. If source pane is now empty → collapsePane
5. Set `focusedPaneId` to target pane
### resizeSplit
User drags a divider between panes.
1. Find group by ID
2. Update the two adjacent sizes based on drag delta
3. Clamp each child to minimum proportion (0.1)
4. Renormalize so sizes sum to 1
## Keyboard Shortcuts
| Action | Shortcut |
|---|---|
| Split right | `Cmd+\` |
| Split down | `Cmd+Shift+\` |
| Focus pane left | `Cmd+Shift+←` |
| Focus pane right | `Cmd+Shift+→` |
| Focus pane up | `Cmd+Shift+↑` |
| Focus pane down | `Cmd+Shift+↓` |
| Move tab to pane left | `Cmd+Shift+Alt+←` |
| Move tab to pane right | `Cmd+Shift+Alt+→` |
| Move tab to pane up | `Cmd+Shift+Alt+↑` |
| Move tab to pane down | `Cmd+Shift+Alt+↓` |
| Close pane | `Cmd+Shift+W` |
Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs.
## Drag and Drop UX
### Drop Zones
When dragging a tab over a pane, the pane is divided into 5 drop zones:
- **Center** (inner 40%) — move tab to this pane (add to existing tab list)
- **Left edge** (leftmost 15%) — split left
- **Right edge** (rightmost 15%) — split right
- **Top edge** (topmost 15%) — split up
- **Bottom edge** (bottommost 15%) — split down
### Overlay Preview
On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity.
### Cross-Pane Tab Drag
Tabs can be dragged:
- Within a pane's tab bar → reorder (existing behavior via SortableInlineList)
- From one pane's tab bar to another pane's tab bar → move tab to that pane
- From a tab bar to a pane's drop zone → split
When dragging the last tab out of a pane, the pane collapses after the drop completes.
## Implementation Steps
### Step 1: Layout Store
Create `packages/app/src/stores/workspace-layout-store.ts`:
- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types
- Zustand store with AsyncStorage persistence
- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs`
- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs`
- Version 6 migration from flat tab store
### Step 2: Migrate Workspace Screen to Layout Store
Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved.
### Step 3: Split Tree Transformations
Add to the layout store:
- `splitPane` with the parent-direction optimization and depth check
- `collapsePane` with unwrap cascading
- `moveTabToPane`
- `resizeSplit`
Pure tree transformation functions, tested independently.
### Step 4: Split Container Component
Create `packages/app/src/components/split-container.tsx`:
- Recursive component that renders `SplitNode`
- Groups render as flex containers with direction from `SplitGroup.direction`
- Panes render tab bar + active panel content (using the panel registry)
- Resize handles between children of a group
### Step 5: Drop Zones and Overlay
Create `packages/app/src/components/split-drop-zone.tsx`:
- Overlay that appears during tab drag
- Divides pane into 5 zones (center + 4 edges)
- Shows preview rectangle on hover
- Calls `splitPane` or `moveTabToPane` on drop
### Step 6: Cross-Pane Drag
Extend the existing dnd-kit setup:
- Tab bar items remain draggable (existing)
- Pane drop zones become droppable targets
- Tab bar of other panes become droppable targets (move to pane)
- DndContext wraps the entire split container (not individual panes)
### Step 7: Keyboard Shortcuts
Register new actions in `keyboard/actions.ts`:
- `workspace.pane.split.right`, `workspace.pane.split.down`
- `workspace.pane.focus.left/right/up/down`
- `workspace.pane.move-tab.left/right/up/down`
- `workspace.pane.close`
Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen.
### Step 8: Pane Focus Navigation
Implement spatial navigation for `focus.left/right/up/down`:
- Walk the tree to find the focused pane's position in the layout
- Find the nearest pane in the requested direction
- Focus it
Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`.
## Constraints
- Mobile stays single-pane — same store, no special casing
- Max 4 levels of nesting
- Minimum pane size: 10% of parent
- `PaneContext` interface unchanged — no `paneId` added
- Panel registry unchanged — panels don't know about splits
- Existing tab shortcuts work on focused pane, unchanged

3194
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,6 @@
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"concurrently": "^9.2.1",
"prettier": "^3.5.3",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",

View File

@@ -2,17 +2,10 @@
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
attachConsole();
});
}
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
import "expo-router/entry";

View File

@@ -16,7 +16,6 @@
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -24,7 +23,6 @@
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "expo export --platform web",
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
@@ -60,9 +58,7 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="%LANG_ISO_CODE%">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
/* These styles make the body full-height */
html,
body {
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
}
/* These styles make the root element full-height */
#root {
display: flex;
height: 100%;
flex: 1;
}
</style>
<style>
button,
a,
input,
textarea,
select,
[role='button'],
[role='link'],
[role='textbox'],
[role='combobox'],
[role='tab'],
[role='switch'],
[role='checkbox'],
[role='slider'],
[role='menuitem'],
[tabindex],
[contenteditable='true'] {
-webkit-app-region: no-drag !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -53,11 +53,10 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -65,19 +64,18 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { attachConsole } from "@/utils/tauri-attach-console";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
attachConsole();
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
@@ -86,33 +84,37 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsDesktop()) {
void ensureOsNotificationPermission();
let disposed = false;
let unlisten: (() => void) | null = null;
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? ((payload as { data: Record<string, unknown> }).data)
: undefined;
router.push(buildNotificationRoute(data) as any);
}
);
void listenToDesktopNotificationClicks((payload) => {
router.push(buildNotificationRoute(payload.data) as any);
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
});
}
const target = globalThis as unknown as EventTarget;
@@ -126,7 +128,10 @@ function PushNotificationRouter() {
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
@@ -476,12 +481,24 @@ function OfferLinkListener({
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
useFaviconStatus();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
useEffect(() => {
if (!activeServerId || hosts.length === 0) {
return;
}
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {

View File

@@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
@@ -57,7 +57,7 @@ export default function Index() {
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { isTauriEnvironment } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isTauriEnvironment()) {
if (isDesktop()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);

View File

@@ -6,9 +6,9 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
getIsTauriMac,
getIsDesktopMac,
} from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
interface ScreenHeaderProps {
@@ -37,18 +37,21 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsTauriMac()
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left
: 0;
// On Tauri macOS, enable window dragging and double-click to maximize
const dragHandlers = useTauriDragHandlers();
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
return (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
style={[
styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
dragRegionStyle,
]}
{...dragHandlers}
>
<View style={[styles.left, leftStyle]}>{left}</View>

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -13,10 +13,10 @@ export function KeyboardShortcutsDialog() {
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isTauri = getIsTauri();
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
[isMac, isTauri]
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac]
);
return (

View File

@@ -20,7 +20,7 @@ import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
import { Combobox } from '@/components/ui/combobox'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
@@ -130,7 +130,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers()
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
@@ -448,9 +448,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
<View style={[{ height: trafficLightPadding.top }, dragRegionStyle]} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={[styles.sidebarHeader, dragRegionStyle]} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}

View File

@@ -30,7 +30,7 @@ import { NestableScrollContainer } from 'react-native-draggable-flatlist'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import type { DraggableListDragHandleProps } from './draggable-list.types'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { getIsTauri } from '@/constants/layout'
import { getIsDesktop } from '@/constants/layout'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import {
buildHostWorkspaceRouteWithOpenIntent,
@@ -1407,10 +1407,10 @@ export function SidebarWorkspaceList({
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map()
)
const isTauri = getIsTauri()
const isDesktopApp = getIsDesktop()
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown)
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
import { isDesktop, isDesktopMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75;
@@ -14,60 +14,56 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
// Tauri desktop app constants for macOS traffic light buttons
// Desktop app constants for macOS traffic light buttons
// These buttons (close/minimize/maximize) overlay the top-left corner
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Check if running in Tauri desktop app (any OS)
function isTauri(): boolean {
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
return getTauri() !== null;
return isDesktop();
}
// Check if running in Tauri desktop app on macOS
function isTauriMac(): boolean {
// Check if running in desktop host on macOS
function isDesktopEnvironmentMac(): boolean {
if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false;
if (getTauri() === null) return false;
// Check for macOS via user agent
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
return isDesktopMac();
}
// Cached result - only cache true, keep checking if false (in case Tauri globals load later)
let _isTauriMacCached: boolean | null = null;
let _isTauriCached: boolean | null = null;
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isDesktopMacCached: boolean | null = null;
let _isDesktopCached: boolean | null = null;
export function getIsTauriMac(): boolean {
if (_isTauriMacCached === true) {
export function getIsDesktopMac(): boolean {
if (_isDesktopMacCached === true) {
return true;
}
const result = isTauriMac();
const result = isDesktopEnvironmentMac();
if (result) {
_isTauriMacCached = true;
_isDesktopMacCached = true;
}
return result;
}
export function getIsTauri(): boolean {
if (_isTauriCached === true) {
export function getIsDesktop(): boolean {
if (_isDesktopCached === true) {
return true;
}
const result = isTauri();
const result = isDesktopEnvironment();
if (result) {
_isTauriCached = true;
_isDesktopCached = true;
}
return result;
}
// Get traffic light padding values (only non-zero on Tauri macOS)
// Get traffic light padding values (only non-zero on desktop macOS)
export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsTauriMac()) {
if (!getIsDesktopMac()) {
return { left: 0, top: 0 };
}
return {
left: TAURI_TRAFFIC_LIGHT_WIDTH,
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}

View File

@@ -1,4 +1,4 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
interface AttachmentFileResult {
path: string;

View File

@@ -4,7 +4,7 @@ const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
}));
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
vi.mock("@/desktop/electron/invoke", () => ({
invokeDesktopCommand: invokeDesktopCommandMock,
}));

View File

@@ -1,6 +1,6 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);

View File

@@ -20,21 +20,21 @@ import { Button } from '@/components/ui/button'
import { useAppSettings } from '@/hooks/use-settings'
import { confirmDialog } from '@/utils/confirm-dialog'
import { openExternalUrl } from '@/utils/open-external-url'
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import {
getCliSymlinkInstructions,
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
stopManagedDaemon,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type CliSymlinkInstructions,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
} from '@/desktop/managed-runtime/managed-runtime'
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from '@/desktop/daemon/desktop-daemon'
export interface LocalDaemonSectionProps {
appVersion: string | null
@@ -46,33 +46,35 @@ export function LocalDaemonSection({
showLifecycleControls,
}: LocalDaemonSectionProps) {
const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon()
const showSection = shouldUseDesktopDaemon()
const { settings, updateSettings } = useAppSettings()
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null)
const [daemonVersion, setDaemonVersion] = useState<string | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null)
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => {
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve()
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status)
setManagedLogs(logs)
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
setDaemonStatus(status)
setDaemonLogs(logs)
setDaemonVersion(version.version)
setStatusError(null)
})
.catch((error) => {
@@ -86,35 +88,31 @@ export function LocalDaemonSection({
if (!showSection) {
return undefined
}
void loadManagedStatus()
void loadDaemonData()
return undefined
}, [loadManagedStatus, showSection])
}, [loadDaemonData, showSection])
)
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
const daemonStatusStateText =
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionMessage =
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'Restarts the built-in daemon.'
: 'Starts the built-in daemon.'
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return
}
if (isRestartingDaemon) {
if (!showSection || isRestartingDaemon) {
return
}
void confirmDialog({
title: daemonActionLabel,
message:
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.',
confirmLabel: daemonActionLabel,
@@ -128,19 +126,18 @@ export function LocalDaemonSection({
setIsRestartingDaemon(true)
setStatusMessage(null)
const action =
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
void action()
.then((status) => {
setManagedStatus(status)
setDaemonStatus(status)
setStatusMessage(
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
)
return loadManagedStatus()
return loadDaemonData()
})
.catch((error) => {
console.error('[Settings] Failed to change managed daemon state', error)
console.error('[Settings] Failed to change desktop daemon state', error)
const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
})
@@ -149,10 +146,10 @@ export function LocalDaemonSection({
})
})
.catch((error) => {
console.error('[Settings] Failed to open managed daemon action confirmation', error)
console.error('[Settings] Failed to open desktop daemon action confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection])
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -193,13 +190,13 @@ export function LocalDaemonSection({
setStatusMessage(null)
const stopPromise =
managedStatus?.status === 'running'
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null)
daemonStatus?.status === 'running'
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null)
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus())
.then(() => loadDaemonData())
.then(() => {
setStatusMessage('Built-in daemon paused and stopped.')
})
@@ -216,9 +213,9 @@ export function LocalDaemonSection({
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [
daemonStatus,
isUpdatingDaemonManagement,
loadManagedStatus,
managedStatus,
loadDaemonData,
settings.manageBuiltInDaemon,
updateSettings,
])
@@ -258,7 +255,7 @@ export function LocalDaemonSection({
}, [cliSymlinkInstructions?.commands])
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath
const logPath = daemonLogs?.logPath
if (!logPath) {
return
}
@@ -271,14 +268,14 @@ export function LocalDaemonSection({
console.error('[Settings] Failed to copy log path', error)
Alert.alert('Error', 'Unable to copy log path.')
})
}, [managedLogs?.logPath])
}, [daemonLogs?.logPath])
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
if (!daemonLogs) {
return
}
setIsLogsModalOpen(true)
}, [managedLogs])
}, [daemonLogs])
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
@@ -289,7 +286,7 @@ export function LocalDaemonSection({
setIsLoadingPairing(true)
setPairingStatusMessage(null)
void getManagedDaemonPairing()
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing)
if (!pairing.relayEnabled || !pairing.url) {
@@ -344,7 +341,7 @@ export function LocalDaemonSection({
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -398,7 +395,7 @@ export function LocalDaemonSection({
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? managedStatus?.status === 'running'
? daemonStatus?.status === 'running'
? 'Restarting...'
: 'Starting...'
: daemonActionLabel}
@@ -425,10 +422,10 @@ export function LocalDaemonSection({
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
</View>
<View style={styles.actionGroup}>
{managedLogs?.logPath ? (
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
@@ -443,7 +440,7 @@ export function LocalDaemonSection({
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!managedLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
@@ -523,9 +520,9 @@ export function LocalDaemonSection({
snapPoints={['70%', '92%']}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
{daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
</Text>
</View>
</AdaptiveModalSheet>
@@ -537,7 +534,7 @@ const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
function PairingOfferDialogContent(input: {
isLoading: boolean
pairingOffer: ManagedPairingOffer | null
pairingOffer: DesktopPairingOffer | null
statusMessage: string | null
onCopyLink: () => void
}) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const managedRuntimeMock = vi.hoisted(() => {
const desktopDaemonMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
@@ -40,32 +40,32 @@ const managedRuntimeMock = vi.hoisted(() => {
};
});
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
vi.mock("./desktop-daemon", () => ({
openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
}));
describe("managed-tauri-daemon-transport", () => {
describe("desktop-daemon-transport", () => {
beforeEach(() => {
managedRuntimeMock.openLocalTransportSession.mockReset();
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
managedRuntimeMock.sendLocalTransportMessage.mockClear();
managedRuntimeMock.closeLocalTransportSession.mockClear();
desktopDaemonMock.openLocalTransportSession.mockReset();
desktopDaemonMock.listenToLocalTransportEvents.mockClear();
desktopDaemonMock.sendLocalTransportMessage.mockClear();
desktopDaemonMock.closeLocalTransportSession.mockClear();
});
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void;
managedRuntimeMock.openLocalTransportSession.mockImplementation(
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
@@ -75,7 +75,7 @@ describe("managed-tauri-daemon-transport", () => {
const onOpen = vi.fn();
transport.onOpen(onOpen);
managedRuntimeMock.emitEvent({
desktopDaemonMock.emitEvent({
sessionId: "local-session-1",
kind: "open",
});
@@ -93,21 +93,21 @@ describe("managed-tauri-daemon-transport", () => {
let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
managedRuntimeMock.openLocalTransportSession.mockImplementation(
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
@@ -121,7 +121,7 @@ describe("managed-tauri-daemon-transport", () => {
await Promise.resolve();
await Promise.resolve();
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,7 +5,7 @@ import {
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
} from "@/desktop/managed-runtime/managed-runtime";
} from "./desktop-daemon";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
@@ -49,7 +49,7 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
};
}
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;

View File

@@ -1,29 +1,24 @@
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
import { getDesktopHost, isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export type ManagedRuntimeStatus = {
runtimeId: string
runtimeVersion: string
runtimeRoot: string
}
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
export type ManagedDaemonStatus = {
runtimeId: string
runtimeVersion: string
export type DesktopDaemonStatus = {
serverId: string
status: string
status: DesktopDaemonState
listen: string
hostname: string | null
pid: number | null
home: string
error: string | null
}
export type ManagedDaemonLogs = {
export type DesktopDaemonLogs = {
logPath: string
contents: string
}
export type ManagedPairingOffer = {
export type DesktopPairingOffer = {
relayEnabled: boolean
url: string | null
qr: string | null
@@ -35,12 +30,6 @@ export type CliSymlinkInstructions = {
commands: string
}
export type ManagedTcpSettings = {
enabled: boolean
host: string
port: number
}
export type LocalTransportTarget = {
transportType: 'socket' | 'pipe'
transportPath: string
@@ -68,36 +57,42 @@ function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed runtime status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
const normalized = toStringOrNull(value)?.toLowerCase()
switch (normalized) {
case 'starting':
return 'starting'
case 'running':
return 'running'
case 'errored':
case 'error':
return 'errored'
case 'stopped':
case 'stopping':
case 'unknown':
default:
return 'stopped'
}
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon status response.')
throw new Error('Unexpected desktop daemon status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
serverId: toStringOrNull(raw.serverId) ?? '',
status: toStringOrNull(raw.status) ?? 'unknown',
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? '',
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? '',
error: toStringOrNull(raw.error),
}
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon logs response.')
throw new Error('Unexpected desktop daemon logs response.')
}
return {
logPath: toStringOrNull(raw.logPath) ?? '',
@@ -105,9 +100,9 @@ function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
}
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon pairing response.')
throw new Error('Unexpected desktop daemon pairing response.')
}
return {
relayEnabled: raw.relayEnabled === true,
@@ -127,36 +122,32 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
}
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null
export function shouldUseDesktopDaemon(): boolean {
return isDesktop()
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
@@ -171,27 +162,18 @@ export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstruction
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
}
export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
)
}
export type LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen
const listen = getDesktopHost()?.events?.on
if (typeof listen !== 'function') {
throw new Error('Tauri event API is unavailable.')
throw new Error('Desktop events API is unavailable.')
}
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
if (!payload) {
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
if (!isRecord(payload)) {
return
}
handler({

View File

@@ -0,0 +1,27 @@
import { getDesktopHost } from "@/desktop/host";
export type DesktopEventUnlisten = () => void;
type EventEnvelope = {
payload?: unknown;
};
export async function listenToDesktopEvent<TPayload>(
event: string,
handler: (payload: TPayload) => void
): Promise<DesktopEventUnlisten> {
const listen = getDesktopHost()?.events?.on;
if (typeof listen !== "function") {
throw new Error("Desktop event API is unavailable.");
}
const unlisten = await listen(event, (rawEvent: unknown) => {
const payload =
typeof rawEvent === "object" && rawEvent !== null && "payload" in rawEvent
? (rawEvent as EventEnvelope).payload
: rawEvent;
handler(payload as TPayload);
});
return typeof unlisten === "function" ? unlisten : () => {};
}

View File

@@ -0,0 +1,12 @@
import type { DesktopHostBridge } from "@/desktop/host";
export function getElectronHost(): DesktopHostBridge | null {
if (typeof window === "undefined") {
return null;
}
const host = window.paseoDesktop;
if (!host || typeof host !== "object") {
return null;
}
return host;
}

View File

@@ -1,13 +1,12 @@
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export async function invokeDesktopCommand<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const invoke = getTauri()?.core?.invoke;
const invoke = getDesktopHost()?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri invoke() is unavailable in this environment.");
throw new Error("Desktop invoke() is unavailable in this environment.");
}
return (await invoke(command, args)) as T;
}

View File

@@ -0,0 +1,37 @@
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
const getter = getDesktopHost()?.window?.getCurrentWindow;
if (typeof getter !== "function") {
return null;
}
try {
return getter() ?? null;
} catch {
return null;
}
}
export async function startDesktopDragging(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.startDragging !== "function") {
return;
}
await win.startDragging();
}
export async function toggleDesktopMaximize(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.toggleMaximize !== "function") {
return;
}
await win.toggleMaximize();
}
export async function isDesktopFullscreen(): Promise<boolean> {
const win = getDesktopWindow();
if (!win || typeof win.isFullscreen !== "function") {
return false;
}
return await win.isFullscreen();
}

View File

@@ -0,0 +1,111 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
export type DesktopNotificationPermission = "granted" | "denied" | "default";
export interface DesktopDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
export interface DesktopDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
export interface DesktopDialogBridge {
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
open?: (
options?: DesktopDialogOpenOptions
) => Promise<string | string[] | null>;
}
export interface DesktopNotificationBridge {
isSupported?: () => Promise<boolean>;
sendNotification?: (
payload: string | { title: string; body?: string; data?: Record<string, unknown> }
) => Promise<boolean>;
}
export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startDragging?: () => Promise<void>;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopWindowModuleBridge {
getCurrentWindow?: () => DesktopWindowBridge;
}
export interface DesktopEventsBridge {
on?: (
event: string,
handler: (payload: unknown) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
}
declare global {
interface Window {
paseoDesktop?: DesktopHostBridge;
}
}
export function getDesktopHost(): DesktopHostBridge | null {
if (Platform.OS !== "web") {
return null;
}
return getElectronHost();
}
export function isDesktop(): boolean {
return getDesktopHost() !== null;
}
export function isDesktopMac(): boolean {
if (!isDesktop()) {
return false;
}
if (typeof navigator === "undefined") {
return false;
}
const hostPlatform = getDesktopHost()?.platform?.toLowerCase();
if (hostPlatform === "darwin" || hostPlatform === "mac" || hostPlatform === "macos") {
return true;
}
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
}

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseCliSymlinkInstructions } from "./managed-runtime";
describe("parseCliSymlinkInstructions", () => {
it("parses CLI symlink instructions from the desktop backend", () => {
expect(
parseCliSymlinkInstructions({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
})
).toEqual({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
});
});
it("rejects non-object payloads", () => {
expect(() => parseCliSymlinkInstructions(null)).toThrow(
"Unexpected CLI symlink instructions response."
);
});
});

View File

@@ -1,58 +0,0 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri } from "@/utils/tauri";
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
export interface DesktopNotificationInput {
title: string;
body?: string;
data?: Record<string, unknown>;
}
export interface DesktopNotificationClickPayload {
data?: Record<string, unknown>;
}
export type DesktopNotificationClickHandler = (
payload: DesktopNotificationClickPayload
) => void;
export type DesktopNotificationClickUnlisten = () => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function sendDesktopNotification(
input: DesktopNotificationInput
): Promise<boolean> {
try {
await invokeDesktopCommand("send_desktop_notification", { input });
return true;
} catch (error) {
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
return false;
}
}
export async function listenToDesktopNotificationClicks(
handler: DesktopNotificationClickHandler
): Promise<DesktopNotificationClickUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
data: isRecord(payload.data) ? payload.data : undefined,
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}

View File

@@ -4,14 +4,17 @@ type MockPlatform = 'web' | 'ios' | 'android'
type GlobalSnapshot = {
Notification: unknown
__TAURI__: unknown
navigatorDescriptor?: PropertyDescriptor
paseoDesktop: unknown
}
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
paseoDesktop:
typeof window === 'undefined'
? undefined
: (window as { paseoDesktop?: unknown }).paseoDesktop,
}
function setNavigator(value: unknown): void {
@@ -24,13 +27,16 @@ function setNavigator(value: unknown): void {
function restoreGlobals(): void {
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
if (originalGlobals.navigatorDescriptor) {
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
} else {
delete (globalThis as { navigator?: unknown }).navigator
}
if (typeof window !== 'undefined') {
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
}
}
async function loadModuleForPlatform(platform: MockPlatform) {
@@ -47,20 +53,20 @@ describe('desktop-permissions', () => {
restoreGlobals()
})
it('shows section only in Tauri web runtime', async () => {
it('shows section only in desktop web runtime', async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
expect(shouldShowDesktopPermissionSection()).toBe(false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
expect(shouldShowDesktopPermissionSection()).toBe(true)
})
it('reads notification and microphone status', async () => {
const isPermissionGranted = vi.fn(async () => false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { isPermissionGranted },
class MockNotification {
static permission = 'default'
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
@@ -73,9 +79,8 @@ describe('desktop-permissions', () => {
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.notifications.state).toBe('not-granted')
expect(snapshot.notifications.state).toBe('prompt')
expect(snapshot.microphone.state).toBe('granted')
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
expect(snapshot.checkedAt).toBeTypeOf('number')
})
@@ -127,20 +132,21 @@ describe('desktop-permissions', () => {
)
})
it('requests notification permission via Tauri', async () => {
const requestPermission = vi.fn(async () => 'granted')
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { requestPermission },
it('requests notification permission via the browser Notification API', async () => {
class MockNotification {
static permission = 'default'
static requestPermission = vi.fn(async () => 'granted')
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'notifications' })
expect(result.state).toBe('granted')
expect(requestPermission).toHaveBeenCalledTimes(1)
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1)
})
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
it('reads browser Notification permission when available', async () => {
class MockNotification {
static permission = 'denied'
}

View File

@@ -1,5 +1,5 @@
import { Platform } from 'react-native'
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
import { getDesktopHost } from '@/desktop/host'
export type DesktopPermissionKind = 'notifications' | 'microphone'
@@ -45,7 +45,7 @@ type NavigatorLike = {
}
export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && getDesktopHost() !== null
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -132,27 +132,6 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
})
}
function mapTauriNotificationPermissionResult(
permission: TauriNotificationPermission
): DesktopPermissionStatus {
if (permission === 'granted') {
return status({
state: 'granted',
detail: 'Notifications are allowed by the OS.',
})
}
if (permission === 'denied') {
return status({
state: 'denied',
detail: 'Notifications are denied in system settings.',
})
}
return status({
state: 'prompt',
detail: 'Notifications have not been granted yet.',
})
}
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
@@ -161,44 +140,30 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.isPermissionGranted !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing isPermissionGranted().',
})
}
const desktopHost = getDesktopHost()
if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
try {
const granted = await tauriNotification.isPermissionGranted()
if (granted) {
return status({
state: 'granted',
detail: 'Tauri reports notifications are granted.',
})
}
const supported = await desktopHost.notification.isSupported()
return status({
state: 'not-granted',
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
})
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
state: supported ? 'granted' : 'unavailable',
detail: supported
? 'Desktop notifications are supported.'
: 'Desktop notifications are not supported on this platform.',
})
} catch {
// Fall through to web API check
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') {
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
return mapNotificationPermissionString(NotificationConstructor.permission)
}
return mapNotificationPermissionString(NotificationConstructor.permission)
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -279,18 +244,11 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing requestPermission().',
})
}
const NotificationConstructor = getWebNotificationConstructor()
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
try {
const permission = await tauriNotification.requestPermission()
return mapTauriNotificationPermissionResult(permission)
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
@@ -299,23 +257,10 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
try {
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
})
}
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {

View File

@@ -122,7 +122,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, delivery works. Click it to verify the open flow.",
body: "If you can see this, desktop notifications work.",
});
if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered");

View File

@@ -1,9 +1,9 @@
import { getTauri } from '@/utils/tauri'
import { getDesktopHost } from '@/desktop/host'
export async function pickDirectory(): Promise<string | null> {
const open = getTauri()?.dialog?.open
const open = getDesktopHost()?.dialog?.open
if (typeof open !== 'function') {
throw new Error('Tauri dialog open() is unavailable in this environment.')
throw new Error('Desktop dialog open() is unavailable in this environment.')
}
const selection = await open({

View File

@@ -1,6 +1,6 @@
import { Platform } from 'react-native'
import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && isDesktop()
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {

View File

@@ -1,21 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const tauriState = vi.hoisted(() => ({
const desktopHostState = vi.hoisted(() => ({
api: null as any,
}));
vi.mock("@/utils/tauri", () => ({
getTauri: () => tauriState.api,
vi.mock("@/desktop/host", () => ({
getDesktopHost: () => desktopHostState.api,
}));
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
openImagePathsWithDesktopDialog,
} from "./image-attachment-picker";
describe("image-attachment-picker", () => {
beforeEach(() => {
tauriState.api = null;
desktopHostState.api = null;
});
it("normalizes a picked File into a blob source", async () => {
@@ -69,13 +69,13 @@ describe("image-attachment-picker", () => {
expect(result[0]?.mimeType).toBe("image/png");
});
it("uses the tauri dialog api when available", async () => {
it("uses the desktop dialog api when available", async () => {
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
tauriState.api = {
desktopHostState.api = {
dialog: { open },
};
const result = await openImagePathsWithTauriDialog();
const result = await openImagePathsWithDesktopDialog();
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
@@ -87,21 +87,11 @@ describe("image-attachment-picker", () => {
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
});
it("falls back to core invoke for the tauri dialog plugin", async () => {
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
tauriState.api = {
core: { invoke },
};
it("throws when desktop dialog API is not available", async () => {
desktopHostState.api = {};
const result = await openImagePathsWithTauriDialog();
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
options: expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
}),
});
expect(result).toEqual(["/tmp/one.png"]);
await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
"Desktop dialog API is not available."
);
});
});

View File

@@ -1,4 +1,4 @@
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export type PickedImageSource =
| { kind: "file_uri"; uri: string }
@@ -77,15 +77,15 @@ export async function normalizePickedImageAssets(
);
}
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
function normalizeDesktopDialogSelection(selection: string | string[] | null): string[] {
if (!selection) {
return [];
}
return Array.isArray(selection) ? selection : [selection];
}
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
const tauri = getTauri();
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
const desktop = getDesktopHost();
const options = {
directory: false,
multiple: true,
@@ -93,18 +93,10 @@ export async function openImagePathsWithTauriDialog(): Promise<string[]> {
title: "Attach images",
};
const dialogOpen = tauri?.dialog?.open;
if (typeof dialogOpen === "function") {
return normalizeTauriDialogSelection(await dialogOpen(options));
const dialogOpen = desktop?.dialog?.open;
if (typeof dialogOpen !== "function") {
throw new Error("Desktop dialog API is not available.");
}
const invoke = tauri?.core?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri dialog API is not available.");
}
const result = await invoke("plugin:dialog|open", { options });
return normalizeTauriDialogSelection(
Array.isArray(result) || typeof result === "string" || result === null ? result : null
);
return normalizeDesktopDialogSelection(await dialogOpen(options));
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
export interface AudioCaptureConfig {
sampleRate?: number;
@@ -157,20 +157,20 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
);
}
if (!secureContext && isTauri) {
if (!secureContext && isDesktopApp) {
console.warn(
"[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
"[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
@@ -158,17 +158,17 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
}
if (!secureContext && isTauri) {
if (!secureContext && isDesktopApp) {
console.warn(
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
"[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
);
}

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getCurrentTauriWindow } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
@@ -92,15 +92,15 @@ function getSystemColorScheme(): ColorScheme {
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
return;
}
try {
await tauriWindow.setBadgeCount(count);
await desktopWindow.setBadgeCount(count);
} catch (error) {
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
}

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
@@ -33,7 +33,7 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".tiff": "image/tiff",
};
type TauriDragDropPayload =
type DesktopDragDropPayload =
| {
type: "enter";
paths: string[];
@@ -49,8 +49,8 @@ type TauriDragDropPayload =
type: "leave";
};
type TauriDragDropEvent = {
payload: TauriDragDropPayload;
type DesktopDragDropEvent = {
payload: DesktopDragDropPayload;
};
function isImageFile(file: File): boolean {
@@ -121,26 +121,27 @@ export function useFileDropZone({
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
});
} catch (error) {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
}
}
async function setupTauriDragDrop(): Promise<boolean> {
if (getTauri() === null) {
async function setupDesktopDragDrop(): Promise<boolean> {
const desktopHost = getDesktopHost();
if (desktopHost === null) {
return false;
}
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
const desktopWindow = desktopHost.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") {
return false;
}
try {
const unlisten = await tauriWindow.onDragDropEvent(
(event: TauriDragDropEvent) => {
const unlisten = await desktopWindow.onDragDropEvent(
(event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
@@ -185,7 +186,7 @@ export function useFileDropZone({
cleanup = unlisten;
return true;
} catch (error) {
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
return false;
}
}
@@ -269,8 +270,8 @@ export function useFileDropZone({
}
void (async () => {
const tauriListenersAttached = await setupTauriDragDrop();
if (disposed || tauriListenersAttached) {
const desktopListenersAttached = await setupDesktopDragDrop();
if (disposed || desktopListenersAttached) {
return;
}
setupDomDragDrop();

View File

@@ -2,10 +2,10 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { isTauriEnvironment } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker";
@@ -42,8 +42,8 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isTauriEnvironment()) {
const selectedPaths = await openImagePathsWithTauriDialog();
if (Platform.OS === "web" && isDesktop()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;
}

View File

@@ -1,15 +1,14 @@
import { useQuery } from '@tanstack/react-query'
import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime'
import { isTauriEnvironment } from '@/utils/tauri'
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
const MANAGED_DAEMON_SERVER_ID_QUERY_KEY = ['managed-daemon-server-id'] as const
const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ['desktop-daemon-server-id'] as const
interface ManagedDaemonServerIdResult {
interface DesktopDaemonServerIdResult {
serverId: string | null
}
async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult> {
const status = await getManagedDaemonStatus()
async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult> {
const status = await getDesktopDaemonStatus()
const serverId = status.serverId.trim()
return {
serverId: serverId.length > 0 ? serverId : null,
@@ -18,14 +17,15 @@ async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult>
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim()
const isDesktop = isTauriEnvironment()
const isDesktop = shouldUseDesktopDaemon()
const query = useQuery({
queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadManagedDaemonServerId,
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadDesktopDaemonServerId,
enabled: isDesktop,
staleTime: Infinity,
gcTime: Infinity,
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,

View File

@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -50,7 +50,7 @@ export function useKeyboardShortcuts({
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const isDesktopApp = getIsDesktop();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {
@@ -259,7 +259,7 @@ export function useKeyboardShortcuts({
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
if (isDesktopApp && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
}
if (key === "Shift") {
@@ -278,7 +278,7 @@ export function useKeyboardShortcuts({
event,
context: {
isMac,
isTauri,
isDesktop: isDesktopApp,
focusScope,
commandCenterOpen: store.commandCenterOpen,
hasSelectedAgent: canToggleFileExplorerShortcut({
@@ -314,7 +314,7 @@ export function useKeyboardShortcuts({
if (key === "Alt") {
useKeyboardShortcutsStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
if (isDesktopApp && (key === "Meta" || key === "Control")) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
}
};

View File

@@ -23,7 +23,7 @@ function shortcutContext(
): KeyboardShortcutContext {
return {
isMac: false,
isTauri: false,
isDesktop: false,
focusScope: "other",
commandCenterOpen: false,
hasSelectedAgent: true,
@@ -84,7 +84,7 @@ type HelpSectionCase = {
name: string;
context: {
isMac: boolean;
isTauri: boolean;
isDesktop: boolean;
};
expectedKeys: Record<string, string[]>;
};
@@ -106,42 +106,42 @@ describe("keyboard-shortcuts", () => {
{
name: "matches workspace index jump on web via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.navigate.index",
payload: { index: 2 },
},
{
name: "matches workspace index jump on tauri via Mod+digit",
name: "matches workspace index jump on desktop via Mod+digit",
event: { key: "2", code: "Digit2", metaKey: true },
context: { isMac: true, isTauri: true },
context: { isMac: true, isDesktop: true },
action: "workspace.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on tauri via Alt+digit",
name: "matches tab index jump on desktop via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true },
context: { isTauri: true },
context: { isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on web via Alt+Shift+digit",
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches workspace relative navigation on web via Alt+[",
event: { key: "[", code: "BracketLeft", altKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.navigate.relative",
payload: { delta: -1 },
},
{
name: "matches workspace relative navigation on tauri via Mod+]",
name: "matches workspace relative navigation on desktop via Mod+]",
event: { key: "]", code: "BracketRight", ctrlKey: true },
context: { isTauri: true },
context: { isDesktop: true },
action: "workspace.navigate.relative",
payload: { delta: 1 },
},
@@ -160,13 +160,13 @@ describe("keyboard-shortcuts", () => {
{
name: "matches Alt+Shift+W to close current tab on web",
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.tab.close.current",
},
{
name: "matches Mod+W to close current tab on tauri",
name: "matches Mod+W to close current tab on desktop",
event: { key: "w", code: "KeyW", metaKey: true },
context: { isMac: true, isTauri: true },
context: { isMac: true, isDesktop: true },
action: "workspace.tab.close.current",
},
{
@@ -312,7 +312,7 @@ describe("keyboard-shortcut help sections", () => {
const helpCases: HelpSectionCase[] = [
{
name: "uses web defaults for workspace and tab jump",
context: { isMac: true, isTauri: false },
context: { isMac: true, isDesktop: false },
expectedKeys: {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
@@ -324,8 +324,8 @@ describe("keyboard-shortcut help sections", () => {
},
},
{
name: "uses tauri defaults for workspace and tab jump",
context: { isMac: true, isTauri: true },
name: "uses desktop defaults for workspace and tab jump",
context: { isMac: true, isDesktop: true },
expectedKeys: {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
@@ -338,7 +338,7 @@ describe("keyboard-shortcut help sections", () => {
},
{
name: "uses mod+period as non-mac left sidebar shortcut",
context: { isMac: false, isTauri: false },
context: { isMac: false, isDesktop: false },
expectedKeys: {
"toggle-left-sidebar": ["mod", "."],
},

View File

@@ -8,7 +8,7 @@ import type {
export type KeyboardShortcutContext = {
isMac: boolean;
isTauri: boolean;
isDesktop: boolean;
focusScope: KeyboardFocusScope;
commandCenterOpen: boolean;
hasSelectedAgent: boolean;
@@ -36,7 +36,7 @@ export type KeyboardShortcutHelpSection = {
type KeyboardShortcutPlatformContext = {
isMac: boolean;
isTauri: boolean;
isDesktop: boolean;
};
type KeyboardShortcutHelpEntry = {
@@ -158,20 +158,20 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
},
},
{
id: "workspace-tab-close-current-mod-w-tauri",
id: "workspace-tab-close-current-mod-w-desktop",
action: "workspace.tab.close.current",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-close-current",
section: "global",
label: "Close current tab",
keys: ["mod", "W"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -183,17 +183,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
event.altKey &&
event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-close-current",
section: "global",
label: "Close current tab",
keys: ["alt", "shift", "W"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
id: "workspace-navigate-index-mod-digit-tauri",
id: "workspace-navigate-index-mod-digit-desktop",
action: "workspace.navigate.index",
matches: (event) =>
isMod(event) &&
@@ -201,13 +201,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-jump-index",
section: "global",
label: "Jump to workspace",
keys: ["mod", "1-9"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -220,17 +220,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-jump-index",
section: "global",
label: "Jump to workspace",
keys: ["alt", "1-9"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
id: "workspace-tab-navigate-index-alt-digit-tauri",
id: "workspace-tab-navigate-index-alt-digit-desktop",
action: "workspace.tab.navigate.index",
matches: (event) =>
!event.metaKey &&
@@ -239,13 +239,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-jump-index",
section: "global",
label: "Jump to tab",
keys: ["alt", "1-9"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -258,13 +258,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-jump-index",
section: "global",
label: "Jump to tab",
keys: ["alt", "shift", "1-9"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
@@ -276,13 +276,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketLeft" || event.key === "["),
payload: withRelativeDelta(-1),
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-prev",
section: "global",
label: "Previous workspace",
keys: ["mod", "["],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -294,13 +294,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketRight" || event.key === "]"),
payload: withRelativeDelta(1),
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-next",
section: "global",
label: "Next workspace",
keys: ["mod", "]"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -313,13 +313,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketLeft" || event.key === "["),
payload: withRelativeDelta(-1),
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-prev",
section: "global",
label: "Previous workspace",
keys: ["alt", "["],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
@@ -332,13 +332,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketRight" || event.key === "]"),
payload: withRelativeDelta(1),
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-next",
section: "global",
label: "Next workspace",
keys: ["alt", "]"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{

View File

@@ -16,9 +16,9 @@ import {
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseManagedDesktopDaemon,
startManagedDaemon,
} from "@/desktop/managed-runtime/managed-runtime";
shouldUseDesktopDaemon,
startDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import {
buildDaemonWebSocketUrl,
@@ -32,8 +32,8 @@ import {
} from "@/utils/connection-selection";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "@/utils/managed-tauri-daemon-transport";
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -432,7 +432,7 @@ function probeIntervalForConnection(
function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = {
suppressSendErrors: true,
clientId,
@@ -1188,7 +1188,7 @@ export class HostRuntimeStore {
return;
}
if (shouldUseManagedDesktopDaemon()) {
if (shouldUseDesktopDaemon()) {
await this.bootstrapDesktop();
} else {
await this.bootstrapLocalhost();
@@ -1197,43 +1197,22 @@ export class HostRuntimeStore {
private async bootstrapDesktop(): Promise<void> {
try {
await Promise.allSettled([
(async () => {
const daemon = await startManagedDaemon();
if (!daemon.serverId) {
return;
}
const connection = connectionFromListen(daemon.listen);
if (!connection) {
return;
}
await this.upsertHostConnection({
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
});
})().catch((error) => {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}),
(async () => {
const { client, serverId, hostname } = await connectToDaemon(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: "directTcp",
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
);
await this.upsertDirectConnection({
serverId,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
existingClient: client,
});
})().catch(() => undefined),
]);
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection) {
return;
}
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
label: hostname ?? daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop startup host connections", error);
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}
}

View File

@@ -47,7 +47,7 @@ import type {
} from '@server/server/agent/agent-sdk-types'
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window'
import { useDesktopDragHandlers } from '@/utils/desktop-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
@@ -238,7 +238,7 @@ function DraftAgentScreenContent({
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout
)
const tauriDragHandlers = useTauriDragHandlers()
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
const draftIdRef = useRef(generateDraftId())
const draftAgentIdRef = useRef(generateDraftId())
@@ -974,7 +974,7 @@ function DraftAgentScreenContent({
const explorerServerId = draftExplorerCheckout?.serverId ?? null
const explorerIsGit = draftExplorerCheckout?.isGit ?? false
const mainContent = (
<View style={styles.container} {...tauriDragHandlers}>
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={styles.outerContainer}>
<View style={styles.agentPanel}>
<View

View File

@@ -7,8 +7,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { getIsTauriMac } from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
@@ -19,9 +19,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openProjectPicker = useOpenProjectPicker(serverId);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const dragHandlers = useTauriDragHandlers();
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
useEffect(() => {
if (!isMobile) {
@@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]);
return (
<View style={styles.container} {...dragHandlers}>
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>

View File

@@ -41,6 +41,7 @@ import {
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isDesktop as isDesktopHost } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
@@ -513,7 +514,7 @@ export default function SettingsScreen() {
const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = Platform.OS === "web";
const isDesktop = isDesktopHost();
const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);

View File

@@ -1,7 +1,7 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { useTauriDragHandlers } from "@/utils/tauri-window";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({
}));
export function StartupSplashScreen() {
const dragHandlers = useTauriDragHandlers();
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
return (
<View style={styles.container} {...dragHandlers}>
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up</Text>
</View>

View File

@@ -80,6 +80,17 @@ function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean
return JSON.stringify(left) === JSON.stringify(right)
}
function dedupeHostConnections(connections: HostConnection[]): HostConnection[] {
const next: HostConnection[] = []
for (const connection of connections) {
if (next.some((existing) => hostConnectionEquals(existing, connection))) {
continue
}
next.push(connection)
}
return next
}
export function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[]
serverId: string
@@ -96,9 +107,17 @@ export function upsertHostConnectionInProfiles(input: {
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const existing = input.profiles
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
if (
daemon.serverId === serverId ||
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
) {
matches.push(index)
}
return matches
}, [])
if (idx === -1) {
if (matchingIndexes.length === 0) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
@@ -111,31 +130,37 @@ export function upsertHostConnectionInProfiles(input: {
return [...existing, profile]
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
const hadConnection = connectionIdx !== -1
const connectionChanged =
connectionIdx === -1
? true
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: connectionChanged
? prev.connections.map((connection, index) =>
index === connectionIdx ? input.connection : connection
)
: prev.connections
const matchedProfiles = matchingIndexes.map((index) => existing[index]!)
const prev =
matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]!
const nextConnections = dedupeHostConnections([
...matchedProfiles.flatMap((daemon) => daemon.connections),
input.connection,
])
const nextLifecycle = prev.lifecycle
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const nextLabel =
labelTrimmed || (prev.label === prev.serverId ? derivedLabel : prev.label)
const nextPreferredConnectionId =
prev.preferredConnectionId &&
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
? prev.preferredConnectionId
: input.connection.id
const nextCreatedAt = matchedProfiles.reduce(
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
prev.createdAt
)
const changed =
matchingIndexes.length > 1 ||
prev.serverId !== serverId ||
nextCreatedAt !== prev.createdAt ||
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection ||
connectionChanged
nextConnections.length !== prev.connections.length ||
nextConnections.some((connection, index) => {
const previousConnection = prev.connections[index]
return !previousConnection || !hostConnectionEquals(connection, previousConnection)
})
if (!changed) {
return existing
@@ -143,15 +168,19 @@ export function upsertHostConnectionInProfiles(input: {
const nextProfile: HostProfile = {
...prev,
serverId,
label: nextLabel,
lifecycle: nextLifecycle,
connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId,
createdAt: nextCreatedAt,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
const firstIndex = matchingIndexes[0]!
const matchingIndexSet = new Set(matchingIndexes)
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index))
next.splice(firstIndex, 0, nextProfile)
return next
}

View File

@@ -1,5 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const desktopHostState = {
api: null as {
dialog?: {
ask?: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
} | null,
};
type MockPlatform = "web" | "ios" | "android";
type AlertButton = {
@@ -19,13 +27,16 @@ async function loadModuleForPlatform(platform: MockPlatform): Promise<{
},
Platform: { OS: platform },
}));
vi.doMock("@/desktop/host", () => ({
getDesktopHost: () => desktopHostState.api,
}));
const module = await import("./confirm-dialog");
return { confirmDialog: module.confirmDialog, alertMock };
}
function clearDialogGlobals(): void {
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
desktopHostState.api = null;
delete (globalThis as { confirm?: unknown }).confirm;
}
@@ -37,13 +48,13 @@ describe("confirmDialog", () => {
clearDialogGlobals();
});
it("uses Tauri dialog.ask on web when available", async () => {
it("uses the desktop dialog bridge on web when available", async () => {
const askMock = vi.fn(async () => true);
const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = {
activeElement: { blur: blurMock },
} as unknown as Document;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
desktopHostState.api = {
dialog: { ask: askMock },
};
@@ -67,7 +78,7 @@ describe("confirmDialog", () => {
});
});
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
it("falls back to browser confirm on web when desktop APIs are unavailable", async () => {
const browserConfirm = vi.fn(() => true);
const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = {

View File

@@ -1,5 +1,5 @@
import { Alert, Platform } from "react-native";
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
export interface ConfirmDialogInput {
title: string;
@@ -48,14 +48,14 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
});
}
function getTauriApi() {
function getDesktopApi() {
if (Platform.OS !== "web") {
return null;
}
return getTauri();
return getDesktopHost();
}
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOptions {
const labels = resolveButtonLabels(input);
return {
@@ -74,35 +74,18 @@ function blurActiveWebElement(): void {
(activeElement as HTMLElement | null)?.blur?.();
}
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const tauriApi = getTauriApi();
if (!tauriApi) {
async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const desktopApi = getDesktopApi();
if (!desktopApi) {
return null;
}
blurActiveWebElement();
const options = buildTauriAskOptions(input);
const tauriAsk = tauriApi.dialog?.ask;
const options = buildDesktopAskOptions(input);
const desktopAsk = desktopApi.dialog?.ask;
if (typeof tauriAsk === "function") {
try {
return Boolean(await tauriAsk(input.message, options));
} catch (error) {
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
}
}
const tauriInvoke = tauriApi.core?.invoke;
if (typeof tauriInvoke === "function") {
try {
const result = await tauriInvoke("plugin:dialog|ask", {
message: input.message,
...options,
});
return result === true;
} catch (error) {
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
}
if (typeof desktopAsk === "function") {
return Boolean(await desktopAsk(input.message, options));
}
return null;
@@ -124,9 +107,9 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
return showNativeConfirmDialog(input);
}
const tauriResult = await showTauriConfirmDialog(input);
if (tauriResult !== null) {
return tauriResult;
const desktopResult = await showDesktopConfirmDialog(input);
if (desktopResult !== null) {
return desktopResult;
}
return showWebConfirmDialog(input);
@@ -134,5 +117,5 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
export const __private__ = {
blurActiveWebElement,
buildTauriAskOptions,
buildDesktopAskOptions,
};

View File

@@ -0,0 +1,117 @@
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import { Platform, type ViewStyle } from "react-native";
import {
getIsDesktopMac,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
type DesktopDragHandlers = {
style?: CSSProperties & ViewStyle;
};
const DESKTOP_DRAG_REGION_STYLE = {
WebkitAppRegion: "drag",
} as CSSProperties & ViewStyle;
export async function startDragging() {
const win = getDesktopWindow();
if (win && typeof win.startDragging === "function") {
try {
await win.startDragging();
} catch (error) {
console.warn("[DesktopWindow] startDragging failed", error);
}
}
}
export async function toggleMaximize() {
const win = getDesktopWindow();
if (win && typeof win.toggleMaximize === "function") {
try {
await win.toggleMaximize();
} catch (error) {
console.warn("[DesktopWindow] toggleMaximize failed", error);
}
}
}
export function useDesktopDragHandlers(): DesktopDragHandlers {
if (Platform.OS !== "web" || !isDesktop()) {
return {};
}
return {
style: DESKTOP_DRAG_REGION_STYLE,
};
}
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = getDesktopWindow();
if (!win) return;
const fullscreen =
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fullscreen);
if (typeof win.onResized !== "function") {
return;
}
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs =
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
void setup();
return () => {
disposed = true;
runCleanup();
};
}, []);
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}

View File

@@ -1,10 +1,10 @@
import * as Linking from "expo-linking";
import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") {
const opener = getTauri()?.opener?.openUrl;
const opener = getDesktopHost()?.opener?.openUrl;
if (typeof opener === "function") {
await opener(url);
return;

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockNotificationOptions = {
body?: string;
data?: Record<string, unknown>;
icon?: string;
};
type MockNotificationInstance = {
@@ -18,7 +19,6 @@ type GlobalSnapshot = {
dispatchEvent: unknown;
focus: unknown;
location: unknown;
__TAURI__: unknown;
};
const originalGlobals: GlobalSnapshot = {
@@ -27,12 +27,34 @@ const originalGlobals: GlobalSnapshot = {
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
focus: (globalThis as { focus?: unknown }).focus,
location: (globalThis as { location?: unknown }).location,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
};
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
async function loadModuleForPlatform(
platform: "web" | "ios" | "android",
options?: {
desktopHost?: {
notification?: {
sendNotification?: (payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>;
};
} | null;
}
) {
vi.resetModules();
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
vi.doMock("@/desktop/host", () => ({
getDesktopHost: () => options?.desktopHost ?? null,
}));
vi.doMock("expo-asset", () => ({
Asset: {
fromModule: vi.fn(() => ({
uri: "http://localhost:8081/packages/app/assets/images/notification-icon.png",
})),
},
}));
return import("./os-notifications");
}
@@ -42,7 +64,6 @@ function restoreGlobals(): void {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
(globalThis as { location?: unknown }).location = originalGlobals.location;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
}
describe("sendOsNotification", () => {
@@ -71,7 +92,6 @@ describe("sendOsNotification", () => {
});
afterEach(() => {
vi.doUnmock("@/desktop/notifications/desktop-notifications");
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
@@ -168,14 +188,8 @@ describe("sendOsNotification", () => {
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
});
it("uses the desktop notification bridge when Tauri is available", async () => {
const sendDesktopNotification = vi.fn(async () => true);
vi.doMock("@/desktop/notifications/desktop-notifications", () => ({
sendDesktopNotification,
}));
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {};
it("returns false when the Notification API is unavailable", async () => {
(globalThis as { Notification?: unknown }).Notification = undefined;
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Agent finished",
@@ -183,11 +197,61 @@ describe("sendOsNotification", () => {
data: { serverId: "srv-1", agentId: "agent-1" },
});
expect(sent).toBe(false);
});
it("does not attach a click handler when there is no route target", async () => {
const created: MockNotificationInstance[] = [];
class MockNotification implements MockNotificationInstance {
static permission = "granted";
static requestPermission = vi.fn(async () => "granted");
onclick: ((event: Event) => void) | null = null;
close = vi.fn();
constructor(public title: string, public options?: MockNotificationOptions) {
created.push(this);
}
}
(globalThis as { Notification?: unknown }).Notification = MockNotification;
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = vi.fn();
(globalThis as { location?: unknown }).location = { assign: vi.fn() };
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
});
expect(sent).toBe(true);
expect(sendDesktopNotification).toHaveBeenCalledWith({
title: "Agent finished",
body: "Done",
data: { serverId: "srv-1", agentId: "agent-1" },
expect(created).toHaveLength(1);
expect(created[0]?.onclick).toBeNull();
});
it("uses the desktop notification bridge when available", async () => {
const sendNotification = vi.fn(async () => true);
const { sendOsNotification } = await loadModuleForPlatform("web", {
desktopHost: {
notification: {
sendNotification,
},
},
});
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
data: { serverId: "srv-1" },
});
expect(sent).toBe(true);
expect(sendNotification).toHaveBeenCalledWith({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
data: { serverId: "srv-1" },
});
});
});

View File

@@ -1,7 +1,10 @@
import { Asset } from "expo-asset";
import { Platform } from "react-native";
import { sendDesktopNotification } from "@/desktop/notifications/desktop-notifications";
import { buildNotificationRoute } from "./notification-routing";
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
import {
buildNotificationRoute,
resolveNotificationTarget,
} from "./notification-routing";
type OsNotificationPayload = {
title: string;
@@ -15,28 +18,41 @@ export type WebNotificationClickDetail = {
type WebNotificationInstance = {
onclick?: ((event: Event) => void) | null;
onclose?: ((event: Event) => void) | null;
addEventListener?: (type: string, listener: (event: Event) => void) => void;
removeEventListener?: (type: string, listener: (event: Event) => void) => void;
close?: () => void;
};
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null;
const activeWebNotifications = new Set<WebNotificationInstance>();
let notificationIconUrl: string | null | undefined;
function getTauriNotificationModule(): TauriNotificationApi | null {
if (Platform.OS !== "web") {
return null;
}
return getTauri()?.notification ?? null;
function getDesktopNotificationSender():
| ((payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>)
| null {
const sendNotification = getDesktopHost()?.notification?.sendNotification;
return typeof sendNotification === "function"
? (sendNotification as (payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>)
: null;
}
function getWebNotificationConstructor(): {
permission: string;
requestPermission?: () => Promise<string>;
new (title: string, options?: { body?: string; data?: Record<string, unknown> }): unknown;
new (
title: string,
options?: {
body?: string;
data?: Record<string, unknown>;
icon?: string;
}
): unknown;
} | null {
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
return NotificationConstructor ?? null;
@@ -70,49 +86,31 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
if (Platform.OS !== "web") {
return false;
}
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
return await ensureTauriNotificationPermission(tauriNotification);
}
return await ensureNotificationPermission();
}
async function ensureTauriNotificationPermission(
notificationModule: TauriNotificationApi
): Promise<boolean> {
if (typeof notificationModule.isPermissionGranted === "function") {
try {
const granted = await notificationModule.isPermissionGranted();
if (granted) {
return true;
}
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to check notification permission",
error
);
}
}
function hasNotificationClickTarget(
data: Record<string, unknown> | undefined
): boolean {
const target = resolveNotificationTarget(data);
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
}
if (typeof notificationModule.requestPermission !== "function") {
console.warn(
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
);
return false;
function getWebNotificationIconUrl(): string | undefined {
if (notificationIconUrl !== undefined) {
return notificationIconUrl ?? undefined;
}
try {
const result = await notificationModule.requestPermission();
return result === "granted";
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to request notification permission",
error
const asset = Asset.fromModule(
require("../../assets/images/notification-icon.png")
);
return false;
notificationIconUrl = asset.uri ?? null;
} catch {
notificationIconUrl = null;
}
return notificationIconUrl ?? undefined;
}
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
@@ -156,41 +154,12 @@ function attachWebClickHandler(
notification: WebNotificationInstance,
data: Record<string, unknown> | undefined
): void {
activeWebNotifications.add(notification);
const cleanup = () => {
activeWebNotifications.delete(notification);
};
const onClick = () => {
const focus = (globalThis as { focus?: () => void }).focus;
if (typeof focus === "function") {
focus();
}
notification.onclick = () => {
const handledByApp = dispatchWebNotificationClick({ data });
if (!handledByApp) {
fallbackNavigateToNotificationTarget(data);
}
cleanup();
if (typeof notification.close === "function") {
notification.close();
}
};
const onClose = () => {
cleanup();
};
if (typeof notification.addEventListener === "function") {
notification.addEventListener("click", onClick);
notification.addEventListener("close", onClose);
return;
}
notification.onclick = onClick;
notification.onclose = onClose;
}
export async function sendOsNotification(
@@ -201,8 +170,9 @@ export async function sendOsNotification(
return false;
}
if (getTauri()) {
return await sendDesktopNotification(payload);
const desktopNotificationSender = getDesktopNotificationSender();
if (desktopNotificationSender) {
return await desktopNotificationSender(payload);
}
const NotificationConstructor = getWebNotificationConstructor();
@@ -212,8 +182,11 @@ export async function sendOsNotification(
const notification = new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
icon: getWebNotificationIconUrl(),
}) as WebNotificationInstance;
attachWebClickHandler(notification, payload.data);
if (hasNotificationClickTarget(payload.data)) {
attachWebClickHandler(notification, payload.data);
}
return true;
}
}

View File

@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut";
export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") {
return Platform.OS === "ios" ? "mac" : "non-mac";
}
if (getIsTauriMac()) return "mac";
if (getIsDesktopMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? "";
@@ -15,4 +15,3 @@ export function getShortcutOs(): ShortcutOs {
/Mac|iPhone|iPad|iPod/i.test(platform);
return isApple ? "mac" : "non-mac";
}

View File

@@ -1,55 +0,0 @@
import { getTauri } from "@/utils/tauri";
let attached = false;
export function attachConsole(): void {
if (attached || !getTauri()) {
return;
}
attached = true;
const originalLog = console.log;
const originalInfo = console.info;
const originalWarn = console.warn;
const originalError = console.error;
const originalDebug = console.debug;
function formatArgs(args: unknown[]): string {
return args
.map((a) =>
typeof a === "string" ? a : JSON.stringify(a, null, 0) ?? String(a)
)
.join(" ");
}
function forwardToRust(level: number, args: unknown[]): void {
const invoke = getTauri()?.core?.invoke;
if (typeof invoke !== "function") return;
try {
void invoke("webview_log", { level, message: formatArgs(args) });
} catch {
// silently ignore IPC errors
}
}
console.log = (...args: unknown[]) => {
originalLog.apply(console, args);
forwardToRust(1, args); // info
};
console.info = (...args: unknown[]) => {
originalInfo.apply(console, args);
forwardToRust(1, args); // info
};
console.warn = (...args: unknown[]) => {
originalWarn.apply(console, args);
forwardToRust(2, args); // warn
};
console.error = (...args: unknown[]) => {
originalError.apply(console, args);
forwardToRust(3, args); // error
};
console.debug = (...args: unknown[]) => {
originalDebug.apply(console, args);
forwardToRust(0, args); // debug
};
}

View File

@@ -1,178 +0,0 @@
import { Platform } from "react-native";
import { useState, useEffect } from "react";
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
let tauriWindow: any = null;
const NON_DRAGGABLE_SELECTOR = [
"button",
"a",
"input",
"textarea",
"select",
"[role='button']",
"[role='link']",
"[role='textbox']",
"[role='combobox']",
"[contenteditable='true']",
].join(", ");
async function getTauriWindow() {
if (tauriWindow) return tauriWindow;
// Double-check: both environment check AND platform check
if (!isTauriEnvironment()) {
return null;
}
try {
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri().
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
// the native (Hermes) builds and break parsing/runtime on mobile.
const tauri = getTauri();
if (!tauri) {
return null;
}
// Prefer the public global window module.
const publicWindow = getCurrentTauriWindow();
if (publicWindow) {
tauriWindow = publicWindow;
return tauriWindow;
}
// Fallback: core invoke (should exist when withGlobalTauri is enabled).
// We assume the main window label is "main" (default in Tauri when not specified).
const invoke = tauri?.core?.invoke;
if (typeof invoke === "function") {
tauriWindow = {
label: "main",
startDragging: () => invoke("plugin:window|start_dragging", { label: "main" }),
toggleMaximize: () => invoke("plugin:window|toggle_maximize", { label: "main" }),
isFullscreen: () => invoke("plugin:window|is_fullscreen", { label: "main" }),
onResized: (handler: (e: unknown) => void) => tauri?.event?.listen?.("tauri://resize", handler),
};
return tauriWindow;
}
return null;
} catch {
return null;
}
}
export async function startDragging() {
const win = await getTauriWindow();
if (win) {
try {
await win.startDragging();
} catch (error) {
console.warn("[TauriWindow] startDragging failed", error);
}
}
}
export async function toggleMaximize() {
const win = await getTauriWindow();
if (win) {
try {
await win.toggleMaximize();
} catch (error) {
console.warn("[TauriWindow] toggleMaximize failed", error);
}
}
}
// Returns event handlers for drag region behavior
export function useTauriDragHandlers() {
// Dragging should work on any desktop OS when running under Tauri.
if (Platform.OS !== "web" || !isTauriEnvironment()) {
return {};
}
return {
onMouseDown: (e: React.MouseEvent) => {
// Only handle primary button, ignore if clicking on interactive elements.
// Tauri docs recommend using `e.detail` on mousedown for double-click maximize.
if (e.defaultPrevented) return;
if (e.button !== 0) return;
const target = e.target instanceof Element ? e.target : null;
if (target?.closest(NON_DRAGGABLE_SELECTOR)) return;
// Prevent text selection when dragging
e.preventDefault();
// Double click to maximize, otherwise drag.
if (e.detail === 2) {
toggleMaximize();
} else {
startDragging();
}
},
};
}
// Hook that returns traffic light padding, accounting for fullscreen state
// In fullscreen, traffic lights are hidden so no padding is needed
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[TauriWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[TauriWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = await getTauriWindow();
if (!win) return;
// Check initial fullscreen state
const fullscreen = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fullscreen);
// Listen for resize events which include fullscreen changes
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
void setup();
return () => {
disposed = true;
runCleanup();
};
}, []);
if (!getIsTauriMac() || isFullscreen) {
return { left: 0, top: 0 };
}
return {
left: TAURI_TRAFFIC_LIGHT_WIDTH,
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
};
}

View File

@@ -1,106 +0,0 @@
export type TauriNotificationPermission = "granted" | "denied" | "default";
export interface TauriDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
export interface TauriDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
export interface TauriDialogApi {
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
open?: (
options?: TauriDialogOpenOptions
) => Promise<string | string[] | null>;
}
export interface TauriCoreApi {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
convertFileSrc?: (path: string) => string;
}
export interface TauriEventApi {
listen?: (
event: string,
handler: (event: unknown) => void
) => Promise<() => void> | (() => void);
}
export interface TauriWindowApi {
label?: string;
startDragging?: () => Promise<void>;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
}
export interface TauriWindowModule {
getCurrentWindow?: () => TauriWindowApi;
}
export interface TauriOpenerApi {
openUrl?: (url: string) => Promise<void>;
}
export interface TauriNotificationApi {
isPermissionGranted?: () => Promise<boolean>;
requestPermission?: () => Promise<TauriNotificationPermission>;
sendNotification?: (
payload: string | { title: string; body?: string }
) => Promise<void>;
}
export interface TauriWebSocketApi {
connect?: (url: string, config?: unknown) => Promise<unknown>;
}
export interface TauriApi {
core?: TauriCoreApi;
dialog?: TauriDialogApi;
event?: TauriEventApi;
notification?: TauriNotificationApi;
opener?: TauriOpenerApi;
websocket?: TauriWebSocketApi;
window?: TauriWindowModule;
}
export function getTauri(): TauriApi | null {
const tauri = (globalThis as { __TAURI__?: unknown }).__TAURI__;
if (!tauri || typeof tauri !== "object") {
return null;
}
return tauri as TauriApi;
}
export function isTauriEnvironment(): boolean {
return getTauri() !== null;
}
export function getCurrentTauriWindow(): TauriWindowApi | null {
const getter = getTauri()?.window?.getCurrentWindow;
if (typeof getter !== "function") {
return null;
}
try {
return getter() ?? null;
} catch {
return null;
}
}

View File

@@ -5,8 +5,8 @@ import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "./managed-tauri-daemon-transport";
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
@@ -49,7 +49,7 @@ export async function buildClientConfig(
serverId?: string
): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,

View File

@@ -1,4 +1,4 @@
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type {
AudioEngine,
AudioEngineCallbacks,
@@ -300,12 +300,12 @@ export function createAudioEngine(
typeof window !== "undefined" && window.location
? window.location.origin
: "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
);

9
packages/desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Build output
dist/
release/
# Dependencies
node_modules/
# TypeScript
*.tsbuildinfo

View File

@@ -0,0 +1,3 @@
# desktop
Electron desktop app for Paseo.

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,44 @@
appId: sh.paseo.desktop
productName: Paseo
executableName: Paseo
directories:
output: release
files:
- dist/**/*
extraResources:
- from: ../app/dist
to: app-dist
publish:
provider: github
owner: anthropics
repo: paseo
mac:
category: public.app-category.developer-tools
icon: assets/icon.icns
extraFiles:
- from: scripts/paseo
to: MacOS/paseo
target:
- dmg
- zip
linux:
category: Development
icon: assets
extraFiles:
- from: scripts/paseo
to: paseo
target:
- AppImage
- tar.gz
win:
icon: assets/icon.ico
extraFiles:
- from: scripts/paseo.cmd
to: paseo.cmd
target:
- nsis
- zip
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true

View File

@@ -1,233 +0,0 @@
{
"name": "desktop",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "desktop",
"version": "0.1.3",
"license": "ISC",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.9.6",
"@tauri-apps/cli-darwin-x64": "2.9.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

View File

@@ -2,17 +2,26 @@
"name": "@getpaseo/desktop",
"version": "0.1.30",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
"scripts": {
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime && npm run validate:managed-runtime",
"dev": "tauri dev",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
"tauri": "tauri"
"build": "npm --prefix ../.. run build:daemon && npm run build:main && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.30",
"@getpaseo/server": "0.1.30",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.0.3",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"wait-on": "8.0.5"
}
}

View File

@@ -1,590 +0,0 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const cacheRoot = path.join(desktopRoot, ".cache", "managed-runtime");
const packageVersion = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
).version;
const workspaces = [
{ name: "@getpaseo/relay", root: path.join(repoRoot, "packages", "relay") },
{ name: "@getpaseo/server", root: path.join(repoRoot, "packages", "server") },
{ 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 });
}
async function ensureDir(target) {
await fs.mkdir(target, { recursive: true });
}
async function copyDir(source, target) {
await ensureDir(path.dirname(target));
await fs.cp(source, target, { recursive: true, dereference: true, force: true });
}
async function copyFile(source, target, mode) {
await ensureDir(path.dirname(target));
await fs.copyFile(source, target);
if (mode != null) {
await fs.chmod(target, mode);
}
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
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();
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const [tool, ...rest] = trimmed.split(/\s+/);
if (!tool || rest.length === 0) {
continue;
}
entries.set(tool, rest.join(" "));
}
return entries;
}
function resolveNodeVersion(toolVersions) {
const raw = toolVersions.get("nodejs");
if (!raw) {
throw new Error("Missing nodejs entry in .tool-versions.");
}
const version = raw.trim().replace(/^v/, "");
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Unsupported nodejs version in .tool-versions: ${raw}`);
}
return version;
}
function resolveNodeArtifact(version) {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const archMap = {
arm64: "arm64",
x64: "x64",
};
const platform = platformMap[process.platform];
const arch = archMap[process.arch];
if (!platform || !arch) {
throw new Error(`Managed runtime is not implemented for ${process.platform}-${process.arch}.`);
}
const baseName = `node-v${version}-${platform}-${arch}`;
const extension = process.platform === "win32" ? "zip" : process.platform === "linux" ? "tar.xz" : "tar.gz";
return {
version,
baseName,
archiveName: `${baseName}.${extension}`,
extension,
downloadUrl: `https://nodejs.org/dist/v${version}/${baseName}.${extension}`,
checksumsUrl: `https://nodejs.org/dist/v${version}/SHASUMS256.txt`,
};
}
async function downloadFile(url, target) {
await ensureDir(path.dirname(target));
runCommand("curl", [
"--fail",
"--location",
"--retry",
"3",
"--silent",
"--show-error",
"--output",
target,
url,
]);
}
async function sha256File(target) {
const hash = createHash("sha256");
hash.update(await fs.readFile(target));
return hash.digest("hex");
}
async function computeRuntimeContentHash(input) {
const hash = createHash("sha256");
hash.update(`packageVersion:${packageVersion}\n`);
hash.update(`nodeVersion:${input.nodeVersion}\n`);
hash.update(`platform:${process.platform}\n`);
hash.update(`arch:${process.arch}\n`);
for (const tarball of input.tarballs) {
hash.update(`workspace:${tarball.name}\n`);
hash.update(`filename:${path.basename(tarball.path)}\n`);
hash.update(`sha256:${await sha256File(tarball.path)}\n`);
}
return hash.digest("hex").slice(0, 12);
}
async function ensureNodeArchive(nodeArtifact) {
const versionCacheRoot = path.join(cacheRoot, `node-v${nodeArtifact.version}`);
const archivePath = path.join(versionCacheRoot, nodeArtifact.archiveName);
const checksumsPath = path.join(versionCacheRoot, "SHASUMS256.txt");
if (!(await pathExists(archivePath))) {
await downloadFile(nodeArtifact.downloadUrl, archivePath);
}
if (!(await pathExists(checksumsPath))) {
await downloadFile(nodeArtifact.checksumsUrl, checksumsPath);
}
const checksums = await fs.readFile(checksumsPath, "utf8");
const expectedLine = checksums
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.endsWith(` ${nodeArtifact.archiveName}`));
if (!expectedLine) {
throw new Error(`Missing checksum for ${nodeArtifact.archiveName} in SHASUMS256.txt`);
}
const expectedSha = expectedLine.split(/\s+/)[0];
const actualSha = await sha256File(archivePath);
if (actualSha !== expectedSha) {
throw new Error(
`Checksum mismatch for ${nodeArtifact.archiveName}: expected ${expectedSha}, got ${actualSha}`
);
}
return archivePath;
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
if (result.status !== 0 || result.error) {
throw new Error(
[
`Command failed: ${command} ${args.join(" ")}`,
result.error ? String(result.error) : null,
result.signal ? `signal: ${result.signal}` : null,
result.status != null ? `exit code: ${result.status}` : null,
result.stdout?.trim(),
result.stderr?.trim(),
]
.filter(Boolean)
.join("\n")
);
}
return result;
}
function withManagedRuntimeNodeOptions(env = process.env) {
const existing = env.NODE_OPTIONS?.trim() ?? "";
if (existing.includes("--max-old-space-size")) {
return env;
}
const nodeOptions = existing ? `${existing} --max-old-space-size=8192` : "--max-old-space-size=8192";
return {
...env,
NODE_OPTIONS: nodeOptions,
};
}
async function extractNodeDistribution(archivePath, nodeArtifact) {
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
if (nodeArtifact.extension === "zip") {
runCommand("powershell", [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractionRoot.replace(/'/g, "''")}' -Force`,
]);
} else {
const tarFlag = nodeArtifact.extension === "tar.xz" ? "-xJf" : "-xzf";
runCommand("tar", [tarFlag, archivePath, "-C", extractionRoot]);
}
return {
extractionRoot,
extractedRoot: path.join(extractionRoot, nodeArtifact.baseName),
};
}
async function ensureWorkspaceBuilds() {
const requiredPaths = [
path.join(repoRoot, "packages", "relay", "dist"),
path.join(repoRoot, "packages", "server", "dist"),
path.join(repoRoot, "packages", "cli", "dist"),
];
for (const requiredPath of requiredPaths) {
if (!(await pathExists(requiredPath))) {
throw new Error(
`Managed runtime build is missing required path: ${requiredPath}. Run the daemon build first.`
);
}
}
}
async function packWorkspace(packageRoot, tarballRoot) {
await ensureDir(tarballRoot);
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("Missing npm_execpath while building managed runtime.");
}
const result = runCommand(process.execPath, [
npmExecPath,
"pack",
"--json",
"--pack-destination",
tarballRoot,
], {
cwd: packageRoot,
env: withManagedRuntimeNodeOptions(process.env),
});
const [{ filename }] = JSON.parse(result.stdout.trim());
if (!filename) {
throw new Error(`npm pack did not produce a filename for ${packageRoot}`);
}
return path.join(tarballRoot, filename);
}
async function installPackedWorkspaces(runtimeRoot, bundledNodeRoot, tarballs) {
const nodeExecutable = process.platform === "win32"
? path.join(bundledNodeRoot, "node.exe")
: path.join(bundledNodeRoot, "bin", "node");
const npmCli = process.platform === "win32"
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
const install = spawnSync(nodeExecutable, [
npmCli,
"install",
"--include=optional",
"--omit=dev",
"--no-package-lock",
"--no-save",
...tarballs,
], {
cwd: runtimeRoot,
stdio: "inherit",
env: {
...process.env,
npm_config_audit: "false",
npm_config_fund: "false",
},
});
if (install.status !== 0) {
throw new Error(
`Managed runtime dependency install failed with exit code ${install.status ?? 1}.`
);
}
}
function resolveSherpaNativePackageName() {
const sherpaPlatformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const sherpaPlatform = sherpaPlatformMap[process.platform];
if (!sherpaPlatform) {
throw new Error(`Managed runtime sherpa package is not implemented for ${process.platform}.`);
}
return `sherpa-onnx-${sherpaPlatform}-${process.arch}`;
}
async function ensureSherpaNativePackage(runtimeRoot, bundledNodeRoot) {
const packageName = resolveSherpaNativePackageName();
const packageDir = path.join(runtimeRoot, "node_modules", packageName);
if (await pathExists(packageDir)) {
return;
}
const sherpaNodePackageJson = JSON.parse(
await fs.readFile(
path.join(runtimeRoot, "node_modules", "sherpa-onnx-node", "package.json"),
"utf8"
)
);
const packageVersion = sherpaNodePackageJson.version;
if (!packageVersion) {
throw new Error("Missing version in sherpa-onnx-node package.json.");
}
const nodeExecutable = process.platform === "win32"
? path.join(bundledNodeRoot, "node.exe")
: path.join(bundledNodeRoot, "bin", "node");
const npmCli = process.platform === "win32"
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
const install = spawnSync(nodeExecutable, [
npmCli,
"install",
"--include=optional",
"--omit=dev",
"--no-package-lock",
"--no-save",
`${packageName}@${packageVersion}`,
], {
cwd: runtimeRoot,
stdio: "inherit",
env: {
...process.env,
npm_config_audit: "false",
npm_config_fund: "false",
},
});
if (install.status !== 0) {
throw new Error(
`Managed runtime sherpa package install failed with exit code ${install.status ?? 1}.`
);
}
}
async function writeRuntimePackageJson(runtimeRoot) {
await fs.writeFile(
path.join(runtimeRoot, "package.json"),
JSON.stringify(
{
name: "paseo-managed-runtime",
private: true,
version: packageVersion,
},
null,
2
) + "\n",
"utf8"
);
}
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]));
const archDir = path.join(onnxRoot, "linux", process.arch);
if (await pathExists(archDir)) {
const entries = await fs.readdir(archDir);
await Promise.all(
entries
.filter((name) => name.includes("cuda") || name.includes("tensorrt"))
.map((name) => fs.rm(path.join(archDir, name), { force: true }))
);
}
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 vendorRoot = path.join(
runtimeRoot,
"node_modules",
"@anthropic-ai",
"claude-agent-sdk",
"vendor"
);
const ripgrepRoot = path.join(vendorRoot, "ripgrep");
const keepName = ripgrepPlatformDirMap[process.platform]?.[process.arch];
if (keepName) {
await pruneChildrenExcept(ripgrepRoot, new Set(["COPYING", keepName]));
}
const treeSitterBashRoot = path.join(vendorRoot, "tree-sitter-bash");
if (keepName) {
await pruneChildrenExcept(treeSitterBashRoot, new Set([keepName]));
}
}
async function pruneManagedRuntime(runtimeRoot) {
await Promise.all([
pruneNodeDistribution(runtimeRoot),
pruneOnnxRuntime(runtimeRoot),
pruneNodePty(runtimeRoot),
pruneClaudeAgentSdk(runtimeRoot),
]);
}
async function main() {
await ensureWorkspaceBuilds();
const toolVersions = await readToolVersions();
const nodeVersion = resolveNodeVersion(toolVersions);
const nodeArtifact = resolveNodeArtifact(nodeVersion);
await rmSafe(resourcesRoot);
await ensureDir(resourcesRoot);
const archivePath = await ensureNodeArchive(nodeArtifact);
const { extractionRoot, extractedRoot } = await extractNodeDistribution(archivePath, nodeArtifact);
const tarballRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-pack-"));
try {
const tarballs = [];
for (const workspace of workspaces) {
tarballs.push({
name: workspace.name,
path: await packWorkspace(workspace.root, tarballRoot),
});
}
const runtimeContentHash = await computeRuntimeContentHash({
nodeVersion,
tarballs,
});
const runtimeId =
`${packageVersion}-node-${nodeVersion}-${process.platform}-${process.arch}-${runtimeContentHash}`;
const runtimeRoot = path.join(resourcesRoot, runtimeId);
await ensureDir(runtimeRoot);
await copyDir(extractedRoot, path.join(runtimeRoot, "node"));
await writeRuntimePackageJson(runtimeRoot);
await installPackedWorkspaces(
runtimeRoot,
path.join(runtimeRoot, "node"),
tarballs.map((entry) => entry.path)
);
await ensureSherpaNativePackage(runtimeRoot, path.join(runtimeRoot, "node"));
await pruneManagedRuntime(runtimeRoot);
const nodeRelativePath = process.platform === "win32"
? path.join("node", "node.exe")
: path.join("node", "bin", "node");
const manifest = {
runtimeId,
runtimeVersion: packageVersion,
platform: process.platform,
arch: process.arch,
createdAt: new Date().toISOString(),
nodeRelativePath,
cliEntrypointRelativePath: path.join(
"node_modules",
"@getpaseo",
"cli",
"dist",
"index.js"
),
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
serverRunnerRelativePath: path.join(
"node_modules",
"@getpaseo",
"server",
"dist",
"scripts",
"daemon-runner.js"
),
};
await fs.writeFile(
path.join(runtimeRoot, "runtime-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
"utf8"
);
await fs.writeFile(
path.join(resourcesRoot, "current-runtime.json"),
JSON.stringify(
{
runtimeId,
runtimeVersion: packageVersion,
relativeRoot: runtimeId,
},
null,
2
) + "\n",
"utf8"
);
if (process.platform !== "win32") {
await fs.chmod(path.join(runtimeRoot, nodeRelativePath), 0o755);
}
} finally {
await rmSafe(extractionRoot);
await rmSafe(tarballRoot);
}
}
await main();

View File

@@ -1,807 +0,0 @@
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import WebSocket from "ws";
import { createClientChannel } from "@getpaseo/relay/e2ee";
const execFileAsync = promisify(execFile);
const COMMAND_TIMEOUT_MS = 120_000;
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const relayRoot = path.join(repoRoot, "packages", "relay");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const currentRuntimeVersion = desktopPackageJson.version;
const currentRuntimePointer = JSON.parse(
await fs.readFile(
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
"utf8"
)
);
const currentRuntimeId = currentRuntimePointer.runtimeId;
function resolvePackagedBinary() {
const targetRoot = process.env.PASEO_MANAGED_SMOKE_RUST_TARGET
? path.join(
desktopRoot,
"src-tauri",
"target",
process.env.PASEO_MANAGED_SMOKE_RUST_TARGET,
"release"
)
: path.join(desktopRoot, "src-tauri", "target", "release");
if (process.platform === "darwin") {
return path.join(
targetRoot,
"bundle",
"macos",
"Paseo.app",
"Contents",
"MacOS",
"Paseo"
);
}
if (process.platform === "linux") {
return path.join(
targetRoot,
"bundle",
"appimage",
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
);
}
if (process.platform === "win32") {
return path.join(
targetRoot,
"Paseo.exe"
);
}
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function snapshotTree(root) {
if (!(await pathExists(root))) {
return [];
}
const entries = [];
async function walk(current) {
const stat = await fs.stat(current);
const relative = path.relative(root, current) || ".";
entries.push({
relative,
kind: stat.isDirectory() ? "dir" : "file",
size: stat.size,
mtimeMs: stat.mtimeMs,
});
if (!stat.isDirectory()) {
return;
}
const children = await fs.readdir(current);
children.sort();
for (const child of children) {
await walk(path.join(current, child));
}
}
await walk(root);
entries.sort((left, right) => left.relative.localeCompare(right.relative));
return entries;
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function waitForChildExit(child, timeoutMs) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
await new Promise((resolve) => {
const timeout = setTimeout(resolve, timeoutMs);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
async function execFileWithTimeout(command, args, options, label) {
try {
return await execFileAsync(command, args, {
timeout: COMMAND_TIMEOUT_MS,
...options,
});
} catch (error) {
if (error?.killed && error?.signal === "SIGTERM") {
const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
throw new Error(
`Timed out after ${COMMAND_TIMEOUT_MS}ms running ${label}: ${JSON.stringify(command)} ${renderedArgs}`
);
}
throw error;
}
}
async function runBinary(binaryPath, args, env) {
const { stdout, stderr } = await execFileWithTimeout(binaryPath, args, {
env,
cwd: repoRoot,
maxBuffer: 10 * 1024 * 1024,
}, "packaged desktop binary");
const trimmed = stdout.trim();
return {
stdout,
stderr,
json: trimmed ? JSON.parse(trimmed) : null,
};
}
async function terminateChildProcess(child, label) {
if (!child || child.exitCode !== null || child.killed) {
return;
}
if (process.platform === "win32" && typeof child.pid === "number") {
try {
await execFileWithTimeout(
"taskkill",
["/pid", String(child.pid), "/T", "/F"],
{ maxBuffer: 1024 * 1024 },
`${label} taskkill`
);
} catch {}
await waitForChildExit(child, 5_000);
return;
}
try {
child.kill("SIGTERM");
} catch {}
await waitForChildExit(child, 5_000);
if (child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {}
await waitForChildExit(child, 5_000);
}
}
async function runWorkspaceCli(args, env) {
const { stdout, stderr } = await execFileWithTimeout(
process.execPath,
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
},
maxBuffer: 10 * 1024 * 1024,
},
"workspace CLI"
);
let json = null;
if (stdout.trim()) {
try {
json = JSON.parse(stdout.trim());
} catch {
json = null;
}
}
return { stdout, stderr, json };
}
async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
const { stdout, stderr } = await execFileWithTimeout(
path.join(runtimeRoot, manifest.nodeRelativePath),
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
PASEO_HOME: managedHome,
},
maxBuffer: 10 * 1024 * 1024,
},
"bundled runtime CLI"
);
return { stdout, stderr };
}
async function resolvePackagedRuntimeStatus(binaryPath) {
const binaryDir = path.dirname(binaryPath);
const resourceDirs = [
path.join(binaryDir, "resources"),
binaryDir,
path.join(binaryDir, "..", "Resources"),
path.join(binaryDir, "..", "resources"),
];
let bundledRoot = null;
for (const resourceDir of resourceDirs) {
for (const candidate of [
path.join(resourceDir, "resources", "managed-runtime"),
path.join(resourceDir, "managed-runtime"),
]) {
if (await pathExists(candidate)) {
bundledRoot = candidate;
break;
}
}
if (bundledRoot) {
break;
}
}
if (!bundledRoot) {
throw new Error("Managed runtime resources are not bundled with this desktop build.");
}
const pointer = JSON.parse(
await fs.readFile(path.join(bundledRoot, "current-runtime.json"), "utf8")
);
const runtimeRoot = path.join(bundledRoot, pointer.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
return {
runtimeId: manifest.runtimeId,
runtimeVersion: manifest.runtimeVersion,
runtimeRoot,
};
}
async function readDaemonStatus(home, env) {
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
return result.json ?? {};
}
async function pidListeningOnPort(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
maxBuffer: 1024 * 1024,
});
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
async function getAvailablePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
const url = new URL(`ws://${endpoint}/ws`);
url.searchParams.set("serverId", serverId);
url.searchParams.set("role", role);
url.searchParams.set("v", "2");
return url.toString();
}
function parseOfferUrlFromCommandOutput(stdout) {
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
if (!match) {
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
}
return match[0];
}
function decodeOfferFromFragmentUrl(url) {
const marker = "#offer=";
const index = url.indexOf(marker);
if (index === -1) {
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
}
const encoded = url.slice(index + marker.length);
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(raw);
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
throw new Error(`Unexpected relay offer payload: ${raw}`);
}
return offer;
}
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
await waitFor(async () => {
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const url = buildRelayWebSocketUrl({
endpoint,
serverId: probeServerId,
role: "server",
});
const opened = await new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5_000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
assert.equal(opened, true);
}, timeoutMs, "relay websocket endpoint to accept connections");
}
async function connectViaRelay(endpoint, offer) {
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
role: "client",
})
);
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for managed relay pong."));
}, 20_000);
const transport = {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
ws.on("message", (data) => {
transport.onmessage?.(typeof data === "string" ? data : data.toString());
});
ws.on("close", (code, reason) => {
transport.onclose?.(code, reason.toString());
});
ws.on("error", (error) => {
transport.onerror?.(error);
});
ws.on("open", async () => {
try {
let pingSent = false;
let channelRef = null;
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
onmessage: (data) => {
try {
const payload = typeof data === "string" ? JSON.parse(data) : data;
if (payload?.type === "welcome") {
if (!pingSent && channelRef) {
pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" }));
}
return;
}
if (payload?.type === "pong") {
clearTimeout(timeout);
resolve(payload);
ws.close();
}
} catch (error) {
clearTimeout(timeout);
reject(error);
}
},
onerror: (error) => {
clearTimeout(timeout);
reject(error);
},
});
channelRef = channel;
await channel.send(
JSON.stringify({
type: "hello",
clientId: stableClientId,
clientType: "cli",
protocolVersion: 1,
})
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
});
}
function assertNoForbiddenPathsOrPorts(value, forbidden) {
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
for (const candidate of forbidden) {
assert.ok(
!text.includes(candidate),
`Unexpected forbidden managed smoke reference: ${candidate}`
);
}
}
async function ensurePackagedArtifact(binaryPath) {
if (process.env.PASEO_MANAGED_SMOKE_SKIP_BUILD === "1") {
return;
}
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to build the packaged desktop artifact during smoke tests.");
}
try {
await execFileWithTimeout(process.execPath, [npmExecPath, "run", "build"], {
cwd: desktopRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
}, "desktop smoke artifact build");
} catch (error) {
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
const signingBlocked =
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
(await pathExists(binaryPath));
if (!signingBlocked) {
throw error;
}
console.warn(
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
);
}
}
async function waitFor(assertion, timeoutMs, label) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
return await assertion();
} catch {
await sleep(250);
}
}
throw new Error(`Timed out waiting for ${label}`);
}
function logStep(label) {
currentStepLabel = label;
currentStepStartedAt = Date.now();
console.log(`\n[managed-smoke] ${label}`);
}
const packagedBinary = resolvePackagedBinary();
await ensurePackagedArtifact(packagedBinary);
if (!(await pathExists(packagedBinary))) {
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
}
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
const testRoot = path.join(tmpRoot, "managed-test-root");
const fakeHome = path.join(tmpRoot, "fake-home");
const fakePaseoHome = path.join(fakeHome, ".paseo");
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
const externalHome = path.join(tmpRoot, "external-daemon-home");
const externalPort = await getAvailablePort();
const externalEndpoint = `127.0.0.1:${externalPort}`;
const relayPort = await getAvailablePort();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const managedRuntimeDir = path.join(testRoot, "runtime");
await fs.mkdir(testRoot, { recursive: true });
await fs.mkdir(fakePaseoHome, { recursive: true });
await fs.mkdir(cliScratchHome, { recursive: true });
await fs.mkdir(externalHome, { recursive: true });
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
const smokeStartedAt = Date.now();
const smokeDeadlineMs = 15 * 60 * 1000;
let currentStepLabel = "initializing";
let currentStepStartedAt = smokeStartedAt;
const heartbeat = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - smokeStartedAt) / 1000);
const currentStepSeconds = Math.floor((Date.now() - currentStepStartedAt) / 1000);
console.log(
`[managed-smoke] heartbeat elapsed=${elapsedSeconds}s currentStep=${JSON.stringify(currentStepLabel)} stepElapsed=${currentStepSeconds}s`
);
if (Date.now() - smokeStartedAt > smokeDeadlineMs) {
console.error(
`[managed-smoke] FAIL exceeded ${smokeDeadlineMs / 1000}s overall deadline during ${JSON.stringify(currentStepLabel)}`
);
process.exit(1);
}
}, 30_000);
const managedEnv = {
...process.env,
HOME: fakeHome,
PASEO_HOME: cliScratchHome,
PASEO_DESKTOP_TEST_ROOT: testRoot,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_RELAY_ENABLED: "true",
PASEO_RELAY_ENDPOINT: relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
PASEO_APP_BASE_URL: "https://app.paseo.test",
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
CI: "true",
};
let externalPid = null;
let startedTemporaryExternalDaemon = false;
let relayProcess = null;
let runtimeStatus = null;
let managedStart = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
throw new Error("npm_execpath is required to launch wrangler during managed desktop smoke tests.");
}
try {
logStep(`Starting isolated local relay on ${relayEndpoint}`);
relayProcess = spawn(process.execPath, [
npmExecPath,
"exec",
"--",
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
], {
cwd: relayRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
relayProcess.stdout?.on("data", (chunk) => {
process.stdout.write(`[managed-smoke relay] ${chunk}`);
});
relayProcess.stderr?.on("data", (chunk) => {
process.stderr.write(`[managed-smoke relay] ${chunk}`);
});
await waitFor(async () => {
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
}, 60_000, "relay HTTP endpoint to start");
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
startedTemporaryExternalDaemon = true;
await runWorkspaceCli(
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
managedEnv
);
const externalStatus = await waitFor(
async () => {
const status = await readDaemonStatus(externalHome, managedEnv);
assert.equal(status.status, "running");
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
return status;
},
15_000,
"external daemon to start"
);
externalPid = externalStatus.pid;
assert.ok(externalPid, "external daemon pid should be present");
logStep("Resolving bundled runtime from packaged desktop artifact");
runtimeStatus = await resolvePackagedRuntimeStatus(packagedBinary);
assert.equal(runtimeStatus.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.runtimeVersion, currentRuntimeVersion);
assert.match(
runtimeStatus.runtimeRoot,
new RegExp(escapeForRegExp(currentRuntimeId)),
"runtime status should point into the bundled runtime selected by current-runtime.json"
);
assert.equal(
runtimeStatus.runtimeRoot.startsWith(testRoot),
false,
"runtime status should not resolve into managed app data"
);
assertNoForbiddenPathsOrPorts(runtimeStatus, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
const managedBootstrap = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["start", "--json"],
managedEnv
);
managedStart = (managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null) ?? await waitFor(
async () => {
const status = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["daemon", "status", "--json"],
managedEnv
);
const json = JSON.parse(status.stdout.trim());
assert.equal(json.status, "running");
assert.ok(json.pid, "managed daemon pid should exist");
assert.ok(json.serverId, "managed daemon should expose a server id");
return json;
},
10_000,
"managed daemon bootstrap status"
);
assert.equal(managedStart.status, "running");
assert.ok(managedStart.pid, "managed daemon pid should exist");
assert.ok(managedStart.serverId, "managed daemon should expose a server id");
assert.ok(managedStart.home, "managed daemon should expose its home directory");
assert.ok(managedStart.listen, "managed daemon should expose its listen target");
assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
const managedPid = managedStart.pid;
logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500);
const persistedManagedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
);
assert.equal(persistedManagedStatus.pid, managedPid);
assert.equal(persistedManagedStatus.status, "running");
assert.equal(persistedManagedStatus.serverId, managedStart.serverId);
assertNoForbiddenPathsOrPorts(persistedManagedStatus, forbiddenManagedReferences);
logStep("Verifying bundled CLI access without installing a shim");
const cliVersion = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const bundledCliStatus = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
);
const bundledCliDaemonStatus = JSON.parse(bundledCliStatus.stdout.trim());
assert.equal(bundledCliDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(bundledCliDaemonStatus, forbiddenManagedReferences);
logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "pair"],
managedEnv
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
assert.deepEqual(relayPong, { type: "pong" });
logStep("Re-running managed start without spawning duplicate daemons");
const managedRestartless = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["start", "--json"],
managedEnv
)
).stdout.trim()
);
assert.equal(managedRestartless.pid, managedPid);
logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
const externalPidAfter = externalStatusAfter.pid ?? null;
assert.equal(externalStatusAfter.status, "running");
assert.equal(externalPidAfter, externalPid);
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
const managedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
);
assert.ok(managedStatus.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus, forbiddenManagedReferences);
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
await fs.writeFile(
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
JSON.stringify(
{
runtimeStatus,
managedBootstrap: managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null,
managedStart,
persistedManagedStatus,
managedRestartless,
bundledCliDaemonStatus,
relayEndpoint,
relayOfferUrl,
relayPong,
externalEndpoint,
externalPid,
externalPidAfter,
managedStatus,
},
null,
2
) + "\n",
"utf8"
);
console.log(`\n[managed-smoke] PASS (${testRoot})`);
} finally {
clearInterval(heartbeat);
try {
if (runtimeStatus?.runtimeRoot) {
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart?.home ?? cliScratchHome,
["daemon", "stop", "--json"],
managedEnv
);
}
} catch {}
try {
if (startedTemporaryExternalDaemon) {
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
}
} catch {}
try {
await terminateChildProcess(relayProcess, "local relay");
} catch {}
}

28
packages/desktop/scripts/paseo Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
APP_EXECUTABLE="${SCRIPT_DIR}/Paseo"
if [ -d "${SCRIPT_DIR}/../Resources" ]; then
RESOURCES_DIR="${SCRIPT_DIR}/../Resources"
elif [ -d "${SCRIPT_DIR}/resources" ]; then
RESOURCES_DIR="${SCRIPT_DIR}/resources"
else
echo "Unable to locate Paseo resources directory next to ${SCRIPT_DIR}" >&2
exit 1
fi
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
if [ ! -x "${APP_EXECUTABLE}" ]; then
echo "Bundled Paseo executable not found at ${APP_EXECUTABLE}" >&2
exit 1
fi
if [ ! -f "${CLI_ENTRY}" ]; then
echo "Bundled Paseo CLI entrypoint not found at ${CLI_ENTRY}" >&2
exit 1
fi
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"

View File

@@ -0,0 +1,19 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
set "APP_EXECUTABLE=%SCRIPT_DIR%Paseo.exe"
set "CLI_ENTRY=%SCRIPT_DIR%resources\app.asar\node_modules\@getpaseo\cli\dist\index.js"
if not exist "%APP_EXECUTABLE%" (
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
exit /b 1
)
if not exist "%CLI_ENTRY%" (
echo Bundled Paseo CLI entrypoint not found at %CLI_ENTRY% 1>&2
exit /b 1
)
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*

View File

@@ -1,144 +0,0 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const signingIdentity = process.env.APPLE_SIGNING_IDENTITY;
if (process.platform !== "darwin") {
throw new Error("sign-managed-runtime-macos.mjs can only run on macOS.");
}
if (!signingIdentity) {
throw new Error("APPLE_SIGNING_IDENTITY is required to sign the managed runtime.");
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function walkFiles(root) {
const files = [];
async function walk(current) {
const stat = await fs.stat(current);
if (stat.isDirectory()) {
const children = await fs.readdir(current);
children.sort();
for (const child of children) {
await walk(path.join(current, child));
}
return;
}
files.push(current);
}
await walk(root);
return files;
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
if (result.status !== 0) {
throw new Error(
[
`Command failed: ${command} ${args.join(" ")}`,
result.stdout?.trim(),
result.stderr?.trim(),
]
.filter(Boolean)
.join("\n")
);
}
return result.stdout.trim();
}
function getFileKind(target) {
return run("file", ["-b", target]);
}
function isMachO(fileKind) {
return fileKind.includes("Mach-O");
}
function needsHardenedRuntime(fileKind) {
return fileKind.includes("executable");
}
function extractEntitlements(file) {
const result = spawnSync("codesign", ["-d", "--entitlements", "-", "--xml", file], {
stdio: "pipe",
encoding: "utf8",
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
return result.stdout;
}
async function main() {
const pointer = JSON.parse(
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
);
const runtimeRoot = path.join(resourcesRoot, pointer.relativeRoot);
if (!(await pathExists(runtimeRoot))) {
throw new Error(`Managed runtime root does not exist: ${runtimeRoot}`);
}
const files = await walkFiles(runtimeRoot);
const signTargets = [];
for (const file of files) {
const fileKind = getFileKind(file);
if (!isMachO(fileKind)) {
continue;
}
signTargets.push({
file,
needsRuntime: needsHardenedRuntime(fileKind),
});
}
signTargets.sort((left, right) => left.file.localeCompare(right.file));
for (const target of signTargets) {
let entitlementsFile = null;
if (target.needsRuntime) {
const entitlements = extractEntitlements(target.file);
if (entitlements) {
entitlementsFile = `${target.file}.entitlements.plist`;
await fs.writeFile(entitlementsFile, entitlements, "utf8");
}
}
const args = [
"--force",
"--sign",
signingIdentity,
"--timestamp",
];
if (target.needsRuntime) {
args.push("--options", "runtime");
if (entitlementsFile) {
args.push("--entitlements", entitlementsFile);
}
}
args.push(target.file);
console.log(`[managed-runtime-sign] ${path.relative(repoRoot, target.file)}`);
run("codesign", args);
if (entitlementsFile) {
await fs.unlink(entitlementsFile);
}
}
}
await main();

View File

@@ -1,82 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const desktopRoot = fileURLToPath(new URL("..", import.meta.url));
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const expectedVersion = desktopPackageJson.version;
const currentRuntime = JSON.parse(
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
);
assert.equal(currentRuntime.runtimeVersion, expectedVersion, "current-runtime.json version mismatch");
assert.ok(currentRuntime.runtimeId, "current-runtime.json missing runtimeId");
assert.ok(currentRuntime.relativeRoot, "current-runtime.json missing relativeRoot");
const runtimeRoot = path.join(resourcesRoot, currentRuntime.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
assert.equal(manifest.runtimeId, currentRuntime.runtimeId, "manifest runtimeId mismatch");
assert.equal(manifest.runtimeVersion, expectedVersion, "manifest version mismatch");
assert.ok(manifest.nodeRelativePath, "manifest missing nodeRelativePath");
assert.ok(manifest.cliEntrypointRelativePath, "manifest missing cliEntrypointRelativePath");
assert.ok(manifest.serverRunnerRelativePath, "manifest missing serverRunnerRelativePath");
const nodeBinary = path.join(runtimeRoot, manifest.nodeRelativePath);
await fs.access(nodeBinary).catch(() => {
throw new Error(`Bundled Node binary not found: ${nodeBinary}`);
});
const cliEntry = path.join(runtimeRoot, manifest.cliEntrypointRelativePath);
await fs.access(cliEntry).catch(() => {
throw new Error(`CLI entrypoint not found: ${cliEntry}`);
});
const serverRunner = path.join(runtimeRoot, manifest.serverRunnerRelativePath);
await fs.access(serverRunner).catch(() => {
throw new Error(`Server runner not found: ${serverRunner}`);
});
const runtimePackageJson = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "package.json"), "utf8")
);
assert.equal(runtimePackageJson.version, expectedVersion, "runtime package.json version mismatch");
for (const pkg of ["@getpaseo/relay", "@getpaseo/server", "@getpaseo/cli"]) {
const pkgDir = path.join(runtimeRoot, "node_modules", ...pkg.split("/"));
await fs.access(pkgDir).catch(() => {
throw new Error(`Missing bundled dependency: ${pkg} (expected at ${pkgDir})`);
});
}
const sherpaPlatformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const sherpaPlatform = sherpaPlatformMap[manifest.platform];
assert.ok(
sherpaPlatform,
`Unsupported sherpa platform mapping for managed runtime validation: ${manifest.platform}`
);
const sherpaNativePackage = `sherpa-onnx-${sherpaPlatform}-${manifest.arch}`;
const sherpaNativePackageDir = path.join(runtimeRoot, "node_modules", sherpaNativePackage);
await fs.access(sherpaNativePackageDir).catch(() => {
throw new Error(
`Missing bundled native speech dependency: ${sherpaNativePackage} (expected at ${sherpaNativePackageDir})`
);
});
console.log(`[validate-managed-runtime] PASS`);
console.log(` runtimeId: ${manifest.runtimeId}`);
console.log(` version: ${expectedVersion}`);
console.log(` platform: ${manifest.platform}`);
console.log(` arch: ${manifest.arch}`);
console.log(` sherpaNativePackage: ${sherpaNativePackage}`);

View File

@@ -0,0 +1,332 @@
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { chromium } from "playwright";
const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
const REQUIRED_DESKTOP_KEYS = [
"invoke",
"events",
"window",
"dialog",
"notification",
"opener",
];
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
async function captureScreenshot(page, fileName) {
const filePath = path.join(OUTPUT_DIR, fileName);
await page.screenshot({ path: filePath, fullPage: true });
return filePath;
}
async function findAppPage(browser) {
for (let attempt = 0; attempt < 30; attempt += 1) {
for (const context of browser.contexts()) {
for (const page of context.pages()) {
if (page.url().includes(APP_URL_FRAGMENT) && !page.url().startsWith("devtools://")) {
return page;
}
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Unable to find Electron app page for ${APP_URL_FRAGMENT}`);
}
async function main() {
await ensureDir(OUTPUT_DIR);
const browser = await chromium.connectOverCDP(CDP_URL);
const page = await findAppPage(browser);
const consoleMessages = [];
const results = [];
page.on("console", (message) => {
consoleMessages.push({
type: message.type(),
text: message.text(),
});
});
page.on("pageerror", (error) => {
consoleMessages.push({
type: "pageerror",
text: String(error),
});
});
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(1000);
if (!page.url().endsWith("/welcome")) {
await page.goto(`http://${APP_URL_FRAGMENT}/welcome`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1000);
}
const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png");
const desktopDetection = await page.evaluate(() => {
const bridge = window.paseoDesktop;
const keys = bridge && typeof bridge === "object" ? Object.keys(bridge) : [];
const keyTypes = bridge && typeof bridge === "object"
? Object.fromEntries(
Object.entries(bridge).map(([key, value]) => [key, typeof value])
)
: {};
return {
exists: Boolean(bridge && typeof bridge === "object"),
keys,
keyTypes,
platform: bridge?.platform ?? null,
};
});
const hasExpectedDesktopShape =
desktopDetection.exists &&
REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key));
results.push({
check: "desktop-detection",
pass: hasExpectedDesktopShape,
details: desktopDetection,
screenshot: welcomeScreenshot,
});
const desktopStatus = await page.evaluate(() => window.paseoDesktop.invoke("desktop_daemon_status"));
assert(
typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0,
"desktop_daemon_status did not return a serverId"
);
const serverId = desktopStatus.serverId.trim();
await page.evaluate((nextServerId) => {
window.location.href = `/h/${nextServerId}/settings`;
}, serverId);
await page.waitForURL(new RegExp(`/h/${escapeRegExp(serverId)}/settings$`), {
timeout: 30_000,
});
await page.getByText("Daemon management", { exact: true }).waitFor({
timeout: 30_000,
});
const settingsScreenshot = await captureScreenshot(page, "02-settings-page.png");
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first();
const menuToggle = page.locator('[data-testid="menu-button"]').first();
if (
(await sidebarSettingsButton.isVisible().catch(() => false)) &&
(await menuToggle.isVisible().catch(() => false))
) {
await menuToggle.click();
await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(500);
}
const dragRegionCheck = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll("*"));
const annotationId = "electron-verify-drag-style";
const existingAnnotation = document.getElementById(annotationId);
existingAnnotation?.remove();
const annotationStyle = document.createElement("style");
annotationStyle.id = annotationId;
annotationStyle.textContent = `
[data-electron-verify-drag="true"] {
outline: 3px solid #ff4d4f !important;
outline-offset: -3px !important;
}
`;
document.head.appendChild(annotationStyle);
function isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}
function summarizeText(element) {
return (element.textContent ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
}
const regions = [];
let candidate = null;
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion =
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
const text = summarizeText(element);
const info = {
tagName: element.tagName.toLowerCase(),
text,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
paddingTop: Number.parseFloat(style.paddingTop || "0"),
};
regions.push(info);
const looksLikeHeader =
rect.top < 180 &&
rect.height >= 40 &&
(text.includes("Settings") || text.includes("Sessions"));
if (!candidate && looksLikeHeader) {
candidate = { ...info };
element.setAttribute("data-electron-verify-drag", "true");
}
}
if (!candidate && regions.length > 0) {
candidate = regions
.slice()
.sort((left, right) => left.top - right.top)[0];
for (const element of nodes) {
if (!(element instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(element);
const appRegion =
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
if (appRegion !== "drag" || !isVisible(element)) {
continue;
}
const rect = element.getBoundingClientRect();
if (
Math.abs(rect.top - candidate.top) < 1 &&
Math.abs(rect.left - candidate.left) < 1 &&
Math.abs(rect.width - candidate.width) < 1 &&
Math.abs(rect.height - candidate.height) < 1
) {
element.setAttribute("data-electron-verify-drag", "true");
break;
}
}
}
return {
count: regions.length,
candidate,
regions: regions.slice(0, 10),
};
});
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
const dragRegionPassed =
dragRegionCheck.count > 0 &&
Boolean(dragRegionCheck.candidate) &&
dragRegionCheck.candidate.top < 180;
results.push({
check: "drag-regions",
pass: dragRegionPassed,
details: dragRegionCheck,
screenshot: dragScreenshot,
});
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
const isMac = process.platform === "darwin";
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
const trafficLightPaddingPassed = !isMac
? true
: typeof observedPaddingLeft === "number" &&
observedPaddingLeft >= 78 &&
observedPaddingLeft <= 110;
results.push({
check: "traffic-light-padding",
pass: trafficLightPaddingPassed,
details: {
platform: process.platform,
observedPaddingLeft,
expectedApproximatePaddingLeft: 78,
candidate: dragRegionCheck.candidate,
},
screenshot: trafficLightScreenshot,
});
const daemonManagementVisible = await Promise.all([
page.getByText("Built-in daemon", { exact: true }).isVisible(),
page.getByText("Daemon management", { exact: true }).isVisible(),
page.getByRole("button", { name: "Restart daemon" }).first().isVisible(),
]).then((values) => values.every(Boolean));
const daemonManagementScreenshot = await captureScreenshot(
page,
"05-settings-daemon-management.png"
);
results.push({
check: "settings-daemon-management",
pass: daemonManagementVisible,
details: {
route: page.url(),
serverId,
desktopStatus,
},
screenshot: daemonManagementScreenshot,
});
const desktopDetectionScreenshot = await captureScreenshot(
page,
"06-desktop-detection.png"
);
results[0].screenshot = desktopDetectionScreenshot;
const report = {
cdpUrl: CDP_URL,
outputDir: OUTPUT_DIR,
pageUrl: page.url(),
desktopStatus,
results,
consoleMessages,
};
const reportPath = path.join(OUTPUT_DIR, "report.json");
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
const failedChecks = results.filter((result) => !result.pass);
console.log(JSON.stringify(report, null, 2));
await browser.close();
if (failedChecks.length > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -1,4 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
[package]
name = "paseo"
version = "0.1.30"
description = "Paseo Desktop"
authors = ["moboudra"]
license = "MIT"
repository = "https://github.com/getpaseo/paseo"
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "paseo_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "Paseo"
path = "src/main.rs"
[build-dependencies]
cc = "1"
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
base64 = "0.22"
dirs = "6"
dunce = "1"
futures-util = "0.3"
http = "1"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-websocket = "2"
tokio = { version = "1", features = ["net", "sync"] }
tokio-tungstenite = "0.24"

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ApplePressAndHoldEnabled</key>
<false/>
<key>CFBundleIdentifier</key>
<string>dev.paseo.desktop</string>
<key>CFBundleName</key>
<string>Paseo</string>
<key>CFBundleShortVersionString</key>
<string>0.1.30</string>
<key>CFBundleVersion</key>
<string>0.1.30</string>
<key>NSMicrophoneUsageDescription</key>
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
</dict>
</plist>

View File

@@ -1,17 +0,0 @@
fn main() {
#[cfg(target_os = "macos")]
{
println!("cargo:rerun-if-changed=macos/paseo_notifications.h");
println!("cargo:rerun-if-changed=macos/paseo_notifications.m");
cc::Build::new()
.file("macos/paseo_notifications.m")
.flag("-fobjc-arc")
.compile("paseo_notifications");
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=UserNotifications");
}
tauri_build::build()
}

View File

@@ -1,25 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"remote": {
"urls": [
"http://localhost:8081/*",
"http://127.0.0.1:8081/*"
]
},
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-badge-count",
"core:window:allow-toggle-maximize",
"dialog:default",
"notification:default",
"opener:default",
"updater:default",
"websocket:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Some files were not shown because too many files have changed in this diff Show More