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
+129 -412
View File
@@ -3,21 +3,21 @@ name: Desktop Release
on: on:
push: push:
tags: tags:
- "v*" - 'v*'
- "desktop-v*" - 'desktop-v*'
- "desktop-macos-v*" - 'desktop-macos-v*'
- "desktop-linux-v*" - 'desktop-linux-v*'
- "desktop-windows-v*" - 'desktop-windows-v*'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: "Existing tag to build (e.g. v0.1.0)" description: 'Existing tag to build (e.g. v0.1.0)'
required: true required: true
type: string type: string
platform: platform:
description: "Optional desktop platform to build." description: 'Optional desktop platform to build.'
required: false required: false
default: "all" default: 'all'
type: choice type: choice
options: options:
- all - all
@@ -31,6 +31,8 @@ concurrency:
env: env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} 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: jobs:
publish-macos: publish-macos:
@@ -40,9 +42,9 @@ jobs:
matrix: matrix:
include: include:
- runner: macos-14 - runner: macos-14
rust_target: aarch64-apple-darwin electron_arch: arm64
- runner: macos-15-intel - runner: macos-15-intel
rust_target: x86_64-apple-darwin electron_arch: x64
permissions: permissions:
contents: write contents: write
packages: read packages: read
@@ -75,85 +77,39 @@ jobs:
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV" echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi 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 - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: '22'
cache: "npm" cache: 'npm'
cache-dependency-path: package-lock.json cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com" registry-url: 'https://npm.pkg.github.com'
scope: "@boudra" 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
- name: Install JS dependencies - name: Install JS dependencies
run: npm ci run: npm ci
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 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 - name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true' if: env.IS_SMOKE_TAG != 'true'
env: env:
@@ -162,76 +118,36 @@ jobs:
run: | run: |
set -euo pipefail 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="$(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 else
release_draft="false" release_type="release"
fi fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV" echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build and publish macOS Tauri release - name: Build desktop 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 }}
shell: bash 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: | run: |
set -euo pipefail set -euo pipefail
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}' publish_mode="never"
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))') publish_args=()
if [ -z "$dmg_path" ]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
echo "::error::No DMG found in tauri build artifacts" publish_mode="always"
exit 1 publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi fi
echo "DMG: $dmg_path"
echo "Signing DMG..." npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
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
publish-linux: 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'))) }} 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" echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi 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 - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: '22'
cache: "npm" cache: 'npm'
cache-dependency-path: package-lock.json cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com" registry-url: 'https://npm.pkg.github.com'
scope: "@boudra" 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
- name: Install JS dependencies - name: Install JS dependencies
run: npm ci run: npm ci
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri - name: Set desktop package version from tag
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
shell: bash shell: bash
run: | run: |
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true node <<'NODE'
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy const fs = require('node:fs');
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference) const path = require('node:path');
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 const version = process.env.DESKTOP_VERSION;
echo "Removing needed $lib from $f" if (!version) throw new Error('DESKTOP_VERSION env var is missing');
patchelf --remove-needed "$lib" "$f"
done const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
done 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 - name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true' if: env.IS_SMOKE_TAG != 'true'
@@ -359,141 +224,30 @@ jobs:
run: | run: |
set -euo pipefail 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="$(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 else
release_draft="false" release_type="release"
fi fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV" echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build Linux Tauri release - name: Build desktop 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"
shell: bash 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: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob publish_mode="never"
assets=( publish_args=()
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage if [[ "$IS_SMOKE_TAG" != "true" ]]; then
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig publish_mode="always"
) publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No Linux AppImage assets were produced."
exit 1
fi 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) npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
publish-windows: 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'))) }} 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" echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi 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 - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: '22'
cache: "npm" cache: 'npm'
cache-dependency-path: package-lock.json cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com" registry-url: 'https://npm.pkg.github.com'
scope: "@boudra" 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
- name: Install JS dependencies - name: Install JS dependencies
run: npm ci run: npm ci
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 shell: pwsh
run: | run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName $patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath" $env:NODE_OPTIONS = "--require=$patchPath"
npm run build:web --workspace=@getpaseo/app 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 - name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true' if: env.IS_SMOKE_TAG != 'true'
env: env:
@@ -607,31 +328,27 @@ jobs:
run: | run: |
set -euo pipefail 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="$(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 else
release_draft="false" release_type="release"
fi fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV" echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build and publish Windows Tauri release - name: Build desktop release
if: env.IS_SMOKE_TAG != 'true' shell: bash
uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} set -euo pipefail
with: publish_mode="never"
projectPath: packages/desktop publish_args=()
tagName: ${{ env.RELEASE_TAG }} if [[ "$IS_SMOKE_TAG" != "true" ]]; then
releaseName: Paseo ${{ env.RELEASE_TAG }} publish_mode="always"
releaseBody: See the assets to download and install this version. publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
releaseDraft: ${{ env.RELEASE_DRAFT }} fi
prerelease: false
args: --bundles nsis
- name: Build Windows app (smoke only) npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
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 }}
+3
View File
@@ -46,6 +46,9 @@ test-results/
# Vercel # Vercel
.vercel/ .vercel/
# Expo
.expo/
# Misc # Misc
*.pem *.pem
.vercel .vercel
+3 -3
View File
@@ -166,7 +166,7 @@
- Redesigned the website get-started experience into a clearer two-step flow. - Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings. - Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging. - 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. - Hid empty filter groups in the left sidebar.
### Fixed ### Fixed
@@ -188,7 +188,7 @@
- Improved new worktree-agent defaults by prefilling CWD to the main repository. - Improved new worktree-agent defaults by prefilling CWD to the main repository.
- Improved desktop command autocomplete behavior to match combobox interactions. - 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 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. - Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed ### Fixed
@@ -227,7 +227,7 @@
- Fixed stuck "send while running" recovery across app and server session handling. - Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents. - Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions. - 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. - Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16 ## 0.1.7 - 2026-02-16
+1 -1
View File
@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
- `packages/app` — Mobile + web client (Expo) - `packages/app` — Mobile + web client (Expo)
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`) - `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
- `packages/relay` — E2E encrypted relay for remote access - `packages/relay` — E2E encrypted relay for remote access
- `packages/desktop` — Tauri desktop wrapper - `packages/desktop` — Electron desktop wrapper
- `packages/website` — Marketing site (paseo.sh) - `packages/website` — Marketing site (paseo.sh)
## Documentation ## Documentation
+1 -1
View File
@@ -44,7 +44,7 @@ Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server) - `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web) - `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows - `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/relay`: Relay package for remote connectivity
- `packages/website`: Marketing site and documentation (`paseo.sh`) - `packages/website`: Marketing site and documentation (`paseo.sh`)
+4 -4
View File
@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
``` ```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │ │ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Tauri) │ │ (Expo) │ │ (Commander) │ │ (Electron) │
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘ └──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │ │ │
│ WebSocket │ WebSocket │ Managed subprocess │ 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. 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 - Can spawn the daemon as a managed subprocess
- Native file access for workspace integration - Native file access for workspace integration
@@ -180,5 +180,5 @@ $PASEO_HOME/
## Deployment models ## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` 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 3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
-192
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** |
-341
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
+2
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 - `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 - `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag - 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 ## 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 - `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types - `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 - 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 - Website Mac download CTA URL derives from `packages/website/package.json` version at build time
-275
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
+2916 -278
View File
File diff suppressed because it is too large Load Diff
-1
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" "release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"knip": "^5.82.1", "knip": "^5.82.1",
+1 -8
View File
@@ -2,17 +2,10 @@
import { polyfillCrypto } from "./src/polyfills/crypto"; import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto(); 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"; import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation(); 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. // Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles"; import "./src/styles/unistyles";
import "expo-router/entry"; import "expo-router/entry";
+1 -5
View File
@@ -16,7 +16,6 @@
"ios": "expo run:ios", "ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release", "ios:release": "expo run:ios --configuration Release",
"web": "expo start --web", "web": "expo start --web",
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
"lint": "expo lint", "lint": "expo lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -24,7 +23,6 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"build": "npm run build:web", "build": "npm run build:web",
"build:web": "expo export --platform 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" "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
}, },
"dependencies": { "dependencies": {
@@ -60,9 +58,7 @@
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1", "@xterm/addon-fit": "^0.11.0",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
+54
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>
+46 -29
View File
@@ -53,11 +53,10 @@ import {
HorizontalScrollProvider, HorizontalScrollProvider,
useHorizontalScrollOptional, useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context"; } from "@/contexts/horizontal-scroll-context";
import { getIsTauri } from "@/constants/layout"; import { getIsDesktop } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center"; import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client"; import { queryClient } from "@/query/query-client";
import { import {
@@ -65,19 +64,18 @@ import {
type WebNotificationClickDetail, type WebNotificationClickDetail,
ensureOsNotificationPermission, ensureOsNotificationPermission,
} from "@/utils/os-notifications"; } from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildNotificationRoute } from "@/utils/notification-routing";
import { import {
buildHostRootRoute, buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname, parseServerIdFromPathname,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { attachConsole } from "@/utils/tauri-attach-console";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto(); polyfillCrypto();
attachConsole();
const HostRuntimeBootstrapContext = createContext(false); const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() { function PushNotificationRouter() {
@@ -86,33 +84,37 @@ function PushNotificationRouter() {
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (getTauri()) { let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsDesktop()) {
void ensureOsNotificationPermission(); void ensureOsNotificationPermission();
let disposed = false; const unlistenResult = getDesktopHost()?.events?.on?.(
let unlisten: (() => void) | null = null; "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) => { void Promise.resolve(unlistenResult).then((unlisten) => {
router.push(buildNotificationRoute(payload.data) as any); if (typeof unlisten !== "function") {
}) return;
.then((cleanup) => { }
if (disposed) { if (cancelled) {
cleanup(); unlisten();
return; return;
} }
unlisten = cleanup; removeDesktopNotificationListener = unlisten;
}) });
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
} }
const target = globalThis as unknown as EventTarget; const target = globalThis as unknown as EventTarget;
@@ -126,7 +128,10 @@ function PushNotificationRouter() {
WEB_NOTIFICATION_CLICK_EVENT, WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener openFromWebClick as EventListener
); );
return () => { return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener( target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT, WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener openFromWebClick as EventListener
@@ -476,12 +481,24 @@ function OfferLinkListener({
} }
function AppWithSidebar({ children }: { children: ReactNode }) { function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>(); const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
useFaviconStatus(); useFaviconStatus();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null; 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 // Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes // useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => { const selectedAgentKey = useMemo(() => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore, useState } from 'react' import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router' import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime' 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 { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen' import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen' import { WelcomeScreen } from '@/components/welcome-screen'
@@ -57,7 +57,7 @@ export default function Index() {
const pathname = usePathname() const pathname = usePathname()
const daemons = useHosts() const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false) const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseManagedDesktopDaemon() const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId)) const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
+2 -2
View File
@@ -1,12 +1,12 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { isTauriEnvironment } from "@/utils/tauri"; import { isDesktop } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types"; import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null; let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> { async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (isTauriEnvironment()) { if (isDesktop()) {
const { createDesktopAttachmentStore } = await import( const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store" "../desktop/attachments/desktop-attachment-store"
); );
@@ -6,9 +6,9 @@ import {
HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE, HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE, HEADER_TOP_PADDING_MOBILE,
getIsTauriMac, getIsDesktopMac,
} from "@/constants/layout"; } from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
interface ScreenHeaderProps { interface ScreenHeaderProps {
@@ -37,18 +37,21 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2]; const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset = const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsTauriMac() !isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left ? trafficLightPadding.left
: 0; : 0;
// On Tauri macOS, enable window dragging and double-click to maximize const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
const dragHandlers = useTauriDragHandlers();
return ( return (
<View style={styles.header}> <View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}> <View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View <View
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]} style={[
styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
dragRegionStyle,
]}
{...dragHandlers} {...dragHandlers}
> >
<View style={[styles.left, leftStyle]}>{left}</View> <View style={[styles.left, leftStyle]}>{left}</View>
@@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { getIsTauri } from "@/constants/layout"; import { getIsDesktop } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -13,10 +13,10 @@ export function KeyboardShortcutsDialog() {
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac"; const isMac = getShortcutOs() === "mac";
const isTauri = getIsTauri(); const isDesktopApp = getIsDesktop();
const sections = useMemo( const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }), () => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isMac, isTauri] [isDesktopApp, isMac]
); );
return ( return (
+4 -4
View File
@@ -20,7 +20,7 @@ import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model' import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list' import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' 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 { Combobox } from '@/components/ui/combobox'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime' import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons' import { formatConnectionStatus } from '@/utils/daemons'
@@ -130,7 +130,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
isGesturing, isGesturing,
closeGestureRef, closeGestureRef,
} = useSidebarAnimation() } = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers() const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding() const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0) const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0) const closeTouchStartY = useSharedValue(0)
@@ -448,9 +448,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
return ( return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}> <View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{trafficLightPadding.top > 0 ? ( {trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} /> <View style={[{ height: trafficLightPadding.top }, dragRegionStyle]} {...dragHandlers} />
) : null} ) : null}
<View style={styles.sidebarHeader} {...dragHandlers}> <View style={[styles.sidebarHeader, dragRegionStyle]} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<Pressable <Pressable
style={styles.newAgentButton} style={styles.newAgentButton}
@@ -30,7 +30,7 @@ import { NestableScrollContainer } from 'react-native-draggable-flatlist'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import type { DraggableListDragHandleProps } from './draggable-list.types' import type { DraggableListDragHandleProps } from './draggable-list.types'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' 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 { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import { import {
buildHostWorkspaceRouteWithOpenIntent, buildHostWorkspaceRouteWithOpenIntent,
@@ -1407,10 +1407,10 @@ export function SidebarWorkspaceList({
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>( const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map() new Map()
) )
const isTauri = getIsTauri() const isDesktopApp = getIsDesktop()
const altDown = useKeyboardShortcutsStore((state) => state.altDown) const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown) const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown) const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown)
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder) const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder) const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
+25 -29
View File
@@ -1,5 +1,5 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri"; import { isDesktop, isDesktopMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75; 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) // Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820; 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 // These buttons (close/minimize/maximize) overlay the top-left corner
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78; export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56; export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Check if running in Tauri desktop app (any OS) // Check if running in desktop app (any OS)
function isTauri(): boolean { function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false; if (Platform.OS !== "web") return false;
return getTauri() !== null; return isDesktop();
} }
// Check if running in Tauri desktop app on macOS // Check if running in desktop host on macOS
function isTauriMac(): boolean { function isDesktopEnvironmentMac(): boolean {
if (Platform.OS !== "web") return false; if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false; return isDesktopMac();
if (getTauri() === null) return false;
// Check for macOS via user agent
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
} }
// Cached result - only cache true, keep checking if false (in case Tauri globals load later) // Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isTauriMacCached: boolean | null = null; let _isDesktopMacCached: boolean | null = null;
let _isTauriCached: boolean | null = null; let _isDesktopCached: boolean | null = null;
export function getIsTauriMac(): boolean { export function getIsDesktopMac(): boolean {
if (_isTauriMacCached === true) { if (_isDesktopMacCached === true) {
return true; return true;
} }
const result = isTauriMac(); const result = isDesktopEnvironmentMac();
if (result) { if (result) {
_isTauriMacCached = true; _isDesktopMacCached = true;
} }
return result; return result;
} }
export function getIsTauri(): boolean { export function getIsDesktop(): boolean {
if (_isTauriCached === true) { if (_isDesktopCached === true) {
return true; return true;
} }
const result = isTauri(); const result = isDesktopEnvironment();
if (result) { if (result) {
_isTauriCached = true; _isDesktopCached = true;
} }
return result; 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 } { export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsTauriMac()) { if (!getIsDesktopMac()) {
return { left: 0, top: 0 }; return { left: 0, top: 0 };
} }
return { return {
left: TAURI_TRAFFIC_LIGHT_WIDTH, left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: TAURI_TRAFFIC_LIGHT_HEIGHT, top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
}; };
} }
@@ -1,4 +1,4 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command"; import { invokeDesktopCommand } from "@/desktop/electron/invoke";
interface AttachmentFileResult { interface AttachmentFileResult {
path: string; path: string;
@@ -4,7 +4,7 @@ const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="), invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
})); }));
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({ vi.mock("@/desktop/electron/invoke", () => ({
invokeDesktopCommand: invokeDesktopCommandMock, invokeDesktopCommand: invokeDesktopCommandMock,
})); }));
@@ -1,6 +1,6 @@
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { fileUriToPath } from "@/attachments/utils"; import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command"; import { invokeDesktopCommand } from "@/desktop/electron/invoke";
function base64ToUint8Array(base64: string): Uint8Array { function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64); const binary = atob(base64);
@@ -20,21 +20,21 @@ import { Button } from '@/components/ui/button'
import { useAppSettings } from '@/hooks/use-settings' import { useAppSettings } from '@/hooks/use-settings'
import { confirmDialog } from '@/utils/confirm-dialog' import { confirmDialog } from '@/utils/confirm-dialog'
import { openExternalUrl } from '@/utils/open-external-url' import { openExternalUrl } from '@/utils/open-external-url'
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates' import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import { import {
getCliSymlinkInstructions, getCliSymlinkInstructions,
getManagedDaemonLogs, getDesktopDaemonLogs,
getManagedDaemonPairing, getDesktopDaemonPairing,
getManagedDaemonStatus, getDesktopDaemonStatus,
restartManagedDaemon, restartDesktopDaemon,
shouldUseManagedDesktopDaemon, shouldUseDesktopDaemon,
startManagedDaemon, startDesktopDaemon,
stopManagedDaemon, stopDesktopDaemon,
type CliSymlinkInstructions, type CliSymlinkInstructions,
type ManagedDaemonLogs, type DesktopDaemonLogs,
type ManagedPairingOffer, type DesktopDaemonStatus,
type ManagedDaemonStatus, type DesktopPairingOffer,
} from '@/desktop/managed-runtime/managed-runtime' } from '@/desktop/daemon/desktop-daemon'
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
appVersion: string | null appVersion: string | null
@@ -46,33 +46,35 @@ export function LocalDaemonSection({
showLifecycleControls, showLifecycleControls,
}: LocalDaemonSectionProps) { }: LocalDaemonSectionProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon() const showSection = shouldUseDesktopDaemon()
const { settings, updateSettings } = useAppSettings() 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 [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false) const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false) const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false) const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null) const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = 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 [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false) const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false) const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false) const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null) const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
const [cliSymlinkInstructions, setCliSymlinkInstructions] = const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null) useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null) const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => { const loadDaemonData = useCallback(() => {
if (!showSection) { if (!showSection) {
return Promise.resolve() return Promise.resolve()
} }
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()]) return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs]) => { .then(([status, logs, version]) => {
setManagedStatus(status) setDaemonStatus(status)
setManagedLogs(logs) setDaemonLogs(logs)
setDaemonVersion(version.version)
setStatusError(null) setStatusError(null)
}) })
.catch((error) => { .catch((error) => {
@@ -86,35 +88,31 @@ export function LocalDaemonSection({
if (!showSection) { if (!showSection) {
return undefined return undefined
} }
void loadManagedStatus() void loadDaemonData()
return undefined return undefined
}, [loadManagedStatus, showSection]) }, [loadDaemonData, showSection])
) )
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null) const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
const daemonStatusStateText = const daemonStatusStateText =
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running') statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}` const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
const isDaemonManagementPaused = !settings.manageBuiltInDaemon const isDaemonManagementPaused = !settings.manageBuiltInDaemon
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon' const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionMessage = const daemonActionMessage =
managedStatus?.status === 'running' daemonStatus?.status === 'running'
? 'Restarts the built-in daemon.' ? 'Restarts the built-in daemon.'
: 'Starts the built-in daemon.' : 'Starts the built-in daemon.'
const handleUpdateLocalDaemon = useCallback(() => { const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) { if (!showSection || isRestartingDaemon) {
return
}
if (isRestartingDaemon) {
return return
} }
void confirmDialog({ void confirmDialog({
title: daemonActionLabel, title: daemonActionLabel,
message: message:
managedStatus?.status === 'running' daemonStatus?.status === 'running'
? 'This will restart the built-in daemon. The app will reconnect automatically.' ? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.', : 'This will start the built-in daemon.',
confirmLabel: daemonActionLabel, confirmLabel: daemonActionLabel,
@@ -128,19 +126,18 @@ export function LocalDaemonSection({
setIsRestartingDaemon(true) setIsRestartingDaemon(true)
setStatusMessage(null) setStatusMessage(null)
const action = const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
void action() void action()
.then((status) => { .then((status) => {
setManagedStatus(status) setDaemonStatus(status)
setStatusMessage( setStatusMessage(
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.' daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
) )
return loadManagedStatus() return loadDaemonData()
}) })
.catch((error) => { .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) const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`) setStatusMessage(`${daemonActionLabel} failed: ${message}`)
}) })
@@ -149,10 +146,10 @@ export function LocalDaemonSection({
}) })
}) })
.catch((error) => { .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.') 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(() => { const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) { if (isUpdatingDaemonManagement) {
@@ -193,13 +190,13 @@ export function LocalDaemonSection({
setStatusMessage(null) setStatusMessage(null)
const stopPromise = const stopPromise =
managedStatus?.status === 'running' daemonStatus?.status === 'running'
? stopManagedDaemon() ? stopDesktopDaemon()
: Promise.resolve(managedStatus ?? null) : Promise.resolve(daemonStatus ?? null)
void stopPromise void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false })) .then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus()) .then(() => loadDaemonData())
.then(() => { .then(() => {
setStatusMessage('Built-in daemon paused and stopped.') setStatusMessage('Built-in daemon paused and stopped.')
}) })
@@ -216,9 +213,9 @@ export function LocalDaemonSection({
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.') Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
}) })
}, [ }, [
daemonStatus,
isUpdatingDaemonManagement, isUpdatingDaemonManagement,
loadManagedStatus, loadDaemonData,
managedStatus,
settings.manageBuiltInDaemon, settings.manageBuiltInDaemon,
updateSettings, updateSettings,
]) ])
@@ -258,7 +255,7 @@ export function LocalDaemonSection({
}, [cliSymlinkInstructions?.commands]) }, [cliSymlinkInstructions?.commands])
const handleCopyLogPath = useCallback(() => { const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath const logPath = daemonLogs?.logPath
if (!logPath) { if (!logPath) {
return return
} }
@@ -271,14 +268,14 @@ export function LocalDaemonSection({
console.error('[Settings] Failed to copy log path', error) console.error('[Settings] Failed to copy log path', error)
Alert.alert('Error', 'Unable to copy log path.') Alert.alert('Error', 'Unable to copy log path.')
}) })
}, [managedLogs?.logPath]) }, [daemonLogs?.logPath])
const handleOpenLogs = useCallback(() => { const handleOpenLogs = useCallback(() => {
if (!managedLogs) { if (!daemonLogs) {
return return
} }
setIsLogsModalOpen(true) setIsLogsModalOpen(true)
}, [managedLogs]) }, [daemonLogs])
const handleOpenPairingModal = useCallback(() => { const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) { if (isLoadingPairing) {
@@ -289,7 +286,7 @@ export function LocalDaemonSection({
setIsLoadingPairing(true) setIsLoadingPairing(true)
setPairingStatusMessage(null) setPairingStatusMessage(null)
void getManagedDaemonPairing() void getDesktopDaemonPairing()
.then((pairing) => { .then((pairing) => {
setPairingOffer(pairing) setPairingOffer(pairing)
if (!pairing.relayEnabled || !pairing.url) { if (!pairing.relayEnabled || !pairing.url) {
@@ -344,7 +341,7 @@ export function LocalDaemonSection({
<View style={styles.row}> <View style={styles.row}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text> <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>
<View style={styles.statusValueGroup}> <View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text> <Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -398,7 +395,7 @@ export function LocalDaemonSection({
disabled={isRestartingDaemon} disabled={isRestartingDaemon}
> >
{isRestartingDaemon {isRestartingDaemon
? managedStatus?.status === 'running' ? daemonStatus?.status === 'running'
? 'Restarting...' ? 'Restarting...'
: 'Starting...' : 'Starting...'
: daemonActionLabel} : daemonActionLabel}
@@ -425,10 +422,10 @@ export function LocalDaemonSection({
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text> <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>
<View style={styles.actionGroup}> <View style={styles.actionGroup}>
{managedLogs?.logPath ? ( {daemonLogs?.logPath ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -443,7 +440,7 @@ export function LocalDaemonSection({
size="sm" size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />} leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs} onPress={handleOpenLogs}
disabled={!managedLogs} disabled={!daemonLogs}
> >
Open logs Open logs
</Button> </Button>
@@ -523,9 +520,9 @@ export function LocalDaemonSection({
snapPoints={['70%', '92%']} snapPoints={['70%', '92%']}
> >
<View style={styles.modalBody}> <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> <Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'} {daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
</Text> </Text>
</View> </View>
</AdaptiveModalSheet> </AdaptiveModalSheet>
@@ -537,7 +534,7 @@ const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
function PairingOfferDialogContent(input: { function PairingOfferDialogContent(input: {
isLoading: boolean isLoading: boolean
pairingOffer: ManagedPairingOffer | null pairingOffer: DesktopPairingOffer | null
statusMessage: string | null statusMessage: string | null
onCopyLink: () => void onCopyLink: () => void
}) { }) {
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
const managedRuntimeMock = vi.hoisted(() => { const desktopDaemonMock = vi.hoisted(() => {
let eventHandler: ((payload: { let eventHandler: ((payload: {
sessionId: string; sessionId: string;
kind: "open" | "message" | "close" | "error"; kind: "open" | "message" | "close" | "error";
@@ -40,32 +40,32 @@ const managedRuntimeMock = vi.hoisted(() => {
}; };
}); });
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({ vi.mock("./desktop-daemon", () => ({
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession, openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents, listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage, sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession, closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
})); }));
describe("managed-tauri-daemon-transport", () => { describe("desktop-daemon-transport", () => {
beforeEach(() => { beforeEach(() => {
managedRuntimeMock.openLocalTransportSession.mockReset(); desktopDaemonMock.openLocalTransportSession.mockReset();
managedRuntimeMock.listenToLocalTransportEvents.mockClear(); desktopDaemonMock.listenToLocalTransportEvents.mockClear();
managedRuntimeMock.sendLocalTransportMessage.mockClear(); desktopDaemonMock.sendLocalTransportMessage.mockClear();
managedRuntimeMock.closeLocalTransportSession.mockClear(); desktopDaemonMock.closeLocalTransportSession.mockClear();
}); });
it("emits open after the session resolves even if the rust open event raced earlier", async () => { it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void; let resolveSession!: (sessionId: string) => void;
managedRuntimeMock.openLocalTransportSession.mockImplementation( desktopDaemonMock.openLocalTransportSession.mockImplementation(
() => () =>
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
resolveSession = resolve; resolveSession = resolve;
}) })
); );
const mod = await import("./managed-tauri-daemon-transport"); const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory(); const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull(); expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ const transport = transportFactory!({
@@ -75,7 +75,7 @@ describe("managed-tauri-daemon-transport", () => {
const onOpen = vi.fn(); const onOpen = vi.fn();
transport.onOpen(onOpen); transport.onOpen(onOpen);
managedRuntimeMock.emitEvent({ desktopDaemonMock.emitEvent({
sessionId: "local-session-1", sessionId: "local-session-1",
kind: "open", kind: "open",
}); });
@@ -93,21 +93,21 @@ describe("managed-tauri-daemon-transport", () => {
let resolveListen!: (cleanup: () => void) => void; let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn(); const cleanup = vi.fn();
managedRuntimeMock.openLocalTransportSession.mockImplementation( desktopDaemonMock.openLocalTransportSession.mockImplementation(
() => () =>
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
resolveSession = resolve; resolveSession = resolve;
}) })
); );
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation( desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
() => () =>
new Promise<() => void>((resolve) => { new Promise<() => void>((resolve) => {
resolveListen = resolve; resolveListen = resolve;
}) })
); );
const mod = await import("./managed-tauri-daemon-transport"); const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory(); const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull(); expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ const transport = transportFactory!({
@@ -121,7 +121,7 @@ describe("managed-tauri-daemon-transport", () => {
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2"); expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1); expect(cleanup).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -5,7 +5,7 @@ import {
openLocalTransportSession, openLocalTransportSession,
sendLocalTransportMessage, sendLocalTransportMessage,
type LocalTransportTarget, type LocalTransportTarget,
} from "@/desktop/managed-runtime/managed-runtime"; } from "./desktop-daemon";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:"; 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 }) => { return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url); const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null; let sessionId: string | null = null;
@@ -1,29 +1,24 @@
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command' import { getDesktopHost, isDesktop } from '@/desktop/host'
import { getTauri, isTauriEnvironment } from '@/utils/tauri' import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export type ManagedRuntimeStatus = { export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
runtimeId: string
runtimeVersion: string
runtimeRoot: string
}
export type ManagedDaemonStatus = { export type DesktopDaemonStatus = {
runtimeId: string
runtimeVersion: string
serverId: string serverId: string
status: string status: DesktopDaemonState
listen: string listen: string
hostname: string | null hostname: string | null
pid: number | null pid: number | null
home: string home: string
error: string | null
} }
export type ManagedDaemonLogs = { export type DesktopDaemonLogs = {
logPath: string logPath: string
contents: string contents: string
} }
export type ManagedPairingOffer = { export type DesktopPairingOffer = {
relayEnabled: boolean relayEnabled: boolean
url: string | null url: string | null
qr: string | null qr: string | null
@@ -35,12 +30,6 @@ export type CliSymlinkInstructions = {
commands: string commands: string
} }
export type ManagedTcpSettings = {
enabled: boolean
host: string
port: number
}
export type LocalTransportTarget = { export type LocalTransportTarget = {
transportType: 'socket' | 'pipe' transportType: 'socket' | 'pipe'
transportPath: string transportPath: string
@@ -68,36 +57,42 @@ function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null return typeof value === 'number' && Number.isFinite(value) ? value : null
} }
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus { function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
if (!isRecord(raw)) { const normalized = toStringOrNull(value)?.toLowerCase()
throw new Error('Unexpected managed runtime status response.') switch (normalized) {
} case 'starting':
return { return 'starting'
runtimeId: toStringOrNull(raw.runtimeId) ?? '', case 'running':
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '', return 'running'
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '', 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)) { if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon status response.') throw new Error('Unexpected desktop daemon status response.')
} }
return { return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
serverId: toStringOrNull(raw.serverId) ?? '', serverId: toStringOrNull(raw.serverId) ?? '',
status: toStringOrNull(raw.status) ?? 'unknown', status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? '', listen: toStringOrNull(raw.listen) ?? '',
hostname: toStringOrNull(raw.hostname), hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid), pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? '', home: toStringOrNull(raw.home) ?? '',
error: toStringOrNull(raw.error),
} }
} }
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs { function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon logs response.') throw new Error('Unexpected desktop daemon logs response.')
} }
return { return {
logPath: toStringOrNull(raw.logPath) ?? '', 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)) { if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon pairing response.') throw new Error('Unexpected desktop daemon pairing response.')
} }
return { return {
relayEnabled: raw.relayEnabled === true, relayEnabled: raw.relayEnabled === true,
@@ -127,36 +122,32 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
} }
} }
export function shouldUseManagedDesktopDaemon(): boolean { export function shouldUseDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null return isDesktop()
} }
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> { export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status')) return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
} }
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> { export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status')) return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
} }
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> { export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon')) return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
} }
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> { export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon')) return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
} }
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> { export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon')) return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
} }
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> { export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs')) return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
} }
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions { export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
@@ -171,27 +162,18 @@ export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstruction
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions')) 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 LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export async function listenToLocalTransportEvents( export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> { ): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen const listen = getDesktopHost()?.events?.on
if (typeof listen !== 'function') { 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 unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null if (!isRecord(payload)) {
if (!payload) {
return return
} }
handler({ handler({
@@ -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 : () => {};
}
+12
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;
}
@@ -1,13 +1,12 @@
import { getTauri } from "@/utils/tauri"; import { getDesktopHost } from "@/desktop/host";
export async function invokeDesktopCommand<T>( export async function invokeDesktopCommand<T>(
command: string, command: string,
args?: Record<string, unknown> args?: Record<string, unknown>
): Promise<T> { ): Promise<T> {
const invoke = getTauri()?.core?.invoke; const invoke = getDesktopHost()?.invoke;
if (typeof invoke !== "function") { 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; return (await invoke(command, args)) as T;
} }
@@ -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();
}
+111
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");
}
@@ -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."
);
});
});
@@ -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 : () => {};
}
@@ -4,14 +4,17 @@ type MockPlatform = 'web' | 'ios' | 'android'
type GlobalSnapshot = { type GlobalSnapshot = {
Notification: unknown Notification: unknown
__TAURI__: unknown
navigatorDescriptor?: PropertyDescriptor navigatorDescriptor?: PropertyDescriptor
paseoDesktop: unknown
} }
const originalGlobals: GlobalSnapshot = { const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification, Notification: (globalThis as { Notification?: unknown }).Notification,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'), navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
paseoDesktop:
typeof window === 'undefined'
? undefined
: (window as { paseoDesktop?: unknown }).paseoDesktop,
} }
function setNavigator(value: unknown): void { function setNavigator(value: unknown): void {
@@ -24,13 +27,16 @@ function setNavigator(value: unknown): void {
function restoreGlobals(): void { function restoreGlobals(): void {
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification ;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
if (originalGlobals.navigatorDescriptor) { if (originalGlobals.navigatorDescriptor) {
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
} else { } else {
delete (globalThis as { navigator?: unknown }).navigator delete (globalThis as { navigator?: unknown }).navigator
} }
if (typeof window !== 'undefined') {
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
}
} }
async function loadModuleForPlatform(platform: MockPlatform) { async function loadModuleForPlatform(platform: MockPlatform) {
@@ -47,20 +53,20 @@ describe('desktop-permissions', () => {
restoreGlobals() restoreGlobals()
}) })
it('shows section only in Tauri web runtime', async () => { it('shows section only in desktop web runtime', async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web') const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
expect(shouldShowDesktopPermissionSection()).toBe(false) expect(shouldShowDesktopPermissionSection()).toBe(false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} } ;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
expect(shouldShowDesktopPermissionSection()).toBe(true) expect(shouldShowDesktopPermissionSection()).toBe(true)
}) })
it('reads notification and microphone status', async () => { it('reads notification and microphone status', async () => {
const isPermissionGranted = vi.fn(async () => false) class MockNotification {
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { static permission = 'default'
notification: { isPermissionGranted },
} }
;(globalThis as { Notification?: unknown }).Notification = MockNotification
setNavigator({ setNavigator({
permissions: { permissions: {
query: vi.fn(async () => ({ state: 'granted' })), query: vi.fn(async () => ({ state: 'granted' })),
@@ -73,9 +79,8 @@ describe('desktop-permissions', () => {
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot() const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.notifications.state).toBe('not-granted') expect(snapshot.notifications.state).toBe('prompt')
expect(snapshot.microphone.state).toBe('granted') expect(snapshot.microphone.state).toBe('granted')
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
expect(snapshot.checkedAt).toBeTypeOf('number') expect(snapshot.checkedAt).toBeTypeOf('number')
}) })
@@ -127,20 +132,21 @@ describe('desktop-permissions', () => {
) )
}) })
it('requests notification permission via Tauri', async () => { it('requests notification permission via the browser Notification API', async () => {
const requestPermission = vi.fn(async () => 'granted') class MockNotification {
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { static permission = 'default'
notification: { requestPermission }, static requestPermission = vi.fn(async () => 'granted')
} }
;(globalThis as { Notification?: unknown }).Notification = MockNotification
const { requestDesktopPermission } = await loadModuleForPlatform('web') const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'notifications' }) const result = await requestDesktopPermission({ kind: 'notifications' })
expect(result.state).toBe('granted') 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 { class MockNotification {
static permission = 'denied' static permission = 'denied'
} }
@@ -1,5 +1,5 @@
import { Platform } from 'react-native' import { Platform } from 'react-native'
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri' import { getDesktopHost } from '@/desktop/host'
export type DesktopPermissionKind = 'notifications' | 'microphone' export type DesktopPermissionKind = 'notifications' | 'microphone'
@@ -45,7 +45,7 @@ type NavigatorLike = {
} }
export function shouldShowDesktopPermissionSection(): boolean { export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null return Platform.OS === 'web' && getDesktopHost() !== null
} }
function status(input: DesktopPermissionStatus): DesktopPermissionStatus { 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> { async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') { if (Platform.OS !== 'web') {
return status({ return status({
@@ -161,44 +140,30 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
}) })
} }
const tauriNotification = getTauri()?.notification const desktopHost = getDesktopHost()
if (tauriNotification) { if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
if (typeof tauriNotification.isPermissionGranted !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing isPermissionGranted().',
})
}
try { try {
const granted = await tauriNotification.isPermissionGranted() const supported = await desktopHost.notification.isSupported()
if (granted) {
return status({
state: 'granted',
detail: 'Tauri reports notifications are granted.',
})
}
return status({ return status({
state: 'not-granted', state: supported ? 'granted' : 'unavailable',
detail: 'Tauri reports notifications are not granted. Use Request to prompt.', detail: supported
}) ? 'Desktop notifications are supported.'
} catch (error) { : 'Desktop notifications are not supported on this platform.',
return status({
state: 'unknown',
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
}) })
} catch {
// Fall through to web API check
} }
} }
const NotificationConstructor = getWebNotificationConstructor() const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') { if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
return status({ return mapNotificationPermissionString(NotificationConstructor.permission)
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
} }
return mapNotificationPermissionString(NotificationConstructor.permission) return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
} }
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -279,18 +244,11 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
}) })
} }
const tauriNotification = getTauri()?.notification const NotificationConstructor = getWebNotificationConstructor()
if (tauriNotification) { if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
if (typeof tauriNotification.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing requestPermission().',
})
}
try { try {
const permission = await tauriNotification.requestPermission() const permission = await NotificationConstructor.requestPermission()
return mapTauriNotificationPermissionResult(permission) return mapNotificationPermissionString(permission)
} catch (error) { } catch (error) {
return status({ return status({
state: 'unknown', state: 'unknown',
@@ -299,23 +257,10 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
} }
} }
const NotificationConstructor = getWebNotificationConstructor() return status({
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') { state: 'unavailable',
return status({ detail: 'Web Notification API requestPermission() is unavailable.',
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)}`,
})
}
} }
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -122,7 +122,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
try { try {
const sent = await sendOsNotification({ const sent = await sendOsNotification({
title: "Paseo notification test", 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) { if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered"); console.warn("[Settings] Desktop test notification was not delivered");
+3 -3
View File
@@ -1,9 +1,9 @@
import { getTauri } from '@/utils/tauri' import { getDesktopHost } from '@/desktop/host'
export async function pickDirectory(): Promise<string | null> { export async function pickDirectory(): Promise<string | null> {
const open = getTauri()?.dialog?.open const open = getDesktopHost()?.dialog?.open
if (typeof open !== 'function') { 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({ const selection = await open({
@@ -1,6 +1,6 @@
import { Platform } from 'react-native' import { Platform } from 'react-native'
import { getTauri } from '@/utils/tauri' import { isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command' import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export interface DesktopAppUpdateCheckResult { export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean hasUpdate: boolean
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
} }
export function shouldShowDesktopUpdateSection(): boolean { export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null return Platform.OS === 'web' && isDesktop()
} }
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult { export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
@@ -1,21 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
const tauriState = vi.hoisted(() => ({ const desktopHostState = vi.hoisted(() => ({
api: null as any, api: null as any,
})); }));
vi.mock("@/utils/tauri", () => ({ vi.mock("@/desktop/host", () => ({
getTauri: () => tauriState.api, getDesktopHost: () => desktopHostState.api,
})); }));
import { import {
normalizePickedImageAssets, normalizePickedImageAssets,
openImagePathsWithTauriDialog, openImagePathsWithDesktopDialog,
} from "./image-attachment-picker"; } from "./image-attachment-picker";
describe("image-attachment-picker", () => { describe("image-attachment-picker", () => {
beforeEach(() => { beforeEach(() => {
tauriState.api = null; desktopHostState.api = null;
}); });
it("normalizes a picked File into a blob source", async () => { 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"); 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"]); const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
tauriState.api = { desktopHostState.api = {
dialog: { open }, dialog: { open },
}; };
const result = await openImagePathsWithTauriDialog(); const result = await openImagePathsWithDesktopDialog();
expect(open).toHaveBeenCalledWith( expect(open).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -87,21 +87,11 @@ describe("image-attachment-picker", () => {
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]); expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
}); });
it("falls back to core invoke for the tauri dialog plugin", async () => { it("throws when desktop dialog API is not available", async () => {
const invoke = vi.fn().mockResolvedValue("/tmp/one.png"); desktopHostState.api = {};
tauriState.api = {
core: { invoke },
};
const result = await openImagePathsWithTauriDialog(); await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
"Desktop dialog API is not available."
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", { );
options: expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
}),
});
expect(result).toEqual(["/tmp/one.png"]);
}); });
}); });
@@ -1,4 +1,4 @@
import { getTauri } from "@/utils/tauri"; import { getDesktopHost } from "@/desktop/host";
export type PickedImageSource = export type PickedImageSource =
| { kind: "file_uri"; uri: string } | { 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) { if (!selection) {
return []; return [];
} }
return Array.isArray(selection) ? selection : [selection]; return Array.isArray(selection) ? selection : [selection];
} }
export async function openImagePathsWithTauriDialog(): Promise<string[]> { export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
const tauri = getTauri(); const desktop = getDesktopHost();
const options = { const options = {
directory: false, directory: false,
multiple: true, multiple: true,
@@ -93,18 +93,10 @@ export async function openImagePathsWithTauriDialog(): Promise<string[]> {
title: "Attach images", title: "Attach images",
}; };
const dialogOpen = tauri?.dialog?.open; const dialogOpen = desktop?.dialog?.open;
if (typeof dialogOpen === "function") { if (typeof dialogOpen !== "function") {
return normalizeTauriDialogSelection(await dialogOpen(options)); throw new Error("Desktop dialog API is not available.");
} }
const invoke = tauri?.core?.invoke; return normalizeDesktopDialogSelection(await dialogOpen(options));
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
);
} }
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard"; import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { getTauri } from "@/utils/tauri"; import { isDesktop } from "@/desktop/host";
export interface AudioCaptureConfig { export interface AudioCaptureConfig {
sampleRate?: number; sampleRate?: number;
@@ -157,20 +157,20 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true; : true;
const currentOrigin = const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null; const isDesktopApp = isDesktop();
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); throw new Error("Microphone capture is not supported in this environment");
} }
if (!secureContext && !isTauri) { if (!secureContext && !isDesktopApp) {
throw new Error( throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
); );
} }
if (!secureContext && isTauri) { if (!secureContext && isDesktopApp) {
console.warn( console.warn(
"[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", "[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin } { currentOrigin }
); );
} }
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav"; 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"; import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
@@ -158,17 +158,17 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true; : true;
const currentOrigin = const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null; const isDesktopApp = isDesktop();
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); 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}`); throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
} }
if (!secureContext && isTauri) { if (!secureContext && isDesktopApp) {
console.warn( console.warn(
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", "[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin } { currentOrigin }
); );
} }
+6 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout"; import { getIsDesktopMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents"; import { useAggregatedAgents } from "./use-aggregated-agents";
import { getCurrentTauriWindow } from "@/utils/tauri"; import { getDesktopHost } from "@/desktop/host";
type FaviconStatus = "none" | "running" | "attention"; type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light"; type ColorScheme = "dark" | "light";
@@ -92,15 +92,15 @@ function getSystemColorScheme(): ColorScheme {
} }
async function updateMacDockBadge(count?: number) { async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsTauriMac()) return; if (Platform.OS !== "web" || !getIsDesktopMac()) return;
const tauriWindow = getCurrentTauriWindow(); const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") { if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
return; return;
} }
try { try {
await tauriWindow.setBadgeCount(count); await desktopWindow.setBadgeCount(count);
} catch (error) { } catch (error) {
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error); console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
} }
+16 -15
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri"; import { getDesktopHost } from "@/desktop/host";
import { import {
persistAttachmentFromBlob, persistAttachmentFromBlob,
persistAttachmentFromFileUri, persistAttachmentFromFileUri,
@@ -33,7 +33,7 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".tiff": "image/tiff", ".tiff": "image/tiff",
}; };
type TauriDragDropPayload = type DesktopDragDropPayload =
| { | {
type: "enter"; type: "enter";
paths: string[]; paths: string[];
@@ -49,8 +49,8 @@ type TauriDragDropPayload =
type: "leave"; type: "leave";
}; };
type TauriDragDropEvent = { type DesktopDragDropEvent = {
payload: TauriDragDropPayload; payload: DesktopDragDropPayload;
}; };
function isImageFile(file: File): boolean { function isImageFile(file: File): boolean {
@@ -121,26 +121,27 @@ export function useFileDropZone({
didCleanup = true; didCleanup = true;
try { try {
void Promise.resolve(cleanupFn()).catch((error) => { 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) { } 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> { async function setupDesktopDragDrop(): Promise<boolean> {
if (getTauri() === null) { const desktopHost = getDesktopHost();
if (desktopHost === null) {
return false; return false;
} }
const tauriWindow = getCurrentTauriWindow(); const desktopWindow = desktopHost.window?.getCurrentWindow?.();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") { if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") {
return false; return false;
} }
try { try {
const unlisten = await tauriWindow.onDragDropEvent( const unlisten = await desktopWindow.onDragDropEvent(
(event: TauriDragDropEvent) => { (event: DesktopDragDropEvent) => {
const payload = event.payload; const payload = event.payload;
if (payload.type === "leave") { if (payload.type === "leave") {
setIsDragging(false); setIsDragging(false);
@@ -185,7 +186,7 @@ export function useFileDropZone({
cleanup = unlisten; cleanup = unlisten;
return true; return true;
} catch (error) { } 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; return false;
} }
} }
@@ -269,8 +270,8 @@ export function useFileDropZone({
} }
void (async () => { void (async () => {
const tauriListenersAttached = await setupTauriDragDrop(); const desktopListenersAttached = await setupDesktopDragDrop();
if (disposed || tauriListenersAttached) { if (disposed || desktopListenersAttached) {
return; return;
} }
setupDomDragDrop(); setupDomDragDrop();
@@ -2,10 +2,10 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native"; import { Alert } from "react-native";
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { isTauriEnvironment } from "@/utils/tauri"; import { isDesktop } from "@/desktop/host";
import { import {
normalizePickedImageAssets, normalizePickedImageAssets,
openImagePathsWithTauriDialog, openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput, type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker"; } from "@/hooks/image-attachment-picker";
@@ -42,8 +42,8 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true; isPickingRef.current = true;
try { try {
if (Platform.OS === "web" && isTauriEnvironment()) { if (Platform.OS === "web" && isDesktop()) {
const selectedPaths = await openImagePathsWithTauriDialog(); const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) { if (selectedPaths.length === 0) {
return null; return null;
} }
@@ -1,15 +1,14 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime' import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { isTauriEnvironment } from '@/utils/tauri'
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 serverId: string | null
} }
async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult> { async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult> {
const status = await getManagedDaemonStatus() const status = await getDesktopDaemonStatus()
const serverId = status.serverId.trim() const serverId = status.serverId.trim()
return { return {
serverId: serverId.length > 0 ? serverId : null, serverId: serverId.length > 0 ? serverId : null,
@@ -18,14 +17,15 @@ async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult>
export function useIsLocalDaemon(serverId: string): boolean { export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim() const normalizedServerId = serverId.trim()
const isDesktop = isTauriEnvironment() const isDesktop = shouldUseDesktopDaemon()
const query = useQuery({ const query = useQuery({
queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY, queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadManagedDaemonServerId, queryFn: loadDesktopDaemonServerId,
enabled: isDesktop, enabled: isDesktop,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: Infinity,
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { usePathname } from "expo-router"; import { usePathname } from "expo-router";
import { getIsTauri } from "@/constants/layout"; import { getIsDesktop } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -50,7 +50,7 @@ export function useKeyboardShortcuts({
if (Platform.OS !== "web") return; if (Platform.OS !== "web") return;
if (isMobile) return; if (isMobile) return;
const isTauri = getIsTauri(); const isDesktopApp = getIsDesktop();
const isMac = getShortcutOs() === "mac"; const isMac = getShortcutOs() === "mac";
const shouldHandle = () => { const shouldHandle = () => {
@@ -259,7 +259,7 @@ export function useKeyboardShortcuts({
if (key === "Alt" && !event.shiftKey) { if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true); useKeyboardShortcutsStore.getState().setAltDown(true);
} }
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) { if (isDesktopApp && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true); useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
} }
if (key === "Shift") { if (key === "Shift") {
@@ -278,7 +278,7 @@ export function useKeyboardShortcuts({
event, event,
context: { context: {
isMac, isMac,
isTauri, isDesktop: isDesktopApp,
focusScope, focusScope,
commandCenterOpen: store.commandCenterOpen, commandCenterOpen: store.commandCenterOpen,
hasSelectedAgent: canToggleFileExplorerShortcut({ hasSelectedAgent: canToggleFileExplorerShortcut({
@@ -314,7 +314,7 @@ export function useKeyboardShortcuts({
if (key === "Alt") { if (key === "Alt") {
useKeyboardShortcutsStore.getState().setAltDown(false); useKeyboardShortcutsStore.getState().setAltDown(false);
} }
if (isTauri && (key === "Meta" || key === "Control")) { if (isDesktopApp && (key === "Meta" || key === "Control")) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false); useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
} }
}; };
@@ -23,7 +23,7 @@ function shortcutContext(
): KeyboardShortcutContext { ): KeyboardShortcutContext {
return { return {
isMac: false, isMac: false,
isTauri: false, isDesktop: false,
focusScope: "other", focusScope: "other",
commandCenterOpen: false, commandCenterOpen: false,
hasSelectedAgent: true, hasSelectedAgent: true,
@@ -84,7 +84,7 @@ type HelpSectionCase = {
name: string; name: string;
context: { context: {
isMac: boolean; isMac: boolean;
isTauri: boolean; isDesktop: boolean;
}; };
expectedKeys: Record<string, string[]>; expectedKeys: Record<string, string[]>;
}; };
@@ -106,42 +106,42 @@ describe("keyboard-shortcuts", () => {
{ {
name: "matches workspace index jump on web via Alt+digit", name: "matches workspace index jump on web via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true }, event: { key: "2", code: "Digit2", altKey: true },
context: { isTauri: false }, context: { isDesktop: false },
action: "workspace.navigate.index", action: "workspace.navigate.index",
payload: { index: 2 }, 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 }, event: { key: "2", code: "Digit2", metaKey: true },
context: { isMac: true, isTauri: true }, context: { isMac: true, isDesktop: true },
action: "workspace.navigate.index", action: "workspace.navigate.index",
payload: { index: 2 }, 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 }, event: { key: "2", code: "Digit2", altKey: true },
context: { isTauri: true }, context: { isDesktop: true },
action: "workspace.tab.navigate.index", action: "workspace.tab.navigate.index",
payload: { index: 2 }, payload: { index: 2 },
}, },
{ {
name: "matches tab index jump on web via Alt+Shift+digit", name: "matches tab index jump on web via Alt+Shift+digit",
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true }, event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
context: { isTauri: false }, context: { isDesktop: false },
action: "workspace.tab.navigate.index", action: "workspace.tab.navigate.index",
payload: { index: 2 }, payload: { index: 2 },
}, },
{ {
name: "matches workspace relative navigation on web via Alt+[", name: "matches workspace relative navigation on web via Alt+[",
event: { key: "[", code: "BracketLeft", altKey: true }, event: { key: "[", code: "BracketLeft", altKey: true },
context: { isTauri: false }, context: { isDesktop: false },
action: "workspace.navigate.relative", action: "workspace.navigate.relative",
payload: { delta: -1 }, 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 }, event: { key: "]", code: "BracketRight", ctrlKey: true },
context: { isTauri: true }, context: { isDesktop: true },
action: "workspace.navigate.relative", action: "workspace.navigate.relative",
payload: { delta: 1 }, payload: { delta: 1 },
}, },
@@ -160,13 +160,13 @@ describe("keyboard-shortcuts", () => {
{ {
name: "matches Alt+Shift+W to close current tab on web", name: "matches Alt+Shift+W to close current tab on web",
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true }, event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
context: { isTauri: false }, context: { isDesktop: false },
action: "workspace.tab.close.current", 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 }, event: { key: "w", code: "KeyW", metaKey: true },
context: { isMac: true, isTauri: true }, context: { isMac: true, isDesktop: true },
action: "workspace.tab.close.current", action: "workspace.tab.close.current",
}, },
{ {
@@ -312,7 +312,7 @@ describe("keyboard-shortcut help sections", () => {
const helpCases: HelpSectionCase[] = [ const helpCases: HelpSectionCase[] = [
{ {
name: "uses web defaults for workspace and tab jump", name: "uses web defaults for workspace and tab jump",
context: { isMac: true, isTauri: false }, context: { isMac: true, isDesktop: false },
expectedKeys: { expectedKeys: {
"new-agent": ["mod", "shift", "O"], "new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"], "workspace-tab-new": ["mod", "T"],
@@ -324,8 +324,8 @@ describe("keyboard-shortcut help sections", () => {
}, },
}, },
{ {
name: "uses tauri defaults for workspace and tab jump", name: "uses desktop defaults for workspace and tab jump",
context: { isMac: true, isTauri: true }, context: { isMac: true, isDesktop: true },
expectedKeys: { expectedKeys: {
"new-agent": ["mod", "shift", "O"], "new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"], "workspace-tab-new": ["mod", "T"],
@@ -338,7 +338,7 @@ describe("keyboard-shortcut help sections", () => {
}, },
{ {
name: "uses mod+period as non-mac left sidebar shortcut", name: "uses mod+period as non-mac left sidebar shortcut",
context: { isMac: false, isTauri: false }, context: { isMac: false, isDesktop: false },
expectedKeys: { expectedKeys: {
"toggle-left-sidebar": ["mod", "."], "toggle-left-sidebar": ["mod", "."],
}, },
+25 -25
View File
@@ -8,7 +8,7 @@ import type {
export type KeyboardShortcutContext = { export type KeyboardShortcutContext = {
isMac: boolean; isMac: boolean;
isTauri: boolean; isDesktop: boolean;
focusScope: KeyboardFocusScope; focusScope: KeyboardFocusScope;
commandCenterOpen: boolean; commandCenterOpen: boolean;
hasSelectedAgent: boolean; hasSelectedAgent: boolean;
@@ -36,7 +36,7 @@ export type KeyboardShortcutHelpSection = {
type KeyboardShortcutPlatformContext = { type KeyboardShortcutPlatformContext = {
isMac: boolean; isMac: boolean;
isTauri: boolean; isDesktop: boolean;
}; };
type KeyboardShortcutHelpEntry = { 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", action: "workspace.tab.close.current",
matches: (event) => matches: (event) =>
isMod(event) && isMod(event) &&
!event.altKey && !event.altKey &&
!event.shiftKey && !event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"), (event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => context.isTauri && !context.commandCenterOpen, when: (context) => context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-tab-close-current", id: "workspace-tab-close-current",
section: "global", section: "global",
label: "Close current tab", label: "Close current tab",
keys: ["mod", "W"], keys: ["mod", "W"],
when: (context) => context.isTauri, when: (context) => context.isDesktop,
}, },
}, },
{ {
@@ -183,17 +183,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
event.altKey && event.altKey &&
event.shiftKey && event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"), (event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => !context.isTauri && !context.commandCenterOpen, when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-tab-close-current", id: "workspace-tab-close-current",
section: "global", section: "global",
label: "Close current tab", label: "Close current tab",
keys: ["alt", "shift", "W"], 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", action: "workspace.navigate.index",
matches: (event) => matches: (event) =>
isMod(event) && isMod(event) &&
@@ -201,13 +201,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey && !event.shiftKey &&
hasDigit(event), hasDigit(event),
payload: withIndexPayload, payload: withIndexPayload,
when: (context) => context.isTauri && !context.commandCenterOpen, when: (context) => context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-jump-index", id: "workspace-jump-index",
section: "global", section: "global",
label: "Jump to workspace", label: "Jump to workspace",
keys: ["mod", "1-9"], keys: ["mod", "1-9"],
when: (context) => context.isTauri, when: (context) => context.isDesktop,
}, },
}, },
{ {
@@ -220,17 +220,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey && !event.shiftKey &&
hasDigit(event), hasDigit(event),
payload: withIndexPayload, payload: withIndexPayload,
when: (context) => !context.isTauri && !context.commandCenterOpen, when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-jump-index", id: "workspace-jump-index",
section: "global", section: "global",
label: "Jump to workspace", label: "Jump to workspace",
keys: ["alt", "1-9"], 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", action: "workspace.tab.navigate.index",
matches: (event) => matches: (event) =>
!event.metaKey && !event.metaKey &&
@@ -239,13 +239,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey && !event.shiftKey &&
hasDigit(event), hasDigit(event),
payload: withIndexPayload, payload: withIndexPayload,
when: (context) => context.isTauri && !context.commandCenterOpen, when: (context) => context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-tab-jump-index", id: "workspace-tab-jump-index",
section: "global", section: "global",
label: "Jump to tab", label: "Jump to tab",
keys: ["alt", "1-9"], keys: ["alt", "1-9"],
when: (context) => context.isTauri, when: (context) => context.isDesktop,
}, },
}, },
{ {
@@ -258,13 +258,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
event.shiftKey && event.shiftKey &&
hasDigit(event), hasDigit(event),
payload: withIndexPayload, payload: withIndexPayload,
when: (context) => !context.isTauri && !context.commandCenterOpen, when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-tab-jump-index", id: "workspace-tab-jump-index",
section: "global", section: "global",
label: "Jump to tab", label: "Jump to tab",
keys: ["alt", "shift", "1-9"], 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.shiftKey &&
(event.code === "BracketLeft" || event.key === "["), (event.code === "BracketLeft" || event.key === "["),
payload: withRelativeDelta(-1), payload: withRelativeDelta(-1),
when: (context) => context.isTauri && !context.commandCenterOpen, when: (context) => context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-prev", id: "workspace-prev",
section: "global", section: "global",
label: "Previous workspace", label: "Previous workspace",
keys: ["mod", "["], keys: ["mod", "["],
when: (context) => context.isTauri, when: (context) => context.isDesktop,
}, },
}, },
{ {
@@ -294,13 +294,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey && !event.shiftKey &&
(event.code === "BracketRight" || event.key === "]"), (event.code === "BracketRight" || event.key === "]"),
payload: withRelativeDelta(1), payload: withRelativeDelta(1),
when: (context) => context.isTauri && !context.commandCenterOpen, when: (context) => context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-next", id: "workspace-next",
section: "global", section: "global",
label: "Next workspace", label: "Next workspace",
keys: ["mod", "]"], keys: ["mod", "]"],
when: (context) => context.isTauri, when: (context) => context.isDesktop,
}, },
}, },
{ {
@@ -313,13 +313,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey && !event.shiftKey &&
(event.code === "BracketLeft" || event.key === "["), (event.code === "BracketLeft" || event.key === "["),
payload: withRelativeDelta(-1), payload: withRelativeDelta(-1),
when: (context) => !context.isTauri && !context.commandCenterOpen, when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-prev", id: "workspace-prev",
section: "global", section: "global",
label: "Previous workspace", label: "Previous workspace",
keys: ["alt", "["], keys: ["alt", "["],
when: (context) => !context.isTauri, when: (context) => !context.isDesktop,
}, },
}, },
{ {
@@ -332,13 +332,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey && !event.shiftKey &&
(event.code === "BracketRight" || event.key === "]"), (event.code === "BracketRight" || event.key === "]"),
payload: withRelativeDelta(1), payload: withRelativeDelta(1),
when: (context) => !context.isTauri && !context.commandCenterOpen, when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: { help: {
id: "workspace-next", id: "workspace-next",
section: "global", section: "global",
label: "Next workspace", label: "Next workspace",
keys: ["alt", "]"], keys: ["alt", "]"],
when: (context) => !context.isTauri, when: (context) => !context.isDesktop,
}, },
}, },
{ {
+22 -43
View File
@@ -16,9 +16,9 @@ import {
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import { import {
shouldUseManagedDesktopDaemon, shouldUseDesktopDaemon,
startManagedDaemon, startDesktopDaemon,
} from "@/desktop/managed-runtime/managed-runtime"; } from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { import {
buildDaemonWebSocketUrl, buildDaemonWebSocketUrl,
@@ -32,8 +32,8 @@ import {
} from "@/utils/connection-selection"; } from "@/utils/connection-selection";
import { import {
buildLocalDaemonTransportUrl, buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
} from "@/utils/managed-tauri-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -432,7 +432,7 @@ function probeIntervalForConnection(
function createDefaultDeps(): HostRuntimeControllerDeps { function createDefaultDeps(): HostRuntimeControllerDeps {
return { return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => { createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createTauriLocalDaemonTransportFactory(); const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = { const base = {
suppressSendErrors: true, suppressSendErrors: true,
clientId, clientId,
@@ -1188,7 +1188,7 @@ export class HostRuntimeStore {
return; return;
} }
if (shouldUseManagedDesktopDaemon()) { if (shouldUseDesktopDaemon()) {
await this.bootstrapDesktop(); await this.bootstrapDesktop();
} else { } else {
await this.bootstrapLocalhost(); await this.bootstrapLocalhost();
@@ -1197,43 +1197,22 @@ export class HostRuntimeStore {
private async bootstrapDesktop(): Promise<void> { private async bootstrapDesktop(): Promise<void> {
try { try {
await Promise.allSettled([ const daemon = await startDesktopDaemon();
(async () => { const connection = connectionFromListen(daemon.listen);
const daemon = await startManagedDaemon(); if (!connection) {
if (!daemon.serverId) { return;
return; }
} const { client, serverId, hostname } = await connectToDaemon(connection, {
const connection = connectionFromListen(daemon.listen); timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
if (!connection) { });
return; await this.upsertHostConnection({
} serverId,
await this.upsertHostConnection({ label: hostname ?? daemon.hostname ?? undefined,
serverId: daemon.serverId, connection,
label: daemon.hostname ?? undefined, existingClient: client,
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),
]);
} catch (error) { } catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop startup host connections", error); console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
} }
} }
@@ -47,7 +47,7 @@ import type {
} from '@server/server/agent/agent-sdk-types' } from '@server/server/agent/agent-sdk-types'
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest' import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes' 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 { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots' import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow' import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
@@ -238,7 +238,7 @@ function DraftAgentScreenContent({
const activateExplorerTabForCheckout = usePanelStore( const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout (state) => state.activateExplorerTabForCheckout
) )
const tauriDragHandlers = useTauriDragHandlers() const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
const draftIdRef = useRef(generateDraftId()) const draftIdRef = useRef(generateDraftId())
const draftAgentIdRef = useRef(generateDraftId()) const draftAgentIdRef = useRef(generateDraftId())
@@ -974,7 +974,7 @@ function DraftAgentScreenContent({
const explorerServerId = draftExplorerCheckout?.serverId ?? null const explorerServerId = draftExplorerCheckout?.serverId ?? null
const explorerIsGit = draftExplorerCheckout?.isGit ?? false const explorerIsGit = draftExplorerCheckout?.isGit ?? false
const mainContent = ( const mainContent = (
<View style={styles.container} {...tauriDragHandlers}> <View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={styles.outerContainer}> <View style={styles.outerContainer}>
<View style={styles.agentPanel}> <View style={styles.agentPanel}>
<View <View
@@ -7,8 +7,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { getIsTauriMac } from "@/constants/layout"; import { getIsDesktopMac } from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) { export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -19,9 +19,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openProjectPicker = useOpenProjectPicker(serverId); const openProjectPicker = useOpenProjectPicker(serverId);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; 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 trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const dragHandlers = useTauriDragHandlers(); const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
useEffect(() => { useEffect(() => {
if (!isMobile) { if (!isMobile) {
@@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]); }, [isMobile, openAgentList]);
return ( return (
<View style={styles.container} {...dragHandlers}> <View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}> <View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle /> <SidebarMenuToggle />
</View> </View>
+2 -1
View File
@@ -41,6 +41,7 @@ import {
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-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 { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version"; import { resolveAppVersion } from "@/utils/app-version";
@@ -513,7 +514,7 @@ export default function SettingsScreen() {
const isLoading = settingsLoading; const isLoading = settingsLoading;
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null); const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = Platform.OS === "web"; const isDesktop = isDesktopHost();
const isLocalDaemon = useIsLocalDaemon(routeServerId); const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion); const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1,7 +1,7 @@
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PaseoLogo } from "@/components/icons/paseo-logo";
import { useTauriDragHandlers } from "@/utils/tauri-window"; import { useDesktopDragHandlers } from "@/utils/desktop-window";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({
})); }));
export function StartupSplashScreen() { export function StartupSplashScreen() {
const dragHandlers = useTauriDragHandlers(); const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
return ( return (
<View style={styles.container} {...dragHandlers}> <View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<PaseoLogo size={96} /> <PaseoLogo size={96} />
<Text style={styles.status}>Starting up…</Text> <Text style={styles.status}>Starting up…</Text>
</View> </View>
+53 -24
View File
@@ -80,6 +80,17 @@ function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean
return JSON.stringify(left) === JSON.stringify(right) 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: { export function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[] profiles: HostProfile[]
serverId: string serverId: string
@@ -96,9 +107,17 @@ export function upsertHostConnectionInProfiles(input: {
const labelTrimmed = input.label?.trim() ?? '' const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId const derivedLabel = labelTrimmed || serverId
const existing = input.profiles 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 = { const profile: HostProfile = {
serverId, serverId,
label: derivedLabel, label: derivedLabel,
@@ -111,31 +130,37 @@ export function upsertHostConnectionInProfiles(input: {
return [...existing, profile] return [...existing, profile]
} }
const prev = existing[idx]! const matchedProfiles = matchingIndexes.map((index) => existing[index]!)
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id) const prev =
const hadConnection = connectionIdx !== -1 matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]!
const connectionChanged = const nextConnections = dedupeHostConnections([
connectionIdx === -1 ...matchedProfiles.flatMap((daemon) => daemon.connections),
? true input.connection,
: !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 nextLifecycle = prev.lifecycle const nextLifecycle = prev.lifecycle
const nextLabel = labelTrimmed ? labelTrimmed : prev.label const nextLabel =
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id 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 = const changed =
matchingIndexes.length > 1 ||
prev.serverId !== serverId ||
nextCreatedAt !== prev.createdAt ||
nextLabel !== prev.label || nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId || nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) || !hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection || nextConnections.length !== prev.connections.length ||
connectionChanged nextConnections.some((connection, index) => {
const previousConnection = prev.connections[index]
return !previousConnection || !hostConnectionEquals(connection, previousConnection)
})
if (!changed) { if (!changed) {
return existing return existing
@@ -143,15 +168,19 @@ export function upsertHostConnectionInProfiles(input: {
const nextProfile: HostProfile = { const nextProfile: HostProfile = {
...prev, ...prev,
serverId,
label: nextLabel, label: nextLabel,
lifecycle: nextLifecycle, lifecycle: nextLifecycle,
connections: nextConnections, connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId, preferredConnectionId: nextPreferredConnectionId,
createdAt: nextCreatedAt,
updatedAt: now, updatedAt: now,
} }
const next = [...existing] const firstIndex = matchingIndexes[0]!
next[idx] = nextProfile const matchingIndexSet = new Set(matchingIndexes)
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index))
next.splice(firstIndex, 0, nextProfile)
return next return next
} }
+15 -4
View File
@@ -1,5 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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 MockPlatform = "web" | "ios" | "android";
type AlertButton = { type AlertButton = {
@@ -19,13 +27,16 @@ async function loadModuleForPlatform(platform: MockPlatform): Promise<{
}, },
Platform: { OS: platform }, Platform: { OS: platform },
})); }));
vi.doMock("@/desktop/host", () => ({
getDesktopHost: () => desktopHostState.api,
}));
const module = await import("./confirm-dialog"); const module = await import("./confirm-dialog");
return { confirmDialog: module.confirmDialog, alertMock }; return { confirmDialog: module.confirmDialog, alertMock };
} }
function clearDialogGlobals(): void { function clearDialogGlobals(): void {
delete (globalThis as { __TAURI__?: unknown }).__TAURI__; desktopHostState.api = null;
delete (globalThis as { confirm?: unknown }).confirm; delete (globalThis as { confirm?: unknown }).confirm;
} }
@@ -37,13 +48,13 @@ describe("confirmDialog", () => {
clearDialogGlobals(); 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 askMock = vi.fn(async () => true);
const blurMock = vi.fn(); const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = { (globalThis as { document?: unknown }).document = {
activeElement: { blur: blurMock }, activeElement: { blur: blurMock },
} as unknown as Document; } as unknown as Document;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = { desktopHostState.api = {
dialog: { ask: askMock }, 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 browserConfirm = vi.fn(() => true);
const blurMock = vi.fn(); const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = { (globalThis as { document?: unknown }).document = {
+15 -32
View File
@@ -1,5 +1,5 @@
import { Alert, Platform } from "react-native"; import { Alert, Platform } from "react-native";
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri"; import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
export interface ConfirmDialogInput { export interface ConfirmDialogInput {
title: string; title: string;
@@ -48,14 +48,14 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
}); });
} }
function getTauriApi() { function getDesktopApi() {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return null; return null;
} }
return getTauri(); return getDesktopHost();
} }
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions { function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOptions {
const labels = resolveButtonLabels(input); const labels = resolveButtonLabels(input);
return { return {
@@ -74,35 +74,18 @@ function blurActiveWebElement(): void {
(activeElement as HTMLElement | null)?.blur?.(); (activeElement as HTMLElement | null)?.blur?.();
} }
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> { async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const tauriApi = getTauriApi(); const desktopApi = getDesktopApi();
if (!tauriApi) { if (!desktopApi) {
return null; return null;
} }
blurActiveWebElement(); blurActiveWebElement();
const options = buildTauriAskOptions(input); const options = buildDesktopAskOptions(input);
const tauriAsk = tauriApi.dialog?.ask; const desktopAsk = desktopApi.dialog?.ask;
if (typeof tauriAsk === "function") { if (typeof desktopAsk === "function") {
try { return Boolean(await desktopAsk(input.message, options));
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);
}
} }
return null; return null;
@@ -124,9 +107,9 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
return showNativeConfirmDialog(input); return showNativeConfirmDialog(input);
} }
const tauriResult = await showTauriConfirmDialog(input); const desktopResult = await showDesktopConfirmDialog(input);
if (tauriResult !== null) { if (desktopResult !== null) {
return tauriResult; return desktopResult;
} }
return showWebConfirmDialog(input); return showWebConfirmDialog(input);
@@ -134,5 +117,5 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
export const __private__ = { export const __private__ = {
blurActiveWebElement, blurActiveWebElement,
buildTauriAskOptions, buildDesktopAskOptions,
}; };
+117
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,
};
}
+2 -2
View File
@@ -1,10 +1,10 @@
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri"; import { getDesktopHost } from "@/desktop/host";
export async function openExternalUrl(url: string): Promise<void> { export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") { if (Platform.OS === "web") {
const opener = getTauri()?.opener?.openUrl; const opener = getDesktopHost()?.opener?.openUrl;
if (typeof opener === "function") { if (typeof opener === "function") {
await opener(url); await opener(url);
return; return;
+81 -17
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockNotificationOptions = { type MockNotificationOptions = {
body?: string; body?: string;
data?: Record<string, unknown>; data?: Record<string, unknown>;
icon?: string;
}; };
type MockNotificationInstance = { type MockNotificationInstance = {
@@ -18,7 +19,6 @@ type GlobalSnapshot = {
dispatchEvent: unknown; dispatchEvent: unknown;
focus: unknown; focus: unknown;
location: unknown; location: unknown;
__TAURI__: unknown;
}; };
const originalGlobals: GlobalSnapshot = { const originalGlobals: GlobalSnapshot = {
@@ -27,12 +27,34 @@ const originalGlobals: GlobalSnapshot = {
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent, dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
focus: (globalThis as { focus?: unknown }).focus, focus: (globalThis as { focus?: unknown }).focus,
location: (globalThis as { location?: unknown }).location, 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.resetModules();
vi.doMock("react-native", () => ({ Platform: { OS: platform } })); 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"); return import("./os-notifications");
} }
@@ -42,7 +64,6 @@ function restoreGlobals(): void {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent; (globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
(globalThis as { focus?: unknown }).focus = originalGlobals.focus; (globalThis as { focus?: unknown }).focus = originalGlobals.focus;
(globalThis as { location?: unknown }).location = originalGlobals.location; (globalThis as { location?: unknown }).location = originalGlobals.location;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
} }
describe("sendOsNotification", () => { describe("sendOsNotification", () => {
@@ -71,7 +92,6 @@ describe("sendOsNotification", () => {
}); });
afterEach(() => { afterEach(() => {
vi.doUnmock("@/desktop/notifications/desktop-notifications");
vi.doUnmock("react-native"); vi.doUnmock("react-native");
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.resetModules(); vi.resetModules();
@@ -168,14 +188,8 @@ describe("sendOsNotification", () => {
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
}); });
it("uses the desktop notification bridge when Tauri is available", async () => { it("returns false when the Notification API is unavailable", async () => {
const sendDesktopNotification = vi.fn(async () => true); (globalThis as { Notification?: unknown }).Notification = undefined;
vi.doMock("@/desktop/notifications/desktop-notifications", () => ({
sendDesktopNotification,
}));
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {};
const { sendOsNotification } = await loadModuleForPlatform("web"); const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({ const sent = await sendOsNotification({
title: "Agent finished", title: "Agent finished",
@@ -183,11 +197,61 @@ describe("sendOsNotification", () => {
data: { serverId: "srv-1", agentId: "agent-1" }, 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(sent).toBe(true);
expect(sendDesktopNotification).toHaveBeenCalledWith({ expect(created).toHaveLength(1);
title: "Agent finished", expect(created[0]?.onclick).toBeNull();
body: "Done", });
data: { serverId: "srv-1", agentId: "agent-1" },
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" },
}); });
}); });
}); });
+54 -81
View File
@@ -1,7 +1,10 @@
import { Asset } from "expo-asset";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { sendDesktopNotification } from "@/desktop/notifications/desktop-notifications"; import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute } from "./notification-routing"; import {
import { getTauri, type TauriNotificationApi } from "@/utils/tauri"; buildNotificationRoute,
resolveNotificationTarget,
} from "./notification-routing";
type OsNotificationPayload = { type OsNotificationPayload = {
title: string; title: string;
@@ -15,28 +18,41 @@ export type WebNotificationClickDetail = {
type WebNotificationInstance = { type WebNotificationInstance = {
onclick?: ((event: Event) => void) | null; 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"; export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null; let permissionRequest: Promise<boolean> | null = null;
const activeWebNotifications = new Set<WebNotificationInstance>(); let notificationIconUrl: string | null | undefined;
function getTauriNotificationModule(): TauriNotificationApi | null { function getDesktopNotificationSender():
if (Platform.OS !== "web") { | ((payload: {
return null; title: string;
} body?: string;
return getTauri()?.notification ?? null; 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(): { function getWebNotificationConstructor(): {
permission: string; permission: string;
requestPermission?: () => Promise<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 { } | null {
const NotificationConstructor = (globalThis as { Notification?: any }).Notification; const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
return NotificationConstructor ?? null; return NotificationConstructor ?? null;
@@ -70,49 +86,31 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return false; return false;
} }
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
return await ensureTauriNotificationPermission(tauriNotification);
}
return await ensureNotificationPermission(); return await ensureNotificationPermission();
} }
async function ensureTauriNotificationPermission( function hasNotificationClickTarget(
notificationModule: TauriNotificationApi data: Record<string, unknown> | undefined
): Promise<boolean> { ): boolean {
if (typeof notificationModule.isPermissionGranted === "function") { const target = resolveNotificationTarget(data);
try { return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
const granted = await notificationModule.isPermissionGranted(); }
if (granted) {
return true;
}
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to check notification permission",
error
);
}
}
if (typeof notificationModule.requestPermission !== "function") { function getWebNotificationIconUrl(): string | undefined {
console.warn( if (notificationIconUrl !== undefined) {
"[OSNotifications][Tauri] notification.requestPermission is unavailable" return notificationIconUrl ?? undefined;
);
return false;
} }
try { try {
const result = await notificationModule.requestPermission(); const asset = Asset.fromModule(
return result === "granted"; require("../../assets/images/notification-icon.png")
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to request notification permission",
error
); );
return false; notificationIconUrl = asset.uri ?? null;
} catch {
notificationIconUrl = null;
} }
return notificationIconUrl ?? undefined;
} }
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean { function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
@@ -156,41 +154,12 @@ function attachWebClickHandler(
notification: WebNotificationInstance, notification: WebNotificationInstance,
data: Record<string, unknown> | undefined data: Record<string, unknown> | undefined
): void { ): void {
activeWebNotifications.add(notification); notification.onclick = () => {
const cleanup = () => {
activeWebNotifications.delete(notification);
};
const onClick = () => {
const focus = (globalThis as { focus?: () => void }).focus;
if (typeof focus === "function") {
focus();
}
const handledByApp = dispatchWebNotificationClick({ data }); const handledByApp = dispatchWebNotificationClick({ data });
if (!handledByApp) { if (!handledByApp) {
fallbackNavigateToNotificationTarget(data); 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( export async function sendOsNotification(
@@ -201,8 +170,9 @@ export async function sendOsNotification(
return false; return false;
} }
if (getTauri()) { const desktopNotificationSender = getDesktopNotificationSender();
return await sendDesktopNotification(payload); if (desktopNotificationSender) {
return await desktopNotificationSender(payload);
} }
const NotificationConstructor = getWebNotificationConstructor(); const NotificationConstructor = getWebNotificationConstructor();
@@ -212,8 +182,11 @@ export async function sendOsNotification(
const notification = new NotificationConstructor(payload.title, { const notification = new NotificationConstructor(payload.title, {
body: payload.body, body: payload.body,
data: payload.data, data: payload.data,
icon: getWebNotificationIconUrl(),
}) as WebNotificationInstance; }) as WebNotificationInstance;
attachWebClickHandler(notification, payload.data); if (hasNotificationClickTarget(payload.data)) {
attachWebClickHandler(notification, payload.data);
}
return true; return true;
} }
} }
+2 -3
View File
@@ -1,12 +1,12 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout"; import { getIsDesktopMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut"; import type { ShortcutOs } from "@/utils/format-shortcut";
export function getShortcutOs(): ShortcutOs { export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return Platform.OS === "ios" ? "mac" : "non-mac"; return Platform.OS === "ios" ? "mac" : "non-mac";
} }
if (getIsTauriMac()) return "mac"; if (getIsDesktopMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac"; if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? ""; const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? ""; const platform = (navigator as any).platform ?? "";
@@ -15,4 +15,3 @@ export function getShortcutOs(): ShortcutOs {
/Mac|iPhone|iPad|iPod/i.test(platform); /Mac|iPhone|iPad|iPod/i.test(platform);
return isApple ? "mac" : "non-mac"; return isApple ? "mac" : "non-mac";
} }
@@ -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
};
}
-178
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,
};
}
-106
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;
}
}
@@ -5,8 +5,8 @@ import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { import {
buildLocalDaemonTransportUrl, buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
} from "./managed-tauri-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
@@ -49,7 +49,7 @@ export async function buildClientConfig(
serverId?: string serverId?: string
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId(); const clientId = await getOrCreateClientId();
const localTransportFactory = createTauriLocalDaemonTransportFactory(); const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = { const base = {
clientId, clientId,
clientType: "mobile" as const, clientType: "mobile" as const,
+3 -3
View File
@@ -1,4 +1,4 @@
import { getTauri } from "@/utils/tauri"; import { isDesktop } from "@/desktop/host";
import type { import type {
AudioEngine, AudioEngine,
AudioEngineCallbacks, AudioEngineCallbacks,
@@ -300,12 +300,12 @@ export function createAudioEngine(
typeof window !== "undefined" && window.location typeof window !== "undefined" && window.location
? window.location.origin ? window.location.origin
: "unknown"; : "unknown";
const isTauri = getTauri() !== null; const isDesktopApp = isDesktop();
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); throw new Error("Microphone capture is not supported in this environment");
} }
if (!secureContext && !isTauri) { if (!secureContext && !isDesktopApp) {
throw new Error( throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
); );
+9
View File
@@ -0,0 +1,9 @@
# Build output
dist/
release/
# Dependencies
node_modules/
# TypeScript
*.tsbuildinfo
+3
View File
@@ -0,0 +1,3 @@
# desktop
Electron desktop app for Paseo.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+44
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
-233
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"
}
}
}
}
+18 -9
View File
@@ -2,17 +2,26 @@
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.30", "version": "0.1.30",
"private": true, "private": true,
"description": "Paseo desktop app (Tauri wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
"scripts": { "scripts": {
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs", "build": "npm --prefix ../.. run build:daemon && npm run build:main && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.yml",
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime && npm run validate:managed-runtime", "build:main": "tsc -p tsconfig.json",
"dev": "tauri dev", "dev": "npm run build:main && wait-on tcp:8081 && electron .",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build", "typecheck": "tsc --noEmit -p tsconfig.json"
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs", },
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs", "dependencies": {
"tauri": "tauri" "@getpaseo/cli": "0.1.30",
"@getpaseo/server": "0.1.30",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
}, },
"devDependencies": { "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"
} }
} }
@@ -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();
@@ -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
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}" "$@"
+19
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%" %*
@@ -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();
@@ -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}`);
@@ -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;
});
-4
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
-52
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"
@@ -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>
-18
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>
-17
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()
}
@@ -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