chore: finalize electron desktop migration
541
.github/workflows/desktop-release.yml
vendored
@@ -3,21 +3,21 @@ name: Desktop Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "desktop-v*"
|
||||
- "desktop-macos-v*"
|
||||
- "desktop-linux-v*"
|
||||
- "desktop-windows-v*"
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
- 'desktop-macos-v*'
|
||||
- 'desktop-linux-v*'
|
||||
- 'desktop-windows-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
platform:
|
||||
description: "Optional desktop platform to build."
|
||||
description: 'Optional desktop platform to build.'
|
||||
required: false
|
||||
default: "all"
|
||||
default: 'all'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
@@ -31,6 +31,8 @@ concurrency:
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
DESKTOP_WORKSPACE: '@getpaseo/desktop-electron'
|
||||
DESKTOP_PACKAGE_PATH: 'packages/desktop-electron'
|
||||
|
||||
jobs:
|
||||
publish-macos:
|
||||
@@ -40,9 +42,9 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
rust_target: aarch64-apple-darwin
|
||||
electron_arch: arm64
|
||||
- runner: macos-15-intel
|
||||
rust_target: x86_64-apple-darwin
|
||||
electron_arch: x64
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -75,85 +77,39 @@ jobs:
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: desktop-release-macos-${{ matrix.rust_target }}
|
||||
workspaces: |
|
||||
.
|
||||
packages/desktop/src-tauri -> target
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Import Apple code-signing certificate
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Sign bundled managed runtime
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -162,76 +118,36 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_draft="false"
|
||||
release_type="release"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish macOS Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
id: tauri_build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Notarize and re-upload DMG
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_NAME: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
|
||||
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
|
||||
if [ -z "$dmg_path" ]; then
|
||||
echo "::error::No DMG found in tauri build artifacts"
|
||||
exit 1
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
echo "DMG: $dmg_path"
|
||||
|
||||
echo "Signing DMG..."
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
|
||||
|
||||
echo "Submitting DMG for notarization..."
|
||||
xcrun notarytool submit "$dmg_path" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
|
||||
echo "Stapling notarization ticket..."
|
||||
xcrun stapler staple "$dmg_path"
|
||||
|
||||
echo "Verifying..."
|
||||
spctl --assess --type install --verbose "$dmg_path"
|
||||
|
||||
echo "Replacing release asset with notarized DMG..."
|
||||
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
|
||||
|
||||
- name: Build macOS app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
@@ -267,89 +183,38 @@ jobs:
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Install Linux packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: desktop-release-linux
|
||||
workspaces: |
|
||||
.
|
||||
packages/desktop/src-tauri -> target
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Strip CUDA dependencies from onnxruntime
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
|
||||
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
|
||||
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
|
||||
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
|
||||
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
|
||||
echo "Removing needed $lib from $f"
|
||||
patchelf --remove-needed "$lib" "$f"
|
||||
done
|
||||
done
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
@@ -359,141 +224,30 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_draft="false"
|
||||
release_type="release"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Linux Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
id: linux_tauri
|
||||
continue-on-error: true
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
NO_STRIP: "1"
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
|
||||
|
||||
- name: Attempt manual Linux AppImage fallback
|
||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
|
||||
appdir_path="$appimage_dir/Paseo.AppDir"
|
||||
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
|
||||
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
||||
if [ -n "$existing_appimage" ]; then
|
||||
if [ "$existing_appimage" != "$canonical_appimage" ]; then
|
||||
mv "$existing_appimage" "$canonical_appimage"
|
||||
fi
|
||||
if [ ! -f "$canonical_appimage.sig" ]; then
|
||||
npx tauri signer sign "$canonical_appimage"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -d "$appdir_path" ]; then
|
||||
echo "::error::AppDir was not generated at $appdir_path"
|
||||
exit 1
|
||||
fi
|
||||
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
|
||||
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
|
||||
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
|
||||
tools_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
|
||||
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
|
||||
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
|
||||
npx tauri signer sign "$canonical_appimage"
|
||||
|
||||
- name: Fail Linux release when AppImage bundling fails
|
||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
assets=(
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "::error::Linux AppImage assets are still missing after the manual fallback."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Patch AppImage for Wayland compatibility
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
appimage_dir="$GITHUB_WORKSPACE/packages/desktop/src-tauri/target/release/bundle/appimage"
|
||||
appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
||||
if [ -z "$appimage" ]; then
|
||||
echo "::error::No AppImage found to patch"
|
||||
exit 1
|
||||
fi
|
||||
echo "Patching $appimage for Wayland compatibility..."
|
||||
|
||||
# Extract the AppImage
|
||||
workdir="$(mktemp -d)"
|
||||
cd "$workdir"
|
||||
"$appimage" --appimage-extract
|
||||
|
||||
# Remove GDK_BACKEND=x11 from the GTK plugin hook.
|
||||
# The bundled libgdk-3.so has Wayland support built in; forcing x11
|
||||
# prevents the app from starting on Wayland-only systems.
|
||||
gtk_hook="squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh"
|
||||
if [ -f "$gtk_hook" ]; then
|
||||
sed -i 's/^export GDK_BACKEND=x11/#export GDK_BACKEND=x11/' "$gtk_hook"
|
||||
echo "Patched $gtk_hook — removed GDK_BACKEND=x11"
|
||||
fi
|
||||
|
||||
# Repackage
|
||||
tools_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage \
|
||||
-o "$tools_dir/appimagetool"
|
||||
chmod +x "$tools_dir/appimagetool"
|
||||
ARCH=x86_64 "$tools_dir/appimagetool" squashfs-root "$appimage"
|
||||
|
||||
# Re-sign
|
||||
rm -f "$appimage.sig"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
npx tauri signer sign "$appimage"
|
||||
echo "Repackaged and re-signed $appimage"
|
||||
|
||||
- name: Upload Linux release assets
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
assets=(
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "::error::No Linux AppImage assets were produced."
|
||||
exit 1
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
|
||||
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
|
||||
|
||||
- name: Build Linux app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||
@@ -529,76 +283,43 @@ jobs:
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: desktop-release-windows
|
||||
workspaces: |
|
||||
.
|
||||
packages/desktop/src-tauri -> target
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||
npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -607,31 +328,27 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_draft="false"
|
||||
release_type="release"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish Windows Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --bundles nsis
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
|
||||
- name: Build Windows app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||
|
||||
3
.gitignore
vendored
@@ -46,6 +46,9 @@ test-results/
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||
- Simplified website GitHub navigation and changelog headings.
|
||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
|
||||
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
|
||||
- Hid empty filter groups in the left sidebar.
|
||||
|
||||
### Fixed
|
||||
@@ -188,7 +188,7 @@
|
||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||
- Improved desktop settings and permissions UX in Tauri.
|
||||
- Improved desktop settings and permissions UX on desktop.
|
||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||
|
||||
### Fixed
|
||||
@@ -227,7 +227,7 @@
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## 0.1.7 - 2026-02-16
|
||||
|
||||
@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
|
||||
- `packages/app` — Mobile + web client (Expo)
|
||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||
- `packages/relay` — E2E encrypted relay for remote access
|
||||
- `packages/desktop` — Tauri desktop wrapper
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -44,7 +44,7 @@ Quick monorepo package map:
|
||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Tauri desktop app
|
||||
- `packages/desktop`: Electron desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||
│ (Expo) │ │ (Commander) │ │ (Tauri) │
|
||||
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ Managed subprocess
|
||||
@@ -90,9 +90,9 @@ Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### `packages/desktop` — Desktop app (Tauri)
|
||||
### `packages/desktop` — Desktop app (Electron)
|
||||
|
||||
Tauri wrapper for macOS, Linux, and Windows.
|
||||
Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
- Can spawn the daemon as a managed subprocess
|
||||
- Native file access for workspace integration
|
||||
@@ -180,5 +180,5 @@ $PASEO_HOME/
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Tauri app spawns daemon as subprocess
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
@@ -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** |
|
||||
@@ -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
|
||||
@@ -31,6 +31,7 @@ npm run release:finalize # Publish npm, promote draft to published
|
||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
@@ -53,6 +54,7 @@ If the fix requires a code change (e.g. a broken build script), commit the fix t
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
||||
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# Split Panes Plan
|
||||
|
||||
**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane).
|
||||
|
||||
## Data Model
|
||||
|
||||
### Core Types
|
||||
|
||||
```typescript
|
||||
interface SplitPane {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
focusedTabId: string | null;
|
||||
}
|
||||
|
||||
interface SplitGroup {
|
||||
id: string;
|
||||
direction: "horizontal" | "vertical";
|
||||
children: SplitNode[];
|
||||
sizes: number[]; // proportional, sum to 1, same length as children
|
||||
}
|
||||
|
||||
type SplitNode =
|
||||
| { kind: "pane"; pane: SplitPane }
|
||||
| { kind: "group"; group: SplitGroup };
|
||||
|
||||
interface WorkspaceLayout {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store.
|
||||
- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane.
|
||||
- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use.
|
||||
- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering.
|
||||
- **Max depth: 4 levels.**
|
||||
- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%).
|
||||
|
||||
### Default State
|
||||
|
||||
Every workspace starts with:
|
||||
|
||||
```typescript
|
||||
{
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } },
|
||||
focusedPaneId: "main",
|
||||
}
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree.
|
||||
|
||||
## Store Actions
|
||||
|
||||
### Everyday Operations (pane-agnostic)
|
||||
|
||||
These don't take a `paneId`. Mobile code only uses these.
|
||||
|
||||
```typescript
|
||||
openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null;
|
||||
closeTab(workspaceKey: string, tabId: string): void;
|
||||
focusTab(workspaceKey: string, tabId: string): void;
|
||||
retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null;
|
||||
reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane
|
||||
getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes
|
||||
```
|
||||
|
||||
- `openTab` creates the tab and adds it to the focused pane.
|
||||
- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane.
|
||||
- `focusTab` finds the tab in any pane, focuses it and focuses that pane.
|
||||
|
||||
### Split Operations (desktop only)
|
||||
|
||||
```typescript
|
||||
splitPane(workspaceKey: string, input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}): string | null; // new pane ID, or null if depth cap hit
|
||||
|
||||
moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void;
|
||||
focusPane(workspaceKey: string, paneId: string): void;
|
||||
resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void;
|
||||
reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void;
|
||||
```
|
||||
|
||||
## Tree Transformations
|
||||
|
||||
### splitPane
|
||||
|
||||
**Position mapping:**
|
||||
- `left` / `right` → `horizontal` direction
|
||||
- `top` / `bottom` → `vertical` direction
|
||||
- `left` / `top` → new pane inserted before target
|
||||
- `right` / `bottom` → new pane inserted after target
|
||||
|
||||
**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat.
|
||||
|
||||
```
|
||||
Before: horizontal([A, B])
|
||||
Split B right with tab X
|
||||
|
||||
Optimized: horizontal([A, B, C]) ← insert into existing group
|
||||
Naive: horizontal([A, horizontal([B, C])]) ← wastes depth
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Check depth — reject if would exceed 4 levels
|
||||
2. Remove `tabId` from source pane (could be same or different pane)
|
||||
3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }`
|
||||
4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane
|
||||
5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position)
|
||||
6. If source pane is now empty → collapse it
|
||||
7. Set `focusedPaneId` to new pane
|
||||
|
||||
### collapsePane
|
||||
|
||||
Triggered when a pane's last tab is removed or moved out.
|
||||
|
||||
```
|
||||
Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3]
|
||||
B loses last tab
|
||||
|
||||
After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized)
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Remove pane from parent group's children
|
||||
2. Remove corresponding entry from parent's sizes
|
||||
3. Renormalize sizes to sum to 1
|
||||
4. If parent group now has 1 child → unwrap: replace group with its single remaining child
|
||||
5. Unwrap can cascade up the tree
|
||||
6. Move focus to nearest sibling
|
||||
|
||||
### moveTabToPane
|
||||
|
||||
Tab dragged from one pane to another existing pane.
|
||||
|
||||
1. Remove `tabId` from source pane's `tabIds`
|
||||
2. Insert into target pane's `tabIds` at drop position (or end)
|
||||
3. Set target pane's `focusedTabId` to the moved tab
|
||||
4. If source pane is now empty → collapsePane
|
||||
5. Set `focusedPaneId` to target pane
|
||||
|
||||
### resizeSplit
|
||||
|
||||
User drags a divider between panes.
|
||||
|
||||
1. Find group by ID
|
||||
2. Update the two adjacent sizes based on drag delta
|
||||
3. Clamp each child to minimum proportion (0.1)
|
||||
4. Renormalize so sizes sum to 1
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
|---|---|
|
||||
| Split right | `Cmd+\` |
|
||||
| Split down | `Cmd+Shift+\` |
|
||||
| Focus pane left | `Cmd+Shift+←` |
|
||||
| Focus pane right | `Cmd+Shift+→` |
|
||||
| Focus pane up | `Cmd+Shift+↑` |
|
||||
| Focus pane down | `Cmd+Shift+↓` |
|
||||
| Move tab to pane left | `Cmd+Shift+Alt+←` |
|
||||
| Move tab to pane right | `Cmd+Shift+Alt+→` |
|
||||
| Move tab to pane up | `Cmd+Shift+Alt+↑` |
|
||||
| Move tab to pane down | `Cmd+Shift+Alt+↓` |
|
||||
| Close pane | `Cmd+Shift+W` |
|
||||
|
||||
Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs.
|
||||
|
||||
## Drag and Drop UX
|
||||
|
||||
### Drop Zones
|
||||
|
||||
When dragging a tab over a pane, the pane is divided into 5 drop zones:
|
||||
- **Center** (inner 40%) — move tab to this pane (add to existing tab list)
|
||||
- **Left edge** (leftmost 15%) — split left
|
||||
- **Right edge** (rightmost 15%) — split right
|
||||
- **Top edge** (topmost 15%) — split up
|
||||
- **Bottom edge** (bottommost 15%) — split down
|
||||
|
||||
### Overlay Preview
|
||||
|
||||
On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity.
|
||||
|
||||
### Cross-Pane Tab Drag
|
||||
|
||||
Tabs can be dragged:
|
||||
- Within a pane's tab bar → reorder (existing behavior via SortableInlineList)
|
||||
- From one pane's tab bar to another pane's tab bar → move tab to that pane
|
||||
- From a tab bar to a pane's drop zone → split
|
||||
|
||||
When dragging the last tab out of a pane, the pane collapses after the drop completes.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Layout Store
|
||||
|
||||
Create `packages/app/src/stores/workspace-layout-store.ts`:
|
||||
- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types
|
||||
- Zustand store with AsyncStorage persistence
|
||||
- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs`
|
||||
- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs`
|
||||
- Version 6 migration from flat tab store
|
||||
|
||||
### Step 2: Migrate Workspace Screen to Layout Store
|
||||
|
||||
Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved.
|
||||
|
||||
### Step 3: Split Tree Transformations
|
||||
|
||||
Add to the layout store:
|
||||
- `splitPane` with the parent-direction optimization and depth check
|
||||
- `collapsePane` with unwrap cascading
|
||||
- `moveTabToPane`
|
||||
- `resizeSplit`
|
||||
|
||||
Pure tree transformation functions, tested independently.
|
||||
|
||||
### Step 4: Split Container Component
|
||||
|
||||
Create `packages/app/src/components/split-container.tsx`:
|
||||
- Recursive component that renders `SplitNode`
|
||||
- Groups render as flex containers with direction from `SplitGroup.direction`
|
||||
- Panes render tab bar + active panel content (using the panel registry)
|
||||
- Resize handles between children of a group
|
||||
|
||||
### Step 5: Drop Zones and Overlay
|
||||
|
||||
Create `packages/app/src/components/split-drop-zone.tsx`:
|
||||
- Overlay that appears during tab drag
|
||||
- Divides pane into 5 zones (center + 4 edges)
|
||||
- Shows preview rectangle on hover
|
||||
- Calls `splitPane` or `moveTabToPane` on drop
|
||||
|
||||
### Step 6: Cross-Pane Drag
|
||||
|
||||
Extend the existing dnd-kit setup:
|
||||
- Tab bar items remain draggable (existing)
|
||||
- Pane drop zones become droppable targets
|
||||
- Tab bar of other panes become droppable targets (move to pane)
|
||||
- DndContext wraps the entire split container (not individual panes)
|
||||
|
||||
### Step 7: Keyboard Shortcuts
|
||||
|
||||
Register new actions in `keyboard/actions.ts`:
|
||||
- `workspace.pane.split.right`, `workspace.pane.split.down`
|
||||
- `workspace.pane.focus.left/right/up/down`
|
||||
- `workspace.pane.move-tab.left/right/up/down`
|
||||
- `workspace.pane.close`
|
||||
|
||||
Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen.
|
||||
|
||||
### Step 8: Pane Focus Navigation
|
||||
|
||||
Implement spatial navigation for `focus.left/right/up/down`:
|
||||
- Walk the tree to find the focused pane's position in the layout
|
||||
- Find the nearest pane in the requested direction
|
||||
- Focus it
|
||||
|
||||
Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Mobile stays single-pane — same store, no special casing
|
||||
- Max 4 levels of nesting
|
||||
- Minimum pane size: 10% of parent
|
||||
- `PaneContext` interface unchanged — no `paneId` added
|
||||
- Panel registry unchanged — panels don't know about splits
|
||||
- Existing tab shortcuts work on focused pane, unchanged
|
||||
3194
package-lock.json
generated
@@ -56,7 +56,6 @@
|
||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
|
||||
@@ -2,17 +2,10 @@
|
||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||
polyfillCrypto();
|
||||
|
||||
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||
polyfillScreenOrientation();
|
||||
|
||||
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
|
||||
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
|
||||
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
|
||||
attachConsole();
|
||||
});
|
||||
}
|
||||
|
||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||
import "./src/styles/unistyles";
|
||||
import "expo-router/entry";
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -24,7 +23,6 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "expo export --platform web",
|
||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -60,9 +58,7 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
|
||||
54
packages/app/public/index.html
Normal 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>
|
||||
@@ -53,11 +53,10 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
@@ -65,19 +64,18 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { attachConsole } from "@/utils/tauri-attach-console";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
polyfillCrypto();
|
||||
attachConsole();
|
||||
const HostRuntimeBootstrapContext = createContext(false);
|
||||
|
||||
function PushNotificationRouter() {
|
||||
@@ -86,33 +84,37 @@ function PushNotificationRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (getTauri()) {
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (getIsDesktop()) {
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||
"notification-click",
|
||||
(payload: unknown) => {
|
||||
const data =
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"data" in payload &&
|
||||
typeof (payload as { data?: unknown }).data === "object" &&
|
||||
(payload as { data?: unknown }).data !== null
|
||||
? ((payload as { data: Record<string, unknown> }).data)
|
||||
: undefined;
|
||||
router.push(buildNotificationRoute(data) as any);
|
||||
}
|
||||
);
|
||||
|
||||
void listenToDesktopNotificationClicks((payload) => {
|
||||
router.push(buildNotificationRoute(payload.data) as any);
|
||||
})
|
||||
.then((cleanup) => {
|
||||
if (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
unlisten = cleanup;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[OSNotifications][Desktop] Failed to register notification click listener",
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||
if (typeof unlisten !== "function") {
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
removeDesktopNotificationListener = unlisten;
|
||||
});
|
||||
}
|
||||
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
@@ -126,7 +128,10 @@ function PushNotificationRouter() {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeDesktopNotificationListener?.();
|
||||
target.removeEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
@@ -476,12 +481,24 @@ function OfferLinkListener({
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
useFaviconStatus();
|
||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const shouldShowAppChrome = activeServerId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId || hosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useSyncExternalStore, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'expo-router'
|
||||
import { useHosts } from '@/runtime/host-runtime'
|
||||
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
|
||||
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
|
||||
import { buildHostRootRoute } from '@/utils/host-routes'
|
||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||
@@ -57,7 +57,7 @@ export default function Index() {
|
||||
const pathname = usePathname()
|
||||
const daemons = useHosts()
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
||||
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
||||
const isDesktopStartupRace = shouldUseDesktopDaemon()
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isTauriEnvironment } from "@/utils/tauri";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isTauriEnvironment()) {
|
||||
if (isDesktop()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
getIsTauriMac,
|
||||
getIsDesktopMac,
|
||||
} from "@/constants/layout";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
@@ -37,18 +37,21 @@ export function ScreenHeader({
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarTrafficLightInset =
|
||||
!isMobile && !desktopAgentListOpen && getIsTauriMac()
|
||||
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
|
||||
? trafficLightPadding.left
|
||||
: 0;
|
||||
|
||||
// On Tauri macOS, enable window dragging and double-click to maximize
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
<View
|
||||
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
|
||||
style={[
|
||||
styles.row,
|
||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
||||
dragRegionStyle,
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -13,10 +13,10 @@ export function KeyboardShortcutsDialog() {
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isTauri = getIsTauri();
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
|
||||
[isMac, isTauri]
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||
[isDesktopApp, isMac]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,7 @@ import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
||||
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
|
||||
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
|
||||
import { formatConnectionStatus } from '@/utils/daemons'
|
||||
@@ -130,7 +130,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation()
|
||||
const dragHandlers = useTauriDragHandlers()
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
|
||||
const trafficLightPadding = useTrafficLightPadding()
|
||||
const closeTouchStartX = useSharedValue(0)
|
||||
const closeTouchStartY = useSharedValue(0)
|
||||
@@ -448,9 +448,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
{trafficLightPadding.top > 0 ? (
|
||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||
<View style={[{ height: trafficLightPadding.top }, dragRegionStyle]} {...dragHandlers} />
|
||||
) : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={[styles.sidebarHeader, dragRegionStyle]} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
import { getIsTauri } from '@/constants/layout'
|
||||
import { getIsDesktop } from '@/constants/layout'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
import {
|
||||
buildHostWorkspaceRouteWithOpenIntent,
|
||||
@@ -1407,10 +1407,10 @@ export function SidebarWorkspaceList({
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map()
|
||||
)
|
||||
const isTauri = getIsTauri()
|
||||
const isDesktopApp = getIsDesktop()
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
|
||||
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown)
|
||||
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { isDesktop, isDesktopMac } from "@/desktop/host";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
|
||||
@@ -14,60 +14,56 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
// Max width for chat content (stream view, input area, new agent form)
|
||||
export const MAX_CONTENT_WIDTH = 820;
|
||||
|
||||
// Tauri desktop app constants for macOS traffic light buttons
|
||||
// Desktop app constants for macOS traffic light buttons
|
||||
// These buttons (close/minimize/maximize) overlay the top-left corner
|
||||
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
|
||||
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
|
||||
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
||||
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||
|
||||
// Check if running in Tauri desktop app (any OS)
|
||||
function isTauri(): boolean {
|
||||
// Check if running in desktop app (any OS)
|
||||
function isDesktopEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return getTauri() !== null;
|
||||
return isDesktop();
|
||||
}
|
||||
|
||||
// Check if running in Tauri desktop app on macOS
|
||||
function isTauriMac(): boolean {
|
||||
// Check if running in desktop host on macOS
|
||||
function isDesktopEnvironmentMac(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
if (getTauri() === null) return false;
|
||||
// Check for macOS via user agent
|
||||
const ua = navigator.userAgent;
|
||||
return ua.includes("Mac OS") || ua.includes("Macintosh");
|
||||
return isDesktopMac();
|
||||
}
|
||||
|
||||
// Cached result - only cache true, keep checking if false (in case Tauri globals load later)
|
||||
let _isTauriMacCached: boolean | null = null;
|
||||
let _isTauriCached: boolean | null = null;
|
||||
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
|
||||
let _isDesktopMacCached: boolean | null = null;
|
||||
let _isDesktopCached: boolean | null = null;
|
||||
|
||||
export function getIsTauriMac(): boolean {
|
||||
if (_isTauriMacCached === true) {
|
||||
export function getIsDesktopMac(): boolean {
|
||||
if (_isDesktopMacCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isTauriMac();
|
||||
const result = isDesktopEnvironmentMac();
|
||||
if (result) {
|
||||
_isTauriMacCached = true;
|
||||
_isDesktopMacCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getIsTauri(): boolean {
|
||||
if (_isTauriCached === true) {
|
||||
export function getIsDesktop(): boolean {
|
||||
if (_isDesktopCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isTauri();
|
||||
const result = isDesktopEnvironment();
|
||||
if (result) {
|
||||
_isTauriCached = true;
|
||||
_isDesktopCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get traffic light padding values (only non-zero on Tauri macOS)
|
||||
// Get traffic light padding values (only non-zero on desktop macOS)
|
||||
export function getTrafficLightPadding(): { left: number; top: number } {
|
||||
if (!getIsTauriMac()) {
|
||||
if (!getIsDesktopMac()) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
return {
|
||||
left: TAURI_TRAFFIC_LIGHT_WIDTH,
|
||||
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
interface AttachmentFileResult {
|
||||
path: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ const { invokeDesktopCommandMock } = vi.hoisted(() => ({
|
||||
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
|
||||
vi.mock("@/desktop/electron/invoke", () => ({
|
||||
invokeDesktopCommand: invokeDesktopCommandMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { fileUriToPath } from "@/attachments/utils";
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
|
||||
@@ -20,21 +20,21 @@ import { Button } from '@/components/ui/button'
|
||||
import { useAppSettings } from '@/hooks/use-settings'
|
||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
|
||||
import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
|
||||
import {
|
||||
getCliSymlinkInstructions,
|
||||
getManagedDaemonLogs,
|
||||
getManagedDaemonPairing,
|
||||
getManagedDaemonStatus,
|
||||
restartManagedDaemon,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
startManagedDaemon,
|
||||
stopManagedDaemon,
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonPairing,
|
||||
getDesktopDaemonStatus,
|
||||
restartDesktopDaemon,
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
type CliSymlinkInstructions,
|
||||
type ManagedDaemonLogs,
|
||||
type ManagedPairingOffer,
|
||||
type ManagedDaemonStatus,
|
||||
} from '@/desktop/managed-runtime/managed-runtime'
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
type DesktopPairingOffer,
|
||||
} from '@/desktop/daemon/desktop-daemon'
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null
|
||||
@@ -46,33 +46,35 @@ export function LocalDaemonSection({
|
||||
showLifecycleControls,
|
||||
}: LocalDaemonSectionProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const showSection = shouldUseManagedDesktopDaemon()
|
||||
const showSection = shouldUseDesktopDaemon()
|
||||
const { settings, updateSettings } = useAppSettings()
|
||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
|
||||
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null)
|
||||
const [daemonVersion, setDaemonVersion] = useState<string | null>(null)
|
||||
const [statusError, setStatusError] = useState<string | null>(null)
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
|
||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
|
||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null)
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
|
||||
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
|
||||
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
|
||||
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
|
||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
||||
useState<CliSymlinkInstructions | null>(null)
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
|
||||
|
||||
const loadManagedStatus = useCallback(() => {
|
||||
const loadDaemonData = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setManagedStatus(status)
|
||||
setManagedLogs(logs)
|
||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
|
||||
.then(([status, logs, version]) => {
|
||||
setDaemonStatus(status)
|
||||
setDaemonLogs(logs)
|
||||
setDaemonVersion(version.version)
|
||||
setStatusError(null)
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -86,35 +88,31 @@ export function LocalDaemonSection({
|
||||
if (!showSection) {
|
||||
return undefined
|
||||
}
|
||||
void loadManagedStatus()
|
||||
void loadDaemonData()
|
||||
return undefined
|
||||
}, [loadManagedStatus, showSection])
|
||||
}, [loadDaemonData, showSection])
|
||||
)
|
||||
|
||||
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
|
||||
const daemonStatusStateText =
|
||||
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
|
||||
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
|
||||
statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
|
||||
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
|
||||
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
|
||||
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
|
||||
const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
|
||||
const daemonActionMessage =
|
||||
managedStatus?.status === 'running'
|
||||
daemonStatus?.status === 'running'
|
||||
? 'Restarts the built-in daemon.'
|
||||
: 'Starts the built-in daemon.'
|
||||
|
||||
const handleUpdateLocalDaemon = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return
|
||||
}
|
||||
if (isRestartingDaemon) {
|
||||
if (!showSection || isRestartingDaemon) {
|
||||
return
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: daemonActionLabel,
|
||||
message:
|
||||
managedStatus?.status === 'running'
|
||||
daemonStatus?.status === 'running'
|
||||
? 'This will restart the built-in daemon. The app will reconnect automatically.'
|
||||
: 'This will start the built-in daemon.',
|
||||
confirmLabel: daemonActionLabel,
|
||||
@@ -128,19 +126,18 @@ export function LocalDaemonSection({
|
||||
setIsRestartingDaemon(true)
|
||||
setStatusMessage(null)
|
||||
|
||||
const action =
|
||||
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
|
||||
const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
|
||||
|
||||
void action()
|
||||
.then((status) => {
|
||||
setManagedStatus(status)
|
||||
setDaemonStatus(status)
|
||||
setStatusMessage(
|
||||
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
|
||||
daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
|
||||
)
|
||||
return loadManagedStatus()
|
||||
return loadDaemonData()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Settings] Failed to change managed daemon state', error)
|
||||
console.error('[Settings] Failed to change desktop daemon state', error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
|
||||
})
|
||||
@@ -149,10 +146,10 @@ export function LocalDaemonSection({
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Settings] Failed to open managed daemon action confirmation', error)
|
||||
console.error('[Settings] Failed to open desktop daemon action confirmation', error)
|
||||
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
||||
})
|
||||
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
|
||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection])
|
||||
|
||||
const handleToggleDaemonManagement = useCallback(() => {
|
||||
if (isUpdatingDaemonManagement) {
|
||||
@@ -193,13 +190,13 @@ export function LocalDaemonSection({
|
||||
setStatusMessage(null)
|
||||
|
||||
const stopPromise =
|
||||
managedStatus?.status === 'running'
|
||||
? stopManagedDaemon()
|
||||
: Promise.resolve(managedStatus ?? null)
|
||||
daemonStatus?.status === 'running'
|
||||
? stopDesktopDaemon()
|
||||
: Promise.resolve(daemonStatus ?? null)
|
||||
|
||||
void stopPromise
|
||||
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||
.then(() => loadManagedStatus())
|
||||
.then(() => loadDaemonData())
|
||||
.then(() => {
|
||||
setStatusMessage('Built-in daemon paused and stopped.')
|
||||
})
|
||||
@@ -216,9 +213,9 @@ export function LocalDaemonSection({
|
||||
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
||||
})
|
||||
}, [
|
||||
daemonStatus,
|
||||
isUpdatingDaemonManagement,
|
||||
loadManagedStatus,
|
||||
managedStatus,
|
||||
loadDaemonData,
|
||||
settings.manageBuiltInDaemon,
|
||||
updateSettings,
|
||||
])
|
||||
@@ -258,7 +255,7 @@ export function LocalDaemonSection({
|
||||
}, [cliSymlinkInstructions?.commands])
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
const logPath = managedLogs?.logPath
|
||||
const logPath = daemonLogs?.logPath
|
||||
if (!logPath) {
|
||||
return
|
||||
}
|
||||
@@ -271,14 +268,14 @@ export function LocalDaemonSection({
|
||||
console.error('[Settings] Failed to copy log path', error)
|
||||
Alert.alert('Error', 'Unable to copy log path.')
|
||||
})
|
||||
}, [managedLogs?.logPath])
|
||||
}, [daemonLogs?.logPath])
|
||||
|
||||
const handleOpenLogs = useCallback(() => {
|
||||
if (!managedLogs) {
|
||||
if (!daemonLogs) {
|
||||
return
|
||||
}
|
||||
setIsLogsModalOpen(true)
|
||||
}, [managedLogs])
|
||||
}, [daemonLogs])
|
||||
|
||||
const handleOpenPairingModal = useCallback(() => {
|
||||
if (isLoadingPairing) {
|
||||
@@ -289,7 +286,7 @@ export function LocalDaemonSection({
|
||||
setIsLoadingPairing(true)
|
||||
setPairingStatusMessage(null)
|
||||
|
||||
void getManagedDaemonPairing()
|
||||
void getDesktopDaemonPairing()
|
||||
.then((pairing) => {
|
||||
setPairingOffer(pairing)
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
@@ -344,7 +341,7 @@ export function LocalDaemonSection({
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Status</Text>
|
||||
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
|
||||
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -398,7 +395,7 @@ export function LocalDaemonSection({
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.status === 'running'
|
||||
? daemonStatus?.status === 'running'
|
||||
? 'Restarting...'
|
||||
: 'Starting...'
|
||||
: daemonActionLabel}
|
||||
@@ -425,10 +422,10 @@ export function LocalDaemonSection({
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Log file</Text>
|
||||
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{managedLogs?.logPath ? (
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -443,7 +440,7 @@ export function LocalDaemonSection({
|
||||
size="sm"
|
||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!managedLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
@@ -523,9 +520,9 @@ export function LocalDaemonSection({
|
||||
snapPoints={['70%', '92%']}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
|
||||
{daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
|
||||
</Text>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -537,7 +534,7 @@ const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
|
||||
|
||||
function PairingOfferDialogContent(input: {
|
||||
isLoading: boolean
|
||||
pairingOffer: ManagedPairingOffer | null
|
||||
pairingOffer: DesktopPairingOffer | null
|
||||
statusMessage: string | null
|
||||
onCopyLink: () => void
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const managedRuntimeMock = vi.hoisted(() => {
|
||||
const desktopDaemonMock = vi.hoisted(() => {
|
||||
let eventHandler: ((payload: {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
@@ -40,32 +40,32 @@ const managedRuntimeMock = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
|
||||
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
|
||||
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
|
||||
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
|
||||
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
|
||||
vi.mock("./desktop-daemon", () => ({
|
||||
openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
|
||||
listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
|
||||
sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
|
||||
closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
|
||||
}));
|
||||
|
||||
describe("managed-tauri-daemon-transport", () => {
|
||||
describe("desktop-daemon-transport", () => {
|
||||
beforeEach(() => {
|
||||
managedRuntimeMock.openLocalTransportSession.mockReset();
|
||||
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
|
||||
managedRuntimeMock.sendLocalTransportMessage.mockClear();
|
||||
managedRuntimeMock.closeLocalTransportSession.mockClear();
|
||||
desktopDaemonMock.openLocalTransportSession.mockReset();
|
||||
desktopDaemonMock.listenToLocalTransportEvents.mockClear();
|
||||
desktopDaemonMock.sendLocalTransportMessage.mockClear();
|
||||
desktopDaemonMock.closeLocalTransportSession.mockClear();
|
||||
});
|
||||
|
||||
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
|
||||
let resolveSession!: (sessionId: string) => void;
|
||||
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
||||
desktopDaemonMock.openLocalTransportSession.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveSession = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const mod = await import("./managed-tauri-daemon-transport");
|
||||
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
||||
const mod = await import("./desktop-daemon-transport");
|
||||
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({
|
||||
@@ -75,7 +75,7 @@ describe("managed-tauri-daemon-transport", () => {
|
||||
const onOpen = vi.fn();
|
||||
transport.onOpen(onOpen);
|
||||
|
||||
managedRuntimeMock.emitEvent({
|
||||
desktopDaemonMock.emitEvent({
|
||||
sessionId: "local-session-1",
|
||||
kind: "open",
|
||||
});
|
||||
@@ -93,21 +93,21 @@ describe("managed-tauri-daemon-transport", () => {
|
||||
let resolveListen!: (cleanup: () => void) => void;
|
||||
const cleanup = vi.fn();
|
||||
|
||||
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
||||
desktopDaemonMock.openLocalTransportSession.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveSession = resolve;
|
||||
})
|
||||
);
|
||||
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
|
||||
desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
|
||||
() =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
resolveListen = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const mod = await import("./managed-tauri-daemon-transport");
|
||||
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
||||
const mod = await import("./desktop-daemon-transport");
|
||||
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({
|
||||
@@ -121,7 +121,7 @@ describe("managed-tauri-daemon-transport", () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
|
||||
expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
type LocalTransportTarget,
|
||||
} from "@/desktop/managed-runtime/managed-runtime";
|
||||
} from "./desktop-daemon";
|
||||
|
||||
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||
|
||||
@@ -49,7 +49,7 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
|
||||
export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null {
|
||||
return ({ url }) => {
|
||||
const target = parseLocalDaemonTransportUrl(url);
|
||||
let sessionId: string | null = null;
|
||||
@@ -1,29 +1,24 @@
|
||||
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
|
||||
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
|
||||
import { getDesktopHost, isDesktop } from '@/desktop/host'
|
||||
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
|
||||
|
||||
export type ManagedRuntimeStatus = {
|
||||
runtimeId: string
|
||||
runtimeVersion: string
|
||||
runtimeRoot: string
|
||||
}
|
||||
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
|
||||
|
||||
export type ManagedDaemonStatus = {
|
||||
runtimeId: string
|
||||
runtimeVersion: string
|
||||
export type DesktopDaemonStatus = {
|
||||
serverId: string
|
||||
status: string
|
||||
status: DesktopDaemonState
|
||||
listen: string
|
||||
hostname: string | null
|
||||
pid: number | null
|
||||
home: string
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type ManagedDaemonLogs = {
|
||||
export type DesktopDaemonLogs = {
|
||||
logPath: string
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type ManagedPairingOffer = {
|
||||
export type DesktopPairingOffer = {
|
||||
relayEnabled: boolean
|
||||
url: string | null
|
||||
qr: string | null
|
||||
@@ -35,12 +30,6 @@ export type CliSymlinkInstructions = {
|
||||
commands: string
|
||||
}
|
||||
|
||||
export type ManagedTcpSettings = {
|
||||
enabled: boolean
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
export type LocalTransportTarget = {
|
||||
transportType: 'socket' | 'pipe'
|
||||
transportPath: string
|
||||
@@ -68,36 +57,42 @@ function toNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error('Unexpected managed runtime status response.')
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
|
||||
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
|
||||
const normalized = toStringOrNull(value)?.toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'starting':
|
||||
return 'starting'
|
||||
case 'running':
|
||||
return 'running'
|
||||
case 'errored':
|
||||
case 'error':
|
||||
return 'errored'
|
||||
case 'stopped':
|
||||
case 'stopping':
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
|
||||
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error('Unexpected managed daemon status response.')
|
||||
throw new Error('Unexpected desktop daemon status response.')
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
|
||||
serverId: toStringOrNull(raw.serverId) ?? '',
|
||||
status: toStringOrNull(raw.status) ?? 'unknown',
|
||||
status: parseDesktopDaemonState(raw.status),
|
||||
listen: toStringOrNull(raw.listen) ?? '',
|
||||
hostname: toStringOrNull(raw.hostname),
|
||||
pid: toNumberOrNull(raw.pid),
|
||||
home: toStringOrNull(raw.home) ?? '',
|
||||
error: toStringOrNull(raw.error),
|
||||
}
|
||||
}
|
||||
|
||||
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
|
||||
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error('Unexpected managed daemon logs response.')
|
||||
throw new Error('Unexpected desktop daemon logs response.')
|
||||
}
|
||||
return {
|
||||
logPath: toStringOrNull(raw.logPath) ?? '',
|
||||
@@ -105,9 +100,9 @@ function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
|
||||
}
|
||||
}
|
||||
|
||||
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
|
||||
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error('Unexpected managed daemon pairing response.')
|
||||
throw new Error('Unexpected desktop daemon pairing response.')
|
||||
}
|
||||
return {
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
@@ -127,36 +122,32 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUseManagedDesktopDaemon(): boolean {
|
||||
return isTauriEnvironment() && getTauri() !== null
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isDesktop()
|
||||
}
|
||||
|
||||
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
|
||||
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
|
||||
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
|
||||
}
|
||||
|
||||
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
|
||||
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
|
||||
}
|
||||
|
||||
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
|
||||
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
|
||||
}
|
||||
|
||||
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
|
||||
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
|
||||
}
|
||||
|
||||
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
|
||||
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
|
||||
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
|
||||
}
|
||||
|
||||
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
|
||||
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
|
||||
}
|
||||
|
||||
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
|
||||
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
|
||||
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
|
||||
}
|
||||
|
||||
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
||||
@@ -171,27 +162,18 @@ export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstruction
|
||||
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
|
||||
}
|
||||
|
||||
export async function updateManagedDaemonTcpSettings(
|
||||
settings: ManagedTcpSettings
|
||||
): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(
|
||||
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
|
||||
)
|
||||
}
|
||||
|
||||
export type LocalTransportEventUnlisten = () => void
|
||||
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
|
||||
|
||||
export async function listenToLocalTransportEvents(
|
||||
handler: LocalTransportEventHandler
|
||||
): Promise<LocalTransportEventUnlisten> {
|
||||
const listen = getTauri()?.event?.listen
|
||||
const listen = getDesktopHost()?.events?.on
|
||||
if (typeof listen !== 'function') {
|
||||
throw new Error('Tauri event API is unavailable.')
|
||||
throw new Error('Desktop events API is unavailable.')
|
||||
}
|
||||
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
|
||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
|
||||
if (!payload) {
|
||||
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
|
||||
if (!isRecord(payload)) {
|
||||
return
|
||||
}
|
||||
handler({
|
||||
27
packages/app/src/desktop/electron/events.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export type DesktopEventUnlisten = () => void;
|
||||
|
||||
type EventEnvelope = {
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export async function listenToDesktopEvent<TPayload>(
|
||||
event: string,
|
||||
handler: (payload: TPayload) => void
|
||||
): Promise<DesktopEventUnlisten> {
|
||||
const listen = getDesktopHost()?.events?.on;
|
||||
if (typeof listen !== "function") {
|
||||
throw new Error("Desktop event API is unavailable.");
|
||||
}
|
||||
|
||||
const unlisten = await listen(event, (rawEvent: unknown) => {
|
||||
const payload =
|
||||
typeof rawEvent === "object" && rawEvent !== null && "payload" in rawEvent
|
||||
? (rawEvent as EventEnvelope).payload
|
||||
: rawEvent;
|
||||
handler(payload as TPayload);
|
||||
});
|
||||
|
||||
return typeof unlisten === "function" ? unlisten : () => {};
|
||||
}
|
||||
12
packages/app/src/desktop/electron/host.ts
Normal 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>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const invoke = getTauri()?.core?.invoke;
|
||||
const invoke = getDesktopHost()?.invoke;
|
||||
if (typeof invoke !== "function") {
|
||||
throw new Error("Tauri invoke() is unavailable in this environment.");
|
||||
throw new Error("Desktop invoke() is unavailable in this environment.");
|
||||
}
|
||||
|
||||
return (await invoke(command, args)) as T;
|
||||
}
|
||||
37
packages/app/src/desktop/electron/window.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
|
||||
|
||||
export function getDesktopWindow(): DesktopWindowBridge | null {
|
||||
const getter = getDesktopHost()?.window?.getCurrentWindow;
|
||||
if (typeof getter !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return getter() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDesktopDragging(): Promise<void> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.startDragging !== "function") {
|
||||
return;
|
||||
}
|
||||
await win.startDragging();
|
||||
}
|
||||
|
||||
export async function toggleDesktopMaximize(): Promise<void> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.toggleMaximize !== "function") {
|
||||
return;
|
||||
}
|
||||
await win.toggleMaximize();
|
||||
}
|
||||
|
||||
export async function isDesktopFullscreen(): Promise<boolean> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.isFullscreen !== "function") {
|
||||
return false;
|
||||
}
|
||||
return await win.isFullscreen();
|
||||
}
|
||||
111
packages/app/src/desktop/host.ts
Normal 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 = {
|
||||
Notification: unknown
|
||||
__TAURI__: unknown
|
||||
navigatorDescriptor?: PropertyDescriptor
|
||||
paseoDesktop: unknown
|
||||
}
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
||||
paseoDesktop:
|
||||
typeof window === 'undefined'
|
||||
? undefined
|
||||
: (window as { paseoDesktop?: unknown }).paseoDesktop,
|
||||
}
|
||||
|
||||
function setNavigator(value: unknown): void {
|
||||
@@ -24,13 +27,16 @@ function setNavigator(value: unknown): void {
|
||||
|
||||
function restoreGlobals(): void {
|
||||
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
|
||||
|
||||
if (originalGlobals.navigatorDescriptor) {
|
||||
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
|
||||
} else {
|
||||
delete (globalThis as { navigator?: unknown }).navigator
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModuleForPlatform(platform: MockPlatform) {
|
||||
@@ -47,20 +53,20 @@ describe('desktop-permissions', () => {
|
||||
restoreGlobals()
|
||||
})
|
||||
|
||||
it('shows section only in Tauri web runtime', async () => {
|
||||
it('shows section only in desktop web runtime', async () => {
|
||||
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
|
||||
|
||||
expect(shouldShowDesktopPermissionSection()).toBe(false)
|
||||
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
|
||||
;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
|
||||
expect(shouldShowDesktopPermissionSection()).toBe(true)
|
||||
})
|
||||
|
||||
it('reads notification and microphone status', async () => {
|
||||
const isPermissionGranted = vi.fn(async () => false)
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
notification: { isPermissionGranted },
|
||||
class MockNotification {
|
||||
static permission = 'default'
|
||||
}
|
||||
;(globalThis as { Notification?: unknown }).Notification = MockNotification
|
||||
setNavigator({
|
||||
permissions: {
|
||||
query: vi.fn(async () => ({ state: 'granted' })),
|
||||
@@ -73,9 +79,8 @@ describe('desktop-permissions', () => {
|
||||
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
|
||||
const snapshot = await getDesktopPermissionSnapshot()
|
||||
|
||||
expect(snapshot.notifications.state).toBe('not-granted')
|
||||
expect(snapshot.notifications.state).toBe('prompt')
|
||||
expect(snapshot.microphone.state).toBe('granted')
|
||||
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
|
||||
expect(snapshot.checkedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
@@ -127,20 +132,21 @@ describe('desktop-permissions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('requests notification permission via Tauri', async () => {
|
||||
const requestPermission = vi.fn(async () => 'granted')
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
notification: { requestPermission },
|
||||
it('requests notification permission via the browser Notification API', async () => {
|
||||
class MockNotification {
|
||||
static permission = 'default'
|
||||
static requestPermission = vi.fn(async () => 'granted')
|
||||
}
|
||||
;(globalThis as { Notification?: unknown }).Notification = MockNotification
|
||||
|
||||
const { requestDesktopPermission } = await loadModuleForPlatform('web')
|
||||
const result = await requestDesktopPermission({ kind: 'notifications' })
|
||||
|
||||
expect(result.state).toBe('granted')
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1)
|
||||
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
|
||||
it('reads browser Notification permission when available', async () => {
|
||||
class MockNotification {
|
||||
static permission = 'denied'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from 'react-native'
|
||||
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
|
||||
import { getDesktopHost } from '@/desktop/host'
|
||||
|
||||
export type DesktopPermissionKind = 'notifications' | 'microphone'
|
||||
|
||||
@@ -45,7 +45,7 @@ type NavigatorLike = {
|
||||
}
|
||||
|
||||
export function shouldShowDesktopPermissionSection(): boolean {
|
||||
return Platform.OS === 'web' && getTauri() !== null
|
||||
return Platform.OS === 'web' && getDesktopHost() !== null
|
||||
}
|
||||
|
||||
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
||||
@@ -132,27 +132,6 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
||||
})
|
||||
}
|
||||
|
||||
function mapTauriNotificationPermissionResult(
|
||||
permission: TauriNotificationPermission
|
||||
): DesktopPermissionStatus {
|
||||
if (permission === 'granted') {
|
||||
return status({
|
||||
state: 'granted',
|
||||
detail: 'Notifications are allowed by the OS.',
|
||||
})
|
||||
}
|
||||
if (permission === 'denied') {
|
||||
return status({
|
||||
state: 'denied',
|
||||
detail: 'Notifications are denied in system settings.',
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'prompt',
|
||||
detail: 'Notifications have not been granted yet.',
|
||||
})
|
||||
}
|
||||
|
||||
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== 'web') {
|
||||
return status({
|
||||
@@ -161,44 +140,30 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
|
||||
})
|
||||
}
|
||||
|
||||
const tauriNotification = getTauri()?.notification
|
||||
if (tauriNotification) {
|
||||
if (typeof tauriNotification.isPermissionGranted !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Tauri notification plugin is missing isPermissionGranted().',
|
||||
})
|
||||
}
|
||||
|
||||
const desktopHost = getDesktopHost()
|
||||
if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
|
||||
try {
|
||||
const granted = await tauriNotification.isPermissionGranted()
|
||||
if (granted) {
|
||||
return status({
|
||||
state: 'granted',
|
||||
detail: 'Tauri reports notifications are granted.',
|
||||
})
|
||||
}
|
||||
const supported = await desktopHost.notification.isSupported()
|
||||
return status({
|
||||
state: 'not-granted',
|
||||
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
|
||||
})
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
|
||||
state: supported ? 'granted' : 'unavailable',
|
||||
detail: supported
|
||||
? 'Desktop notifications are supported.'
|
||||
: 'Desktop notifications are not supported on this platform.',
|
||||
})
|
||||
} catch {
|
||||
// Fall through to web API check
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor()
|
||||
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Web Notification API is unavailable in this environment.',
|
||||
})
|
||||
if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
|
||||
return mapNotificationPermissionString(NotificationConstructor.permission)
|
||||
}
|
||||
|
||||
return mapNotificationPermissionString(NotificationConstructor.permission)
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Web Notification API is unavailable in this environment.',
|
||||
})
|
||||
}
|
||||
|
||||
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
@@ -279,18 +244,11 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
||||
})
|
||||
}
|
||||
|
||||
const tauriNotification = getTauri()?.notification
|
||||
if (tauriNotification) {
|
||||
if (typeof tauriNotification.requestPermission !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Tauri notification plugin is missing requestPermission().',
|
||||
})
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor()
|
||||
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
|
||||
try {
|
||||
const permission = await tauriNotification.requestPermission()
|
||||
return mapTauriNotificationPermissionResult(permission)
|
||||
const permission = await NotificationConstructor.requestPermission()
|
||||
return mapNotificationPermissionString(permission)
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: 'unknown',
|
||||
@@ -299,23 +257,10 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor()
|
||||
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Web Notification API requestPermission() is unavailable.',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await NotificationConstructor.requestPermission()
|
||||
return mapNotificationPermissionString(permission)
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Web Notification API requestPermission() is unavailable.',
|
||||
})
|
||||
}
|
||||
|
||||
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
|
||||
@@ -122,7 +122,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
try {
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, delivery works. Click it to verify the open flow.",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
});
|
||||
if (!sent) {
|
||||
console.warn("[Settings] Desktop test notification was not delivered");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getTauri } from '@/utils/tauri'
|
||||
import { getDesktopHost } from '@/desktop/host'
|
||||
|
||||
export async function pickDirectory(): Promise<string | null> {
|
||||
const open = getTauri()?.dialog?.open
|
||||
const open = getDesktopHost()?.dialog?.open
|
||||
if (typeof open !== 'function') {
|
||||
throw new Error('Tauri dialog open() is unavailable in this environment.')
|
||||
throw new Error('Desktop dialog open() is unavailable in this environment.')
|
||||
}
|
||||
|
||||
const selection = await open({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Platform } from 'react-native'
|
||||
import { getTauri } from '@/utils/tauri'
|
||||
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
|
||||
import { isDesktop } from '@/desktop/host'
|
||||
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
|
||||
|
||||
export interface DesktopAppUpdateCheckResult {
|
||||
hasUpdate: boolean
|
||||
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
||||
}
|
||||
|
||||
export function shouldShowDesktopUpdateSection(): boolean {
|
||||
return Platform.OS === 'web' && getTauri() !== null
|
||||
return Platform.OS === 'web' && isDesktop()
|
||||
}
|
||||
|
||||
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const tauriState = vi.hoisted(() => ({
|
||||
const desktopHostState = vi.hoisted(() => ({
|
||||
api: null as any,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/tauri", () => ({
|
||||
getTauri: () => tauriState.api,
|
||||
vi.mock("@/desktop/host", () => ({
|
||||
getDesktopHost: () => desktopHostState.api,
|
||||
}));
|
||||
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithTauriDialog,
|
||||
openImagePathsWithDesktopDialog,
|
||||
} from "./image-attachment-picker";
|
||||
|
||||
describe("image-attachment-picker", () => {
|
||||
beforeEach(() => {
|
||||
tauriState.api = null;
|
||||
desktopHostState.api = null;
|
||||
});
|
||||
|
||||
it("normalizes a picked File into a blob source", async () => {
|
||||
@@ -69,13 +69,13 @@ describe("image-attachment-picker", () => {
|
||||
expect(result[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("uses the tauri dialog api when available", async () => {
|
||||
it("uses the desktop dialog api when available", async () => {
|
||||
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
tauriState.api = {
|
||||
desktopHostState.api = {
|
||||
dialog: { open },
|
||||
};
|
||||
|
||||
const result = await openImagePathsWithTauriDialog();
|
||||
const result = await openImagePathsWithDesktopDialog();
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -87,21 +87,11 @@ describe("image-attachment-picker", () => {
|
||||
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
});
|
||||
|
||||
it("falls back to core invoke for the tauri dialog plugin", async () => {
|
||||
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
|
||||
tauriState.api = {
|
||||
core: { invoke },
|
||||
};
|
||||
it("throws when desktop dialog API is not available", async () => {
|
||||
desktopHostState.api = {};
|
||||
|
||||
const result = await openImagePathsWithTauriDialog();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
|
||||
options: expect.objectContaining({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
title: "Attach images",
|
||||
}),
|
||||
});
|
||||
expect(result).toEqual(["/tmp/one.png"]);
|
||||
await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
|
||||
"Desktop dialog API is not available."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export type PickedImageSource =
|
||||
| { kind: "file_uri"; uri: string }
|
||||
@@ -77,15 +77,15 @@ export async function normalizePickedImageAssets(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
|
||||
function normalizeDesktopDialogSelection(selection: string | string[] | null): string[] {
|
||||
if (!selection) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(selection) ? selection : [selection];
|
||||
}
|
||||
|
||||
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
|
||||
const tauri = getTauri();
|
||||
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
|
||||
const desktop = getDesktopHost();
|
||||
const options = {
|
||||
directory: false,
|
||||
multiple: true,
|
||||
@@ -93,18 +93,10 @@ export async function openImagePathsWithTauriDialog(): Promise<string[]> {
|
||||
title: "Attach images",
|
||||
};
|
||||
|
||||
const dialogOpen = tauri?.dialog?.open;
|
||||
if (typeof dialogOpen === "function") {
|
||||
return normalizeTauriDialogSelection(await dialogOpen(options));
|
||||
const dialogOpen = desktop?.dialog?.open;
|
||||
if (typeof dialogOpen !== "function") {
|
||||
throw new Error("Desktop dialog API is not available.");
|
||||
}
|
||||
|
||||
const invoke = tauri?.core?.invoke;
|
||||
if (typeof invoke !== "function") {
|
||||
throw new Error("Tauri dialog API is not available.");
|
||||
}
|
||||
|
||||
const result = await invoke("plugin:dialog|open", { options });
|
||||
return normalizeTauriDialogSelection(
|
||||
Array.isArray(result) || typeof result === "string" || result === null ? result : null
|
||||
);
|
||||
return normalizeDesktopDialogSelection(await dialogOpen(options));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
|
||||
export interface AudioCaptureConfig {
|
||||
sampleRate?: number;
|
||||
@@ -157,20 +157,20 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isTauri = getTauri() !== null;
|
||||
const isDesktopApp = isDesktop();
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
|
||||
if (!secureContext && !isTauri) {
|
||||
if (!secureContext && !isDesktopApp) {
|
||||
throw new Error(
|
||||
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
||||
);
|
||||
}
|
||||
if (!secureContext && isTauri) {
|
||||
if (!secureContext && isDesktopApp) {
|
||||
console.warn(
|
||||
"[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
|
||||
"[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
|
||||
{ currentOrigin }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { parsePcm16Wav } from "@/utils/pcm16-wav";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
|
||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||
|
||||
@@ -158,17 +158,17 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isTauri = getTauri() !== null;
|
||||
const isDesktopApp = isDesktop();
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
if (!secureContext && !isTauri) {
|
||||
if (!secureContext && !isDesktopApp) {
|
||||
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
|
||||
}
|
||||
if (!secureContext && isTauri) {
|
||||
if (!secureContext && isDesktopApp) {
|
||||
console.warn(
|
||||
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
|
||||
"[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
|
||||
{ currentOrigin }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { getIsDesktopMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { getCurrentTauriWindow } from "@/utils/tauri";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
type ColorScheme = "dark" | "light";
|
||||
@@ -92,15 +92,15 @@ function getSystemColorScheme(): ColorScheme {
|
||||
}
|
||||
|
||||
async function updateMacDockBadge(count?: number) {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
||||
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
|
||||
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
|
||||
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tauriWindow.setBadgeCount(count);
|
||||
await desktopWindow.setBadgeCount(count);
|
||||
} catch (error) {
|
||||
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import {
|
||||
persistAttachmentFromBlob,
|
||||
persistAttachmentFromFileUri,
|
||||
@@ -33,7 +33,7 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
".tiff": "image/tiff",
|
||||
};
|
||||
|
||||
type TauriDragDropPayload =
|
||||
type DesktopDragDropPayload =
|
||||
| {
|
||||
type: "enter";
|
||||
paths: string[];
|
||||
@@ -49,8 +49,8 @@ type TauriDragDropPayload =
|
||||
type: "leave";
|
||||
};
|
||||
|
||||
type TauriDragDropEvent = {
|
||||
payload: TauriDragDropPayload;
|
||||
type DesktopDragDropEvent = {
|
||||
payload: DesktopDragDropPayload;
|
||||
};
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
@@ -121,26 +121,27 @@ export function useFileDropZone({
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanupFn()).catch((error) => {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupTauriDragDrop(): Promise<boolean> {
|
||||
if (getTauri() === null) {
|
||||
async function setupDesktopDragDrop(): Promise<boolean> {
|
||||
const desktopHost = getDesktopHost();
|
||||
if (desktopHost === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||
const desktopWindow = desktopHost.window?.getCurrentWindow?.();
|
||||
if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const unlisten = await tauriWindow.onDragDropEvent(
|
||||
(event: TauriDragDropEvent) => {
|
||||
const unlisten = await desktopWindow.onDragDropEvent(
|
||||
(event: DesktopDragDropEvent) => {
|
||||
const payload = event.payload;
|
||||
if (payload.type === "leave") {
|
||||
setIsDragging(false);
|
||||
@@ -185,7 +186,7 @@ export function useFileDropZone({
|
||||
cleanup = unlisten;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
|
||||
console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -269,8 +270,8 @@ export function useFileDropZone({
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const tauriListenersAttached = await setupTauriDragDrop();
|
||||
if (disposed || tauriListenersAttached) {
|
||||
const desktopListenersAttached = await setupDesktopDragDrop();
|
||||
if (disposed || desktopListenersAttached) {
|
||||
return;
|
||||
}
|
||||
setupDomDragDrop();
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useCallback, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { isTauriEnvironment } from "@/utils/tauri";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithTauriDialog,
|
||||
openImagePathsWithDesktopDialog,
|
||||
type PickedImageAttachmentInput,
|
||||
} from "@/hooks/image-attachment-picker";
|
||||
|
||||
@@ -42,8 +42,8 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
isPickingRef.current = true;
|
||||
|
||||
try {
|
||||
if (Platform.OS === "web" && isTauriEnvironment()) {
|
||||
const selectedPaths = await openImagePathsWithTauriDialog();
|
||||
if (Platform.OS === "web" && isDesktop()) {
|
||||
const selectedPaths = await openImagePathsWithDesktopDialog();
|
||||
if (selectedPaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime'
|
||||
import { isTauriEnvironment } from '@/utils/tauri'
|
||||
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
|
||||
|
||||
const MANAGED_DAEMON_SERVER_ID_QUERY_KEY = ['managed-daemon-server-id'] as const
|
||||
const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ['desktop-daemon-server-id'] as const
|
||||
|
||||
interface ManagedDaemonServerIdResult {
|
||||
interface DesktopDaemonServerIdResult {
|
||||
serverId: string | null
|
||||
}
|
||||
|
||||
async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult> {
|
||||
const status = await getManagedDaemonStatus()
|
||||
async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult> {
|
||||
const status = await getDesktopDaemonStatus()
|
||||
const serverId = status.serverId.trim()
|
||||
return {
|
||||
serverId: serverId.length > 0 ? serverId : null,
|
||||
@@ -18,14 +17,15 @@ async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult>
|
||||
|
||||
export function useIsLocalDaemon(serverId: string): boolean {
|
||||
const normalizedServerId = serverId.trim()
|
||||
const isDesktop = isTauriEnvironment()
|
||||
const isDesktop = shouldUseDesktopDaemon()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY,
|
||||
queryFn: loadManagedDaemonServerId,
|
||||
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
|
||||
queryFn: loadDesktopDaemonServerId,
|
||||
enabled: isDesktop,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -50,7 +50,7 @@ export function useKeyboardShortcuts({
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isMobile) return;
|
||||
|
||||
const isTauri = getIsTauri();
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
|
||||
const shouldHandle = () => {
|
||||
@@ -259,7 +259,7 @@ export function useKeyboardShortcuts({
|
||||
if (key === "Alt" && !event.shiftKey) {
|
||||
useKeyboardShortcutsStore.getState().setAltDown(true);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
||||
if (isDesktopApp && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
@@ -278,7 +278,7 @@ export function useKeyboardShortcuts({
|
||||
event,
|
||||
context: {
|
||||
isMac,
|
||||
isTauri,
|
||||
isDesktop: isDesktopApp,
|
||||
focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
hasSelectedAgent: canToggleFileExplorerShortcut({
|
||||
@@ -314,7 +314,7 @@ export function useKeyboardShortcuts({
|
||||
if (key === "Alt") {
|
||||
useKeyboardShortcutsStore.getState().setAltDown(false);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control")) {
|
||||
if (isDesktopApp && (key === "Meta" || key === "Control")) {
|
||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ function shortcutContext(
|
||||
): KeyboardShortcutContext {
|
||||
return {
|
||||
isMac: false,
|
||||
isTauri: false,
|
||||
isDesktop: false,
|
||||
focusScope: "other",
|
||||
commandCenterOpen: false,
|
||||
hasSelectedAgent: true,
|
||||
@@ -84,7 +84,7 @@ type HelpSectionCase = {
|
||||
name: string;
|
||||
context: {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
isDesktop: boolean;
|
||||
};
|
||||
expectedKeys: Record<string, string[]>;
|
||||
};
|
||||
@@ -106,42 +106,42 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "matches workspace index jump on web via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isTauri: false },
|
||||
context: { isDesktop: false },
|
||||
action: "workspace.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace index jump on tauri via Mod+digit",
|
||||
name: "matches workspace index jump on desktop via Mod+digit",
|
||||
event: { key: "2", code: "Digit2", metaKey: true },
|
||||
context: { isMac: true, isTauri: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
action: "workspace.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on tauri via Alt+digit",
|
||||
name: "matches tab index jump on desktop via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isTauri: true },
|
||||
context: { isDesktop: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on web via Alt+Shift+digit",
|
||||
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
|
||||
context: { isTauri: false },
|
||||
context: { isDesktop: false },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace relative navigation on web via Alt+[",
|
||||
event: { key: "[", code: "BracketLeft", altKey: true },
|
||||
context: { isTauri: false },
|
||||
context: { isDesktop: false },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: -1 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace relative navigation on tauri via Mod+]",
|
||||
name: "matches workspace relative navigation on desktop via Mod+]",
|
||||
event: { key: "]", code: "BracketRight", ctrlKey: true },
|
||||
context: { isTauri: true },
|
||||
context: { isDesktop: true },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
@@ -160,13 +160,13 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "matches Alt+Shift+W to close current tab on web",
|
||||
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
|
||||
context: { isTauri: false },
|
||||
context: { isDesktop: false },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Mod+W to close current tab on tauri",
|
||||
name: "matches Mod+W to close current tab on desktop",
|
||||
event: { key: "w", code: "KeyW", metaKey: true },
|
||||
context: { isMac: true, isTauri: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
@@ -312,7 +312,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
const helpCases: HelpSectionCase[] = [
|
||||
{
|
||||
name: "uses web defaults for workspace and tab jump",
|
||||
context: { isMac: true, isTauri: false },
|
||||
context: { isMac: true, isDesktop: false },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
@@ -324,8 +324,8 @@ describe("keyboard-shortcut help sections", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses tauri defaults for workspace and tab jump",
|
||||
context: { isMac: true, isTauri: true },
|
||||
name: "uses desktop defaults for workspace and tab jump",
|
||||
context: { isMac: true, isDesktop: true },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
@@ -338,7 +338,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
},
|
||||
{
|
||||
name: "uses mod+period as non-mac left sidebar shortcut",
|
||||
context: { isMac: false, isTauri: false },
|
||||
context: { isMac: false, isDesktop: false },
|
||||
expectedKeys: {
|
||||
"toggle-left-sidebar": ["mod", "."],
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
|
||||
export type KeyboardShortcutContext = {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
isDesktop: boolean;
|
||||
focusScope: KeyboardFocusScope;
|
||||
commandCenterOpen: boolean;
|
||||
hasSelectedAgent: boolean;
|
||||
@@ -36,7 +36,7 @@ export type KeyboardShortcutHelpSection = {
|
||||
|
||||
type KeyboardShortcutPlatformContext = {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
isDesktop: boolean;
|
||||
};
|
||||
|
||||
type KeyboardShortcutHelpEntry = {
|
||||
@@ -158,20 +158,20 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-close-current-mod-w-tauri",
|
||||
id: "workspace-tab-close-current-mod-w-desktop",
|
||||
action: "workspace.tab.close.current",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "global",
|
||||
label: "Close current tab",
|
||||
keys: ["mod", "W"],
|
||||
when: (context) => context.isTauri,
|
||||
when: (context) => context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -183,17 +183,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "global",
|
||||
label: "Close current tab",
|
||||
keys: ["alt", "shift", "W"],
|
||||
when: (context) => !context.isTauri,
|
||||
when: (context) => !context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-navigate-index-mod-digit-tauri",
|
||||
id: "workspace-navigate-index-mod-digit-desktop",
|
||||
action: "workspace.navigate.index",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
@@ -201,13 +201,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "global",
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
when: (context) => context.isTauri,
|
||||
when: (context) => context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -220,17 +220,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "global",
|
||||
label: "Jump to workspace",
|
||||
keys: ["alt", "1-9"],
|
||||
when: (context) => !context.isTauri,
|
||||
when: (context) => !context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-index-alt-digit-tauri",
|
||||
id: "workspace-tab-navigate-index-alt-digit-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
@@ -239,13 +239,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "global",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "1-9"],
|
||||
when: (context) => context.isTauri,
|
||||
when: (context) => context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -258,13 +258,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
event.shiftKey &&
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "global",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "shift", "1-9"],
|
||||
when: (context) => !context.isTauri,
|
||||
when: (context) => !context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -276,13 +276,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketLeft" || event.key === "["),
|
||||
payload: withRelativeDelta(-1),
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "global",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
when: (context) => context.isTauri,
|
||||
when: (context) => context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -294,13 +294,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketRight" || event.key === "]"),
|
||||
payload: withRelativeDelta(1),
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "global",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
when: (context) => context.isTauri,
|
||||
when: (context) => context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -313,13 +313,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketLeft" || event.key === "["),
|
||||
payload: withRelativeDelta(-1),
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "global",
|
||||
label: "Previous workspace",
|
||||
keys: ["alt", "["],
|
||||
when: (context) => !context.isTauri,
|
||||
when: (context) => !context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -332,13 +332,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketRight" || event.key === "]"),
|
||||
payload: withRelativeDelta(1),
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "global",
|
||||
label: "Next workspace",
|
||||
keys: ["alt", "]"],
|
||||
when: (context) => !context.isTauri,
|
||||
when: (context) => !context.isDesktop,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||
import {
|
||||
shouldUseManagedDesktopDaemon,
|
||||
startManagedDaemon,
|
||||
} from "@/desktop/managed-runtime/managed-runtime";
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
} from "@/utils/connection-selection";
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "@/utils/managed-tauri-daemon-transport";
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
@@ -432,7 +432,7 @@ function probeIntervalForConnection(
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
clientId,
|
||||
@@ -1188,7 +1188,7 @@ export class HostRuntimeStore {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUseManagedDesktopDaemon()) {
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapDesktop();
|
||||
} else {
|
||||
await this.bootstrapLocalhost();
|
||||
@@ -1197,43 +1197,22 @@ export class HostRuntimeStore {
|
||||
|
||||
private async bootstrapDesktop(): Promise<void> {
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
const daemon = await startManagedDaemon();
|
||||
if (!daemon.serverId) {
|
||||
return;
|
||||
}
|
||||
const connection = connectionFromListen(daemon.listen);
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
await this.upsertHostConnection({
|
||||
serverId: daemon.serverId,
|
||||
label: daemon.hostname ?? undefined,
|
||||
connection,
|
||||
});
|
||||
})().catch((error) => {
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
||||
}),
|
||||
(async () => {
|
||||
const { client, serverId, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
||||
type: "directTcp",
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
},
|
||||
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
||||
);
|
||||
await this.upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
label: hostname ?? undefined,
|
||||
existingClient: client,
|
||||
});
|
||||
})().catch(() => undefined),
|
||||
]);
|
||||
const daemon = await startDesktopDaemon();
|
||||
const connection = connectionFromListen(daemon.listen);
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||
});
|
||||
await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: hostname ?? daemon.hostname ?? undefined,
|
||||
connection,
|
||||
existingClient: client,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop startup host connections", error);
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import type {
|
||||
} from '@server/server/agent/agent-sdk-types'
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
|
||||
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
||||
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
||||
import { useDesktopDragHandlers } from '@/utils/desktop-window'
|
||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
||||
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
||||
@@ -238,7 +238,7 @@ function DraftAgentScreenContent({
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
(state) => state.activateExplorerTabForCheckout
|
||||
)
|
||||
const tauriDragHandlers = useTauriDragHandlers()
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
|
||||
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
||||
const draftIdRef = useRef(generateDraftId())
|
||||
const draftAgentIdRef = useRef(generateDraftId())
|
||||
@@ -974,7 +974,7 @@ function DraftAgentScreenContent({
|
||||
const explorerServerId = draftExplorerCheckout?.serverId ?? null
|
||||
const explorerIsGit = draftExplorerCheckout?.isGit ?? false
|
||||
const mainContent = (
|
||||
<View style={styles.container} {...tauriDragHandlers}>
|
||||
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||
<View style={styles.outerContainer}>
|
||||
<View style={styles.agentPanel}>
|
||||
<View
|
||||
|
||||
@@ -7,8 +7,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { getIsDesktopMac } from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -19,9 +19,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
|
||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
@@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
}, [isMobile, openAgentList]);
|
||||
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
||||
<SidebarMenuToggle />
|
||||
</View>
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { isDesktop as isDesktopHost } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
@@ -513,7 +514,7 @@ export default function SettingsScreen() {
|
||||
const isLoading = settingsLoading;
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const isDesktop = Platform.OS === "web";
|
||||
const isDesktop = isDesktopHost();
|
||||
const isLocalDaemon = useIsLocalDaemon(routeServerId);
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function StartupSplashScreen() {
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.status}>Starting up…</Text>
|
||||
</View>
|
||||
|
||||
@@ -80,6 +80,17 @@ function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
function dedupeHostConnections(connections: HostConnection[]): HostConnection[] {
|
||||
const next: HostConnection[] = []
|
||||
for (const connection of connections) {
|
||||
if (next.some((existing) => hostConnectionEquals(existing, connection))) {
|
||||
continue
|
||||
}
|
||||
next.push(connection)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function upsertHostConnectionInProfiles(input: {
|
||||
profiles: HostProfile[]
|
||||
serverId: string
|
||||
@@ -96,9 +107,17 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
const labelTrimmed = input.label?.trim() ?? ''
|
||||
const derivedLabel = labelTrimmed || serverId
|
||||
const existing = input.profiles
|
||||
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
|
||||
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
|
||||
if (
|
||||
daemon.serverId === serverId ||
|
||||
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
|
||||
) {
|
||||
matches.push(index)
|
||||
}
|
||||
return matches
|
||||
}, [])
|
||||
|
||||
if (idx === -1) {
|
||||
if (matchingIndexes.length === 0) {
|
||||
const profile: HostProfile = {
|
||||
serverId,
|
||||
label: derivedLabel,
|
||||
@@ -111,31 +130,37 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
return [...existing, profile]
|
||||
}
|
||||
|
||||
const prev = existing[idx]!
|
||||
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
|
||||
const hadConnection = connectionIdx !== -1
|
||||
const connectionChanged =
|
||||
connectionIdx === -1
|
||||
? true
|
||||
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
|
||||
const nextConnections =
|
||||
connectionIdx === -1
|
||||
? [...prev.connections, input.connection]
|
||||
: connectionChanged
|
||||
? prev.connections.map((connection, index) =>
|
||||
index === connectionIdx ? input.connection : connection
|
||||
)
|
||||
: prev.connections
|
||||
|
||||
const matchedProfiles = matchingIndexes.map((index) => existing[index]!)
|
||||
const prev =
|
||||
matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]!
|
||||
const nextConnections = dedupeHostConnections([
|
||||
...matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||
input.connection,
|
||||
])
|
||||
const nextLifecycle = prev.lifecycle
|
||||
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
|
||||
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
|
||||
const nextLabel =
|
||||
labelTrimmed || (prev.label === prev.serverId ? derivedLabel : prev.label)
|
||||
const nextPreferredConnectionId =
|
||||
prev.preferredConnectionId &&
|
||||
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
|
||||
? prev.preferredConnectionId
|
||||
: input.connection.id
|
||||
const nextCreatedAt = matchedProfiles.reduce(
|
||||
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
|
||||
prev.createdAt
|
||||
)
|
||||
const changed =
|
||||
matchingIndexes.length > 1 ||
|
||||
prev.serverId !== serverId ||
|
||||
nextCreatedAt !== prev.createdAt ||
|
||||
nextLabel !== prev.label ||
|
||||
nextPreferredConnectionId !== prev.preferredConnectionId ||
|
||||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
|
||||
!hadConnection ||
|
||||
connectionChanged
|
||||
nextConnections.length !== prev.connections.length ||
|
||||
nextConnections.some((connection, index) => {
|
||||
const previousConnection = prev.connections[index]
|
||||
return !previousConnection || !hostConnectionEquals(connection, previousConnection)
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
return existing
|
||||
@@ -143,15 +168,19 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
|
||||
const nextProfile: HostProfile = {
|
||||
...prev,
|
||||
serverId,
|
||||
label: nextLabel,
|
||||
lifecycle: nextLifecycle,
|
||||
connections: nextConnections,
|
||||
preferredConnectionId: nextPreferredConnectionId,
|
||||
createdAt: nextCreatedAt,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const next = [...existing]
|
||||
next[idx] = nextProfile
|
||||
const firstIndex = matchingIndexes[0]!
|
||||
const matchingIndexSet = new Set(matchingIndexes)
|
||||
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index))
|
||||
next.splice(firstIndex, 0, nextProfile)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const desktopHostState = {
|
||||
api: null as {
|
||||
dialog?: {
|
||||
ask?: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
};
|
||||
} | null,
|
||||
};
|
||||
|
||||
type MockPlatform = "web" | "ios" | "android";
|
||||
|
||||
type AlertButton = {
|
||||
@@ -19,13 +27,16 @@ async function loadModuleForPlatform(platform: MockPlatform): Promise<{
|
||||
},
|
||||
Platform: { OS: platform },
|
||||
}));
|
||||
vi.doMock("@/desktop/host", () => ({
|
||||
getDesktopHost: () => desktopHostState.api,
|
||||
}));
|
||||
|
||||
const module = await import("./confirm-dialog");
|
||||
return { confirmDialog: module.confirmDialog, alertMock };
|
||||
}
|
||||
|
||||
function clearDialogGlobals(): void {
|
||||
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||
desktopHostState.api = null;
|
||||
delete (globalThis as { confirm?: unknown }).confirm;
|
||||
}
|
||||
|
||||
@@ -37,13 +48,13 @@ describe("confirmDialog", () => {
|
||||
clearDialogGlobals();
|
||||
});
|
||||
|
||||
it("uses Tauri dialog.ask on web when available", async () => {
|
||||
it("uses the desktop dialog bridge on web when available", async () => {
|
||||
const askMock = vi.fn(async () => true);
|
||||
const blurMock = vi.fn();
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
activeElement: { blur: blurMock },
|
||||
} as unknown as Document;
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
desktopHostState.api = {
|
||||
dialog: { ask: askMock },
|
||||
};
|
||||
|
||||
@@ -67,7 +78,7 @@ describe("confirmDialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
|
||||
it("falls back to browser confirm on web when desktop APIs are unavailable", async () => {
|
||||
const browserConfirm = vi.fn(() => true);
|
||||
const blurMock = vi.fn();
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
|
||||
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
|
||||
|
||||
export interface ConfirmDialogInput {
|
||||
title: string;
|
||||
@@ -48,14 +48,14 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
||||
});
|
||||
}
|
||||
|
||||
function getTauriApi() {
|
||||
function getDesktopApi() {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
return getTauri();
|
||||
return getDesktopHost();
|
||||
}
|
||||
|
||||
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||
function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOptions {
|
||||
const labels = resolveButtonLabels(input);
|
||||
|
||||
return {
|
||||
@@ -74,35 +74,18 @@ function blurActiveWebElement(): void {
|
||||
(activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
|
||||
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||
const tauriApi = getTauriApi();
|
||||
if (!tauriApi) {
|
||||
async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||
const desktopApi = getDesktopApi();
|
||||
if (!desktopApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
blurActiveWebElement();
|
||||
const options = buildTauriAskOptions(input);
|
||||
const tauriAsk = tauriApi.dialog?.ask;
|
||||
const options = buildDesktopAskOptions(input);
|
||||
const desktopAsk = desktopApi.dialog?.ask;
|
||||
|
||||
if (typeof tauriAsk === "function") {
|
||||
try {
|
||||
return Boolean(await tauriAsk(input.message, options));
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
const tauriInvoke = tauriApi.core?.invoke;
|
||||
if (typeof tauriInvoke === "function") {
|
||||
try {
|
||||
const result = await tauriInvoke("plugin:dialog|ask", {
|
||||
message: input.message,
|
||||
...options,
|
||||
});
|
||||
return result === true;
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
|
||||
}
|
||||
if (typeof desktopAsk === "function") {
|
||||
return Boolean(await desktopAsk(input.message, options));
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -124,9 +107,9 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
|
||||
return showNativeConfirmDialog(input);
|
||||
}
|
||||
|
||||
const tauriResult = await showTauriConfirmDialog(input);
|
||||
if (tauriResult !== null) {
|
||||
return tauriResult;
|
||||
const desktopResult = await showDesktopConfirmDialog(input);
|
||||
if (desktopResult !== null) {
|
||||
return desktopResult;
|
||||
}
|
||||
|
||||
return showWebConfirmDialog(input);
|
||||
@@ -134,5 +117,5 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
|
||||
|
||||
export const __private__ = {
|
||||
blurActiveWebElement,
|
||||
buildTauriAskOptions,
|
||||
buildDesktopAskOptions,
|
||||
};
|
||||
|
||||
117
packages/app/src/utils/desktop-window.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
const opener = getTauri()?.opener?.openUrl;
|
||||
const opener = getDesktopHost()?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
type MockNotificationOptions = {
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
type MockNotificationInstance = {
|
||||
@@ -18,7 +19,6 @@ type GlobalSnapshot = {
|
||||
dispatchEvent: unknown;
|
||||
focus: unknown;
|
||||
location: unknown;
|
||||
__TAURI__: unknown;
|
||||
};
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
@@ -27,12 +27,34 @@ const originalGlobals: GlobalSnapshot = {
|
||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||
focus: (globalThis as { focus?: unknown }).focus,
|
||||
location: (globalThis as { location?: unknown }).location,
|
||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||
async function loadModuleForPlatform(
|
||||
platform: "web" | "ios" | "android",
|
||||
options?: {
|
||||
desktopHost?: {
|
||||
notification?: {
|
||||
sendNotification?: (payload: {
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
) {
|
||||
vi.resetModules();
|
||||
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
|
||||
vi.doMock("@/desktop/host", () => ({
|
||||
getDesktopHost: () => options?.desktopHost ?? null,
|
||||
}));
|
||||
vi.doMock("expo-asset", () => ({
|
||||
Asset: {
|
||||
fromModule: vi.fn(() => ({
|
||||
uri: "http://localhost:8081/packages/app/assets/images/notification-icon.png",
|
||||
})),
|
||||
},
|
||||
}));
|
||||
return import("./os-notifications");
|
||||
}
|
||||
|
||||
@@ -42,7 +64,6 @@ function restoreGlobals(): void {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
|
||||
}
|
||||
|
||||
describe("sendOsNotification", () => {
|
||||
@@ -71,7 +92,6 @@ describe("sendOsNotification", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@/desktop/notifications/desktop-notifications");
|
||||
vi.doUnmock("react-native");
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
@@ -168,14 +188,8 @@ describe("sendOsNotification", () => {
|
||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
||||
});
|
||||
|
||||
it("uses the desktop notification bridge when Tauri is available", async () => {
|
||||
const sendDesktopNotification = vi.fn(async () => true);
|
||||
vi.doMock("@/desktop/notifications/desktop-notifications", () => ({
|
||||
sendDesktopNotification,
|
||||
}));
|
||||
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {};
|
||||
|
||||
it("returns false when the Notification API is unavailable", async () => {
|
||||
(globalThis as { Notification?: unknown }).Notification = undefined;
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
@@ -183,11 +197,61 @@ describe("sendOsNotification", () => {
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(false);
|
||||
});
|
||||
|
||||
it("does not attach a click handler when there is no route target", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
static permission = "granted";
|
||||
static requestPermission = vi.fn(async () => "granted");
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { Notification?: unknown }).Notification = MockNotification;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = vi.fn();
|
||||
(globalThis as { location?: unknown }).location = { assign: vi.fn() };
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(sendDesktopNotification).toHaveBeenCalledWith({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]?.onclick).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the desktop notification bridge when available", async () => {
|
||||
const sendNotification = vi.fn(async () => true);
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web", {
|
||||
desktopHost: {
|
||||
notification: {
|
||||
sendNotification,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
data: { serverId: "srv-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, desktop notifications work.",
|
||||
data: { serverId: "srv-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { Platform } from "react-native";
|
||||
import { sendDesktopNotification } from "@/desktop/notifications/desktop-notifications";
|
||||
import { buildNotificationRoute } from "./notification-routing";
|
||||
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import {
|
||||
buildNotificationRoute,
|
||||
resolveNotificationTarget,
|
||||
} from "./notification-routing";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -15,28 +18,41 @@ export type WebNotificationClickDetail = {
|
||||
|
||||
type WebNotificationInstance = {
|
||||
onclick?: ((event: Event) => void) | null;
|
||||
onclose?: ((event: Event) => void) | null;
|
||||
addEventListener?: (type: string, listener: (event: Event) => void) => void;
|
||||
removeEventListener?: (type: string, listener: (event: Event) => void) => void;
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
const activeWebNotifications = new Set<WebNotificationInstance>();
|
||||
let notificationIconUrl: string | null | undefined;
|
||||
|
||||
function getTauriNotificationModule(): TauriNotificationApi | null {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
return getTauri()?.notification ?? null;
|
||||
function getDesktopNotificationSender():
|
||||
| ((payload: {
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<boolean>)
|
||||
| null {
|
||||
const sendNotification = getDesktopHost()?.notification?.sendNotification;
|
||||
return typeof sendNotification === "function"
|
||||
? (sendNotification as (payload: {
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<boolean>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
permission: string;
|
||||
requestPermission?: () => Promise<string>;
|
||||
new (title: string, options?: { body?: string; data?: Record<string, unknown> }): unknown;
|
||||
new (
|
||||
title: string,
|
||||
options?: {
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
}
|
||||
): unknown;
|
||||
} | null {
|
||||
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
|
||||
return NotificationConstructor ?? null;
|
||||
@@ -70,49 +86,31 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriNotification = getTauriNotificationModule();
|
||||
if (tauriNotification) {
|
||||
return await ensureTauriNotificationPermission(tauriNotification);
|
||||
}
|
||||
|
||||
return await ensureNotificationPermission();
|
||||
}
|
||||
|
||||
async function ensureTauriNotificationPermission(
|
||||
notificationModule: TauriNotificationApi
|
||||
): Promise<boolean> {
|
||||
if (typeof notificationModule.isPermissionGranted === "function") {
|
||||
try {
|
||||
const granted = await notificationModule.isPermissionGranted();
|
||||
if (granted) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to check notification permission",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
function hasNotificationClickTarget(
|
||||
data: Record<string, unknown> | undefined
|
||||
): boolean {
|
||||
const target = resolveNotificationTarget(data);
|
||||
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
|
||||
}
|
||||
|
||||
if (typeof notificationModule.requestPermission !== "function") {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
|
||||
);
|
||||
return false;
|
||||
function getWebNotificationIconUrl(): string | undefined {
|
||||
if (notificationIconUrl !== undefined) {
|
||||
return notificationIconUrl ?? undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await notificationModule.requestPermission();
|
||||
return result === "granted";
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to request notification permission",
|
||||
error
|
||||
const asset = Asset.fromModule(
|
||||
require("../../assets/images/notification-icon.png")
|
||||
);
|
||||
return false;
|
||||
notificationIconUrl = asset.uri ?? null;
|
||||
} catch {
|
||||
notificationIconUrl = null;
|
||||
}
|
||||
|
||||
return notificationIconUrl ?? undefined;
|
||||
}
|
||||
|
||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||
@@ -156,41 +154,12 @@ function attachWebClickHandler(
|
||||
notification: WebNotificationInstance,
|
||||
data: Record<string, unknown> | undefined
|
||||
): void {
|
||||
activeWebNotifications.add(notification);
|
||||
|
||||
const cleanup = () => {
|
||||
activeWebNotifications.delete(notification);
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
const focus = (globalThis as { focus?: () => void }).focus;
|
||||
if (typeof focus === "function") {
|
||||
focus();
|
||||
}
|
||||
|
||||
notification.onclick = () => {
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
if (!handledByApp) {
|
||||
fallbackNavigateToNotificationTarget(data);
|
||||
}
|
||||
|
||||
cleanup();
|
||||
if (typeof notification.close === "function") {
|
||||
notification.close();
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
if (typeof notification.addEventListener === "function") {
|
||||
notification.addEventListener("click", onClick);
|
||||
notification.addEventListener("close", onClose);
|
||||
return;
|
||||
}
|
||||
|
||||
notification.onclick = onClick;
|
||||
notification.onclose = onClose;
|
||||
}
|
||||
|
||||
export async function sendOsNotification(
|
||||
@@ -201,8 +170,9 @@ export async function sendOsNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getTauri()) {
|
||||
return await sendDesktopNotification(payload);
|
||||
const desktopNotificationSender = getDesktopNotificationSender();
|
||||
if (desktopNotificationSender) {
|
||||
return await desktopNotificationSender(payload);
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor();
|
||||
@@ -212,8 +182,11 @@ export async function sendOsNotification(
|
||||
const notification = new NotificationConstructor(payload.title, {
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
icon: getWebNotificationIconUrl(),
|
||||
}) as WebNotificationInstance;
|
||||
attachWebClickHandler(notification, payload.data);
|
||||
if (hasNotificationClickTarget(payload.data)) {
|
||||
attachWebClickHandler(notification, payload.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { getIsDesktopMac } from "@/constants/layout";
|
||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||
|
||||
export function getShortcutOs(): ShortcutOs {
|
||||
if (Platform.OS !== "web") {
|
||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||
}
|
||||
if (getIsTauriMac()) return "mac";
|
||||
if (getIsDesktopMac()) return "mac";
|
||||
if (typeof navigator === "undefined") return "non-mac";
|
||||
const ua = navigator.userAgent ?? "";
|
||||
const platform = (navigator as any).platform ?? "";
|
||||
@@ -15,4 +15,3 @@ export function getShortcutOs(): ShortcutOs {
|
||||
/Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
return isApple ? "mac" : "non-mac";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "./managed-tauri-daemon-transport";
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -49,7 +49,7 @@ export async function buildClientConfig(
|
||||
serverId?: string
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
@@ -300,12 +300,12 @@ export function createAudioEngine(
|
||||
typeof window !== "undefined" && window.location
|
||||
? window.location.origin
|
||||
: "unknown";
|
||||
const isTauri = getTauri() !== null;
|
||||
const isDesktopApp = isDesktop();
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
if (!secureContext && !isTauri) {
|
||||
if (!secureContext && !isDesktopApp) {
|
||||
throw new Error(
|
||||
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
||||
);
|
||||
|
||||
9
packages/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Build output
|
||||
dist/
|
||||
release/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
3
packages/desktop/README.md
Normal 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
packages/desktop/electron-builder.yml
Normal 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
packages/desktop/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,26 @@
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.30",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Tauri wrapper)",
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
|
||||
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime && npm run validate:managed-runtime",
|
||||
"dev": "tauri dev",
|
||||
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
|
||||
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",
|
||||
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
|
||||
"tauri": "tauri"
|
||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.yml",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.30",
|
||||
"@getpaseo/server": "0.1.30",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "41.0.3",
|
||||
"electron-builder": "26.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"wait-on": "8.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
packages/desktop/scripts/paseo
Executable 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
packages/desktop/scripts/paseo.cmd
Normal 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}`);
|
||||
332
packages/desktop/scripts/verify-electron-cdp.mjs
Normal file
@@ -0,0 +1,332 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
|
||||
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
|
||||
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
|
||||
const REQUIRED_DESKTOP_KEYS = [
|
||||
"invoke",
|
||||
"events",
|
||||
"window",
|
||||
"dialog",
|
||||
"notification",
|
||||
"opener",
|
||||
];
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function ensureDir(dirPath) {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function captureScreenshot(page, fileName) {
|
||||
const filePath = path.join(OUTPUT_DIR, fileName);
|
||||
await page.screenshot({ path: filePath, fullPage: true });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function findAppPage(browser) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
for (const context of browser.contexts()) {
|
||||
for (const page of context.pages()) {
|
||||
if (page.url().includes(APP_URL_FRAGMENT) && !page.url().startsWith("devtools://")) {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Unable to find Electron app page for ${APP_URL_FRAGMENT}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureDir(OUTPUT_DIR);
|
||||
|
||||
const browser = await chromium.connectOverCDP(CDP_URL);
|
||||
const page = await findAppPage(browser);
|
||||
const consoleMessages = [];
|
||||
const results = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
consoleMessages.push({
|
||||
type: message.type(),
|
||||
text: message.text(),
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
consoleMessages.push({
|
||||
type: "pageerror",
|
||||
text: String(error),
|
||||
});
|
||||
});
|
||||
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForTimeout(1000);
|
||||
if (!page.url().endsWith("/welcome")) {
|
||||
await page.goto(`http://${APP_URL_FRAGMENT}/welcome`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png");
|
||||
|
||||
const desktopDetection = await page.evaluate(() => {
|
||||
const bridge = window.paseoDesktop;
|
||||
const keys = bridge && typeof bridge === "object" ? Object.keys(bridge) : [];
|
||||
const keyTypes = bridge && typeof bridge === "object"
|
||||
? Object.fromEntries(
|
||||
Object.entries(bridge).map(([key, value]) => [key, typeof value])
|
||||
)
|
||||
: {};
|
||||
return {
|
||||
exists: Boolean(bridge && typeof bridge === "object"),
|
||||
keys,
|
||||
keyTypes,
|
||||
platform: bridge?.platform ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const hasExpectedDesktopShape =
|
||||
desktopDetection.exists &&
|
||||
REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key));
|
||||
|
||||
results.push({
|
||||
check: "desktop-detection",
|
||||
pass: hasExpectedDesktopShape,
|
||||
details: desktopDetection,
|
||||
screenshot: welcomeScreenshot,
|
||||
});
|
||||
|
||||
const desktopStatus = await page.evaluate(() => window.paseoDesktop.invoke("desktop_daemon_status"));
|
||||
assert(
|
||||
typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0,
|
||||
"desktop_daemon_status did not return a serverId"
|
||||
);
|
||||
|
||||
const serverId = desktopStatus.serverId.trim();
|
||||
await page.evaluate((nextServerId) => {
|
||||
window.location.href = `/h/${nextServerId}/settings`;
|
||||
}, serverId);
|
||||
await page.waitForURL(new RegExp(`/h/${escapeRegExp(serverId)}/settings$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByText("Daemon management", { exact: true }).waitFor({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const settingsScreenshot = await captureScreenshot(page, "02-settings-page.png");
|
||||
|
||||
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first();
|
||||
const menuToggle = page.locator('[data-testid="menu-button"]').first();
|
||||
if (
|
||||
(await sidebarSettingsButton.isVisible().catch(() => false)) &&
|
||||
(await menuToggle.isVisible().catch(() => false))
|
||||
) {
|
||||
await menuToggle.click();
|
||||
await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const dragRegionCheck = await page.evaluate(() => {
|
||||
const nodes = Array.from(document.querySelectorAll("*"));
|
||||
const annotationId = "electron-verify-drag-style";
|
||||
const existingAnnotation = document.getElementById(annotationId);
|
||||
existingAnnotation?.remove();
|
||||
|
||||
const annotationStyle = document.createElement("style");
|
||||
annotationStyle.id = annotationId;
|
||||
annotationStyle.textContent = `
|
||||
[data-electron-verify-drag="true"] {
|
||||
outline: 3px solid #ff4d4f !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(annotationStyle);
|
||||
|
||||
function isVisible(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeText(element) {
|
||||
return (element.textContent ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
const regions = [];
|
||||
let candidate = null;
|
||||
|
||||
for (const element of nodes) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion =
|
||||
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||
if (appRegion !== "drag" || !isVisible(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const text = summarizeText(element);
|
||||
const info = {
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
text,
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
||||
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
||||
};
|
||||
regions.push(info);
|
||||
|
||||
const looksLikeHeader =
|
||||
rect.top < 180 &&
|
||||
rect.height >= 40 &&
|
||||
(text.includes("Settings") || text.includes("Sessions"));
|
||||
if (!candidate && looksLikeHeader) {
|
||||
candidate = { ...info };
|
||||
element.setAttribute("data-electron-verify-drag", "true");
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate && regions.length > 0) {
|
||||
candidate = regions
|
||||
.slice()
|
||||
.sort((left, right) => left.top - right.top)[0];
|
||||
|
||||
for (const element of nodes) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion =
|
||||
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||
if (appRegion !== "drag" || !isVisible(element)) {
|
||||
continue;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
Math.abs(rect.top - candidate.top) < 1 &&
|
||||
Math.abs(rect.left - candidate.left) < 1 &&
|
||||
Math.abs(rect.width - candidate.width) < 1 &&
|
||||
Math.abs(rect.height - candidate.height) < 1
|
||||
) {
|
||||
element.setAttribute("data-electron-verify-drag", "true");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: regions.length,
|
||||
candidate,
|
||||
regions: regions.slice(0, 10),
|
||||
};
|
||||
});
|
||||
|
||||
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
|
||||
const dragRegionPassed =
|
||||
dragRegionCheck.count > 0 &&
|
||||
Boolean(dragRegionCheck.candidate) &&
|
||||
dragRegionCheck.candidate.top < 180;
|
||||
|
||||
results.push({
|
||||
check: "drag-regions",
|
||||
pass: dragRegionPassed,
|
||||
details: dragRegionCheck,
|
||||
screenshot: dragScreenshot,
|
||||
});
|
||||
|
||||
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
|
||||
const isMac = process.platform === "darwin";
|
||||
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
|
||||
const trafficLightPaddingPassed = !isMac
|
||||
? true
|
||||
: typeof observedPaddingLeft === "number" &&
|
||||
observedPaddingLeft >= 78 &&
|
||||
observedPaddingLeft <= 110;
|
||||
|
||||
results.push({
|
||||
check: "traffic-light-padding",
|
||||
pass: trafficLightPaddingPassed,
|
||||
details: {
|
||||
platform: process.platform,
|
||||
observedPaddingLeft,
|
||||
expectedApproximatePaddingLeft: 78,
|
||||
candidate: dragRegionCheck.candidate,
|
||||
},
|
||||
screenshot: trafficLightScreenshot,
|
||||
});
|
||||
|
||||
const daemonManagementVisible = await Promise.all([
|
||||
page.getByText("Built-in daemon", { exact: true }).isVisible(),
|
||||
page.getByText("Daemon management", { exact: true }).isVisible(),
|
||||
page.getByRole("button", { name: "Restart daemon" }).first().isVisible(),
|
||||
]).then((values) => values.every(Boolean));
|
||||
const daemonManagementScreenshot = await captureScreenshot(
|
||||
page,
|
||||
"05-settings-daemon-management.png"
|
||||
);
|
||||
|
||||
results.push({
|
||||
check: "settings-daemon-management",
|
||||
pass: daemonManagementVisible,
|
||||
details: {
|
||||
route: page.url(),
|
||||
serverId,
|
||||
desktopStatus,
|
||||
},
|
||||
screenshot: daemonManagementScreenshot,
|
||||
});
|
||||
|
||||
const desktopDetectionScreenshot = await captureScreenshot(
|
||||
page,
|
||||
"06-desktop-detection.png"
|
||||
);
|
||||
results[0].screenshot = desktopDetectionScreenshot;
|
||||
|
||||
const report = {
|
||||
cdpUrl: CDP_URL,
|
||||
outputDir: OUTPUT_DIR,
|
||||
pageUrl: page.url(),
|
||||
desktopStatus,
|
||||
results,
|
||||
consoleMessages,
|
||||
};
|
||||
|
||||
const reportPath = path.join(OUTPUT_DIR, "report.json");
|
||||
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
|
||||
const failedChecks = results.filter((result) => !result.pass);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await browser.close();
|
||||
|
||||
if (failedChecks.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
4
packages/desktop/src-tauri/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
6092
packages/desktop/src-tauri/Cargo.lock
generated
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |