chore: finalize electron desktop migration
@@ -3,21 +3,21 @@ name: Desktop Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- 'v*'
|
||||||
- "desktop-v*"
|
- 'desktop-v*'
|
||||||
- "desktop-macos-v*"
|
- 'desktop-macos-v*'
|
||||||
- "desktop-linux-v*"
|
- 'desktop-linux-v*'
|
||||||
- "desktop-windows-v*"
|
- 'desktop-windows-v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Existing tag to build (e.g. v0.1.0)"
|
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
platform:
|
platform:
|
||||||
description: "Optional desktop platform to build."
|
description: 'Optional desktop platform to build.'
|
||||||
required: false
|
required: false
|
||||||
default: "all"
|
default: 'all'
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- all
|
- all
|
||||||
@@ -31,6 +31,8 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
DESKTOP_WORKSPACE: '@getpaseo/desktop-electron'
|
||||||
|
DESKTOP_PACKAGE_PATH: 'packages/desktop-electron'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-macos:
|
publish-macos:
|
||||||
@@ -40,9 +42,9 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runner: macos-14
|
- runner: macos-14
|
||||||
rust_target: aarch64-apple-darwin
|
electron_arch: arm64
|
||||||
- runner: macos-15-intel
|
- runner: macos-15-intel
|
||||||
rust_target: x86_64-apple-darwin
|
electron_arch: x64
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -75,85 +77,39 @@ jobs:
|
|||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.rust_target }}
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-macos-${{ matrix.rust_target }}
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const version = process.env.DESKTOP_VERSION;
|
||||||
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Build managed runtime
|
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Import Apple code-signing certificate
|
|
||||||
uses: apple-actions/import-codesign-certs@v3
|
|
||||||
with:
|
|
||||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Sign bundled managed runtime
|
|
||||||
env:
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
|
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
- name: Detect existing GitHub release state
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -162,76 +118,36 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
:
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
release_draft="false"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build and publish macOS Tauri release
|
- name: Build desktop release
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
id: tauri_build
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
|
||||||
projectPath: packages/desktop
|
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
|
||||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
|
||||||
releaseBody: See the assets to download and install this version.
|
|
||||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
|
||||||
prerelease: false
|
|
||||||
args: --target ${{ matrix.rust_target }}
|
|
||||||
|
|
||||||
- name: Notarize and re-upload DMG
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
CSC_NAME: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
|
publish_mode="never"
|
||||||
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
|
publish_args=()
|
||||||
if [ -z "$dmg_path" ]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
echo "::error::No DMG found in tauri build artifacts"
|
publish_mode="always"
|
||||||
exit 1
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
fi
|
fi
|
||||||
echo "DMG: $dmg_path"
|
|
||||||
|
|
||||||
echo "Signing DMG..."
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
|
|
||||||
|
|
||||||
echo "Submitting DMG for notarization..."
|
|
||||||
xcrun notarytool submit "$dmg_path" \
|
|
||||||
--apple-id "$APPLE_ID" \
|
|
||||||
--password "$APPLE_PASSWORD" \
|
|
||||||
--team-id "$APPLE_TEAM_ID" \
|
|
||||||
--wait
|
|
||||||
|
|
||||||
echo "Stapling notarization ticket..."
|
|
||||||
xcrun stapler staple "$dmg_path"
|
|
||||||
|
|
||||||
echo "Verifying..."
|
|
||||||
spctl --assess --type install --verbose "$dmg_path"
|
|
||||||
|
|
||||||
echo "Replacing release asset with notarized DMG..."
|
|
||||||
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
|
|
||||||
|
|
||||||
- name: Build macOS app (smoke only)
|
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
|
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||||
@@ -267,89 +183,38 @@ jobs:
|
|||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Install Linux packaging dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-linux
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
|
||||||
|
|
||||||
- name: Build managed runtime
|
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Strip CUDA dependencies from onnxruntime
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
|
node <<'NODE'
|
||||||
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
|
const fs = require('node:fs');
|
||||||
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
|
const path = require('node:path');
|
||||||
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
|
|
||||||
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
|
const version = process.env.DESKTOP_VERSION;
|
||||||
echo "Removing needed $lib from $f"
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
patchelf --remove-needed "$lib" "$f"
|
|
||||||
done
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
done
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
- name: Detect existing GitHub release state
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
@@ -359,141 +224,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
:
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
release_draft="false"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build Linux Tauri release
|
- name: Build desktop release
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
id: linux_tauri
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
NO_STRIP: "1"
|
|
||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
|
|
||||||
|
|
||||||
- name: Attempt manual Linux AppImage fallback
|
|
||||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
|
|
||||||
appdir_path="$appimage_dir/Paseo.AppDir"
|
|
||||||
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
|
|
||||||
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
|
||||||
if [ -n "$existing_appimage" ]; then
|
|
||||||
if [ "$existing_appimage" != "$canonical_appimage" ]; then
|
|
||||||
mv "$existing_appimage" "$canonical_appimage"
|
|
||||||
fi
|
|
||||||
if [ ! -f "$canonical_appimage.sig" ]; then
|
|
||||||
npx tauri signer sign "$canonical_appimage"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [ ! -d "$appdir_path" ]; then
|
|
||||||
echo "::error::AppDir was not generated at $appdir_path"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
|
|
||||||
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
|
|
||||||
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
|
|
||||||
tools_dir="$(mktemp -d)"
|
|
||||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
|
|
||||||
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
|
|
||||||
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
|
|
||||||
npx tauri signer sign "$canonical_appimage"
|
|
||||||
|
|
||||||
- name: Fail Linux release when AppImage bundling fails
|
|
||||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
shopt -s nullglob
|
|
||||||
assets=(
|
|
||||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
|
||||||
)
|
|
||||||
if [ "${#assets[@]}" -eq 0 ]; then
|
|
||||||
echo "::error::Linux AppImage assets are still missing after the manual fallback."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Patch AppImage for Wayland compatibility
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
appimage_dir="$GITHUB_WORKSPACE/packages/desktop/src-tauri/target/release/bundle/appimage"
|
|
||||||
appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
|
||||||
if [ -z "$appimage" ]; then
|
|
||||||
echo "::error::No AppImage found to patch"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Patching $appimage for Wayland compatibility..."
|
|
||||||
|
|
||||||
# Extract the AppImage
|
|
||||||
workdir="$(mktemp -d)"
|
|
||||||
cd "$workdir"
|
|
||||||
"$appimage" --appimage-extract
|
|
||||||
|
|
||||||
# Remove GDK_BACKEND=x11 from the GTK plugin hook.
|
|
||||||
# The bundled libgdk-3.so has Wayland support built in; forcing x11
|
|
||||||
# prevents the app from starting on Wayland-only systems.
|
|
||||||
gtk_hook="squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh"
|
|
||||||
if [ -f "$gtk_hook" ]; then
|
|
||||||
sed -i 's/^export GDK_BACKEND=x11/#export GDK_BACKEND=x11/' "$gtk_hook"
|
|
||||||
echo "Patched $gtk_hook — removed GDK_BACKEND=x11"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Repackage
|
|
||||||
tools_dir="$(mktemp -d)"
|
|
||||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage \
|
|
||||||
-o "$tools_dir/appimagetool"
|
|
||||||
chmod +x "$tools_dir/appimagetool"
|
|
||||||
ARCH=x86_64 "$tools_dir/appimagetool" squashfs-root "$appimage"
|
|
||||||
|
|
||||||
# Re-sign
|
|
||||||
rm -f "$appimage.sig"
|
|
||||||
cd "$GITHUB_WORKSPACE"
|
|
||||||
npx tauri signer sign "$appimage"
|
|
||||||
echo "Repackaged and re-signed $appimage"
|
|
||||||
|
|
||||||
- name: Upload Linux release assets
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
publish_mode="never"
|
||||||
assets=(
|
publish_args=()
|
||||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
publish_mode="always"
|
||||||
)
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
if [ "${#assets[@]}" -eq 0 ]; then
|
|
||||||
echo "::error::No Linux AppImage assets were produced."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
|
|
||||||
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
|
|
||||||
|
|
||||||
- name: Build Linux app (smoke only)
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
|
||||||
|
|
||||||
publish-windows:
|
publish-windows:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||||
@@ -529,76 +283,43 @@ jobs:
|
|||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-windows
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const version = process.env.DESKTOP_VERSION;
|
||||||
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||||
$env:NODE_OPTIONS = "--require=$patchPath"
|
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||||
npm run build:web --workspace=@getpaseo/app
|
npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Build managed runtime
|
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
- name: Detect existing GitHub release state
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -607,31 +328,27 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
:
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
release_draft="false"
|
release_type="release"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build and publish Windows Tauri release
|
- name: Build desktop release
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
shell: bash
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
run: |
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
set -euo pipefail
|
||||||
with:
|
publish_mode="never"
|
||||||
projectPath: packages/desktop
|
publish_args=()
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
publish_mode="always"
|
||||||
releaseBody: See the assets to download and install this version.
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
fi
|
||||||
prerelease: false
|
|
||||||
args: --bundles nsis
|
|
||||||
|
|
||||||
- name: Build Windows app (smoke only)
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ test-results/
|
|||||||
# Vercel
|
# Vercel
|
||||||
.vercel/
|
.vercel/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||||
- Simplified website GitHub navigation and changelog headings.
|
- Simplified website GitHub navigation and changelog headings.
|
||||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||||
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
|
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
|
||||||
- Hid empty filter groups in the left sidebar.
|
- Hid empty filter groups in the left sidebar.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||||
- Improved desktop settings and permissions UX in Tauri.
|
- Improved desktop settings and permissions UX on desktop.
|
||||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||||
- Fixed Claude session identity preservation when reloading existing agents.
|
- Fixed Claude session identity preservation when reloading existing agents.
|
||||||
- Fixed combobox option behavior and related interactions.
|
- Fixed combobox option behavior and related interactions.
|
||||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||||
|
|
||||||
## 0.1.7 - 2026-02-16
|
## 0.1.7 - 2026-02-16
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
|
|||||||
- `packages/app` — Mobile + web client (Expo)
|
- `packages/app` — Mobile + web client (Expo)
|
||||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||||
- `packages/relay` — E2E encrypted relay for remote access
|
- `packages/relay` — E2E encrypted relay for remote access
|
||||||
- `packages/desktop` — Tauri desktop wrapper
|
- `packages/desktop` — Electron desktop wrapper
|
||||||
- `packages/website` — Marketing site (paseo.sh)
|
- `packages/website` — Marketing site (paseo.sh)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Quick monorepo package map:
|
|||||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||||
- `packages/app`: Expo client (iOS, Android, web)
|
- `packages/app`: Expo client (iOS, Android, web)
|
||||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||||
- `packages/desktop`: Tauri desktop app
|
- `packages/desktop`: Electron desktop app
|
||||||
- `packages/relay`: Relay package for remote connectivity
|
- `packages/relay`: Relay package for remote connectivity
|
||||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
|
|||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||||
│ (Expo) │ │ (Commander) │ │ (Tauri) │
|
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
│ WebSocket │ WebSocket │ Managed subprocess
|
│ WebSocket │ WebSocket │ Managed subprocess
|
||||||
@@ -90,9 +90,9 @@ Enables remote access when the daemon is behind a firewall.
|
|||||||
|
|
||||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||||
|
|
||||||
### `packages/desktop` — Desktop app (Tauri)
|
### `packages/desktop` — Desktop app (Electron)
|
||||||
|
|
||||||
Tauri wrapper for macOS, Linux, and Windows.
|
Electron wrapper for macOS, Linux, and Windows.
|
||||||
|
|
||||||
- Can spawn the daemon as a managed subprocess
|
- Can spawn the daemon as a managed subprocess
|
||||||
- Native file access for workspace integration
|
- Native file access for workspace integration
|
||||||
@@ -180,5 +180,5 @@ $PASEO_HOME/
|
|||||||
## Deployment models
|
## Deployment models
|
||||||
|
|
||||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||||
2. **Managed desktop**: Tauri app spawns daemon as subprocess
|
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||||
|
|||||||
@@ -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
|
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||||
- `release:finalize` publishes npm and promotes the same draft release
|
- `release:finalize` publishes npm and promotes the same draft release
|
||||||
- Use the same semver tag for both; don't cut a second tag
|
- Use the same semver tag for both; don't cut a second tag
|
||||||
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ If the fix requires a code change (e.g. a broken build script), commit the fix t
|
|||||||
|
|
||||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -56,7 +56,6 @@
|
|||||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
|
|||||||
@@ -2,17 +2,10 @@
|
|||||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
|
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||||
polyfillScreenOrientation();
|
polyfillScreenOrientation();
|
||||||
|
|
||||||
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
|
|
||||||
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
|
|
||||||
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
|
|
||||||
attachConsole();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||||
import "./src/styles/unistyles";
|
import "./src/styles/unistyles";
|
||||||
import "expo-router/entry";
|
import "expo-router/entry";
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios:release": "expo run:ios --configuration Release",
|
"ios:release": "expo run:ios --configuration Release",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
|
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
"build:web": "expo export --platform web",
|
"build:web": "expo export --platform web",
|
||||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
|
||||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -60,9 +58,7 @@
|
|||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@tauri-apps/plugin-log": "^2.8.0",
|
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|||||||
@@ -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,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsDesktop } from "@/constants/layout";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
|
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -65,19 +64,18 @@ import {
|
|||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
|
mapPathnameToServer,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { getTauri } from "@/utils/tauri";
|
|
||||||
import { attachConsole } from "@/utils/tauri-attach-console";
|
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
attachConsole();
|
|
||||||
const HostRuntimeBootstrapContext = createContext(false);
|
const HostRuntimeBootstrapContext = createContext(false);
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
@@ -86,33 +84,37 @@ function PushNotificationRouter() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
if (getTauri()) {
|
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (getIsDesktop()) {
|
||||||
void ensureOsNotificationPermission();
|
void ensureOsNotificationPermission();
|
||||||
|
|
||||||
let disposed = false;
|
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||||
let unlisten: (() => void) | null = null;
|
"notification-click",
|
||||||
|
(payload: unknown) => {
|
||||||
|
const data =
|
||||||
|
typeof payload === "object" &&
|
||||||
|
payload !== null &&
|
||||||
|
"data" in payload &&
|
||||||
|
typeof (payload as { data?: unknown }).data === "object" &&
|
||||||
|
(payload as { data?: unknown }).data !== null
|
||||||
|
? ((payload as { data: Record<string, unknown> }).data)
|
||||||
|
: undefined;
|
||||||
|
router.push(buildNotificationRoute(data) as any);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
void listenToDesktopNotificationClicks((payload) => {
|
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||||
router.push(buildNotificationRoute(payload.data) as any);
|
if (typeof unlisten !== "function") {
|
||||||
})
|
return;
|
||||||
.then((cleanup) => {
|
}
|
||||||
if (disposed) {
|
if (cancelled) {
|
||||||
cleanup();
|
unlisten();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unlisten = cleanup;
|
removeDesktopNotificationListener = unlisten;
|
||||||
})
|
});
|
||||||
.catch((error) => {
|
|
||||||
console.error(
|
|
||||||
"[OSNotifications][Desktop] Failed to register notification click listener",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
unlisten?.();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = globalThis as unknown as EventTarget;
|
const target = globalThis as unknown as EventTarget;
|
||||||
@@ -126,7 +128,10 @@ function PushNotificationRouter() {
|
|||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
openFromWebClick as EventListener
|
openFromWebClick as EventListener
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
removeDesktopNotificationListener?.();
|
||||||
target.removeEventListener(
|
target.removeEventListener(
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
openFromWebClick as EventListener
|
openFromWebClick as EventListener
|
||||||
@@ -476,12 +481,24 @@ function OfferLinkListener({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
|
const hosts = useHosts();
|
||||||
useFaviconStatus();
|
useFaviconStatus();
|
||||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const shouldShowAppChrome = activeServerId !== null;
|
const shouldShowAppChrome = activeServerId !== null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeServerId || hosts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||||
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||||
const selectedAgentKey = useMemo(() => {
|
const selectedAgentKey = useMemo(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useSyncExternalStore, useState } from 'react'
|
import { useEffect, useSyncExternalStore, useState } from 'react'
|
||||||
import { usePathname, useRouter } from 'expo-router'
|
import { usePathname, useRouter } from 'expo-router'
|
||||||
import { useHosts } from '@/runtime/host-runtime'
|
import { useHosts } from '@/runtime/host-runtime'
|
||||||
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
|
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
|
||||||
import { buildHostRootRoute } from '@/utils/host-routes'
|
import { buildHostRootRoute } from '@/utils/host-routes'
|
||||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
||||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||||
@@ -57,7 +57,7 @@ export default function Index() {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const daemons = useHosts()
|
const daemons = useHosts()
|
||||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
const [hasTimedOut, setHasTimedOut] = useState(false)
|
||||||
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
const isDesktopStartupRace = shouldUseDesktopDaemon()
|
||||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { isTauriEnvironment } from "@/utils/tauri";
|
import { isDesktop } from "@/desktop/host";
|
||||||
import type { AttachmentStore } from "@/attachments/types";
|
import type { AttachmentStore } from "@/attachments/types";
|
||||||
|
|
||||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||||
|
|
||||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
if (isTauriEnvironment()) {
|
if (isDesktop()) {
|
||||||
const { createDesktopAttachmentStore } = await import(
|
const { createDesktopAttachmentStore } = await import(
|
||||||
"../desktop/attachments/desktop-attachment-store"
|
"../desktop/attachments/desktop-attachment-store"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
getIsTauriMac,
|
getIsDesktopMac,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
|
||||||
interface ScreenHeaderProps {
|
interface ScreenHeaderProps {
|
||||||
@@ -37,18 +37,21 @@ export function ScreenHeader({
|
|||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
const collapsedSidebarTrafficLightInset =
|
const collapsedSidebarTrafficLightInset =
|
||||||
!isMobile && !desktopAgentListOpen && getIsTauriMac()
|
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
|
||||||
? trafficLightPadding.left
|
? trafficLightPadding.left
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// On Tauri macOS, enable window dragging and double-click to maximize
|
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||||
const dragHandlers = useTauriDragHandlers();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||||
<View
|
<View
|
||||||
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
|
style={[
|
||||||
|
styles.row,
|
||||||
|
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
||||||
|
dragRegionStyle,
|
||||||
|
]}
|
||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
>
|
>
|
||||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsDesktop } from "@/constants/layout";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
@@ -13,10 +13,10 @@ export function KeyboardShortcutsDialog() {
|
|||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||||
|
|
||||||
const isMac = getShortcutOs() === "mac";
|
const isMac = getShortcutOs() === "mac";
|
||||||
const isTauri = getIsTauri();
|
const isDesktopApp = getIsDesktop();
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
|
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||||
[isMac, isTauri]
|
[isDesktopApp, isMac]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
|||||||
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
|
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
|
||||||
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
||||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
|
||||||
import { Combobox } from '@/components/ui/combobox'
|
import { Combobox } from '@/components/ui/combobox'
|
||||||
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
|
||||||
import { formatConnectionStatus } from '@/utils/daemons'
|
import { formatConnectionStatus } from '@/utils/daemons'
|
||||||
@@ -130,7 +130,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation()
|
} = useSidebarAnimation()
|
||||||
const dragHandlers = useTauriDragHandlers()
|
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
|
||||||
const trafficLightPadding = useTrafficLightPadding()
|
const trafficLightPadding = useTrafficLightPadding()
|
||||||
const closeTouchStartX = useSharedValue(0)
|
const closeTouchStartX = useSharedValue(0)
|
||||||
const closeTouchStartY = useSharedValue(0)
|
const closeTouchStartY = useSharedValue(0)
|
||||||
@@ -448,9 +448,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||||
{trafficLightPadding.top > 0 ? (
|
{trafficLightPadding.top > 0 ? (
|
||||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
<View style={[{ height: trafficLightPadding.top }, dragRegionStyle]} {...dragHandlers} />
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
<View style={[styles.sidebarHeader, dragRegionStyle]} {...dragHandlers}>
|
||||||
<View style={styles.sidebarHeaderRow}>
|
<View style={styles.sidebarHeaderRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.newAgentButton}
|
style={styles.newAgentButton}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
|||||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||||
import { getIsTauri } from '@/constants/layout'
|
import { getIsDesktop } from '@/constants/layout'
|
||||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRouteWithOpenIntent,
|
buildHostWorkspaceRouteWithOpenIntent,
|
||||||
@@ -1407,10 +1407,10 @@ export function SidebarWorkspaceList({
|
|||||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||||
new Map()
|
new Map()
|
||||||
)
|
)
|
||||||
const isTauri = getIsTauri()
|
const isDesktopApp = getIsDesktop()
|
||||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||||
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
|
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown)
|
||||||
|
|
||||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
||||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
|
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { isDesktop, isDesktopMac } from "@/desktop/host";
|
||||||
|
|
||||||
export const FOOTER_HEIGHT = 75;
|
export const FOOTER_HEIGHT = 75;
|
||||||
|
|
||||||
@@ -14,60 +14,56 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
|
|||||||
// Max width for chat content (stream view, input area, new agent form)
|
// Max width for chat content (stream view, input area, new agent form)
|
||||||
export const MAX_CONTENT_WIDTH = 820;
|
export const MAX_CONTENT_WIDTH = 820;
|
||||||
|
|
||||||
// Tauri desktop app constants for macOS traffic light buttons
|
// Desktop app constants for macOS traffic light buttons
|
||||||
// These buttons (close/minimize/maximize) overlay the top-left corner
|
// These buttons (close/minimize/maximize) overlay the top-left corner
|
||||||
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
|
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
||||||
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
|
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||||
|
|
||||||
// Check if running in Tauri desktop app (any OS)
|
// Check if running in desktop app (any OS)
|
||||||
function isTauri(): boolean {
|
function isDesktopEnvironment(): boolean {
|
||||||
if (Platform.OS !== "web") return false;
|
if (Platform.OS !== "web") return false;
|
||||||
return getTauri() !== null;
|
return isDesktop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if running in Tauri desktop app on macOS
|
// Check if running in desktop host on macOS
|
||||||
function isTauriMac(): boolean {
|
function isDesktopEnvironmentMac(): boolean {
|
||||||
if (Platform.OS !== "web") return false;
|
if (Platform.OS !== "web") return false;
|
||||||
if (typeof window === "undefined") return false;
|
return isDesktopMac();
|
||||||
if (getTauri() === null) return false;
|
|
||||||
// Check for macOS via user agent
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
return ua.includes("Mac OS") || ua.includes("Macintosh");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached result - only cache true, keep checking if false (in case Tauri globals load later)
|
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
|
||||||
let _isTauriMacCached: boolean | null = null;
|
let _isDesktopMacCached: boolean | null = null;
|
||||||
let _isTauriCached: boolean | null = null;
|
let _isDesktopCached: boolean | null = null;
|
||||||
|
|
||||||
export function getIsTauriMac(): boolean {
|
export function getIsDesktopMac(): boolean {
|
||||||
if (_isTauriMacCached === true) {
|
if (_isDesktopMacCached === true) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const result = isTauriMac();
|
const result = isDesktopEnvironmentMac();
|
||||||
if (result) {
|
if (result) {
|
||||||
_isTauriMacCached = true;
|
_isDesktopMacCached = true;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIsTauri(): boolean {
|
export function getIsDesktop(): boolean {
|
||||||
if (_isTauriCached === true) {
|
if (_isDesktopCached === true) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const result = isTauri();
|
const result = isDesktopEnvironment();
|
||||||
if (result) {
|
if (result) {
|
||||||
_isTauriCached = true;
|
_isDesktopCached = true;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get traffic light padding values (only non-zero on Tauri macOS)
|
// Get traffic light padding values (only non-zero on desktop macOS)
|
||||||
export function getTrafficLightPadding(): { left: number; top: number } {
|
export function getTrafficLightPadding(): { left: number; top: number } {
|
||||||
if (!getIsTauriMac()) {
|
if (!getIsDesktopMac()) {
|
||||||
return { left: 0, top: 0 };
|
return { left: 0, top: 0 };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
left: TAURI_TRAFFIC_LIGHT_WIDTH,
|
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||||
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
|
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||||
|
|
||||||
interface AttachmentFileResult {
|
interface AttachmentFileResult {
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { invokeDesktopCommandMock } = vi.hoisted(() => ({
|
|||||||
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
|
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
|
vi.mock("@/desktop/electron/invoke", () => ({
|
||||||
invokeDesktopCommand: invokeDesktopCommandMock,
|
invokeDesktopCommand: invokeDesktopCommandMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { fileUriToPath } from "@/attachments/utils";
|
import { fileUriToPath } from "@/attachments/utils";
|
||||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||||
|
|
||||||
function base64ToUint8Array(base64: string): Uint8Array {
|
function base64ToUint8Array(base64: string): Uint8Array {
|
||||||
const binary = atob(base64);
|
const binary = atob(base64);
|
||||||
|
|||||||
@@ -20,21 +20,21 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { useAppSettings } from '@/hooks/use-settings'
|
import { useAppSettings } from '@/hooks/use-settings'
|
||||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||||
import { openExternalUrl } from '@/utils/open-external-url'
|
import { openExternalUrl } from '@/utils/open-external-url'
|
||||||
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
|
import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
|
||||||
import {
|
import {
|
||||||
getCliSymlinkInstructions,
|
getCliSymlinkInstructions,
|
||||||
getManagedDaemonLogs,
|
getDesktopDaemonLogs,
|
||||||
getManagedDaemonPairing,
|
getDesktopDaemonPairing,
|
||||||
getManagedDaemonStatus,
|
getDesktopDaemonStatus,
|
||||||
restartManagedDaemon,
|
restartDesktopDaemon,
|
||||||
shouldUseManagedDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
startManagedDaemon,
|
startDesktopDaemon,
|
||||||
stopManagedDaemon,
|
stopDesktopDaemon,
|
||||||
type CliSymlinkInstructions,
|
type CliSymlinkInstructions,
|
||||||
type ManagedDaemonLogs,
|
type DesktopDaemonLogs,
|
||||||
type ManagedPairingOffer,
|
type DesktopDaemonStatus,
|
||||||
type ManagedDaemonStatus,
|
type DesktopPairingOffer,
|
||||||
} from '@/desktop/managed-runtime/managed-runtime'
|
} from '@/desktop/daemon/desktop-daemon'
|
||||||
|
|
||||||
export interface LocalDaemonSectionProps {
|
export interface LocalDaemonSectionProps {
|
||||||
appVersion: string | null
|
appVersion: string | null
|
||||||
@@ -46,33 +46,35 @@ export function LocalDaemonSection({
|
|||||||
showLifecycleControls,
|
showLifecycleControls,
|
||||||
}: LocalDaemonSectionProps) {
|
}: LocalDaemonSectionProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const showSection = shouldUseManagedDesktopDaemon()
|
const showSection = shouldUseDesktopDaemon()
|
||||||
const { settings, updateSettings } = useAppSettings()
|
const { settings, updateSettings } = useAppSettings()
|
||||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
|
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null)
|
||||||
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null)
|
||||||
const [statusError, setStatusError] = useState<string | null>(null)
|
const [statusError, setStatusError] = useState<string | null>(null)
|
||||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
|
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
|
||||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
|
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
|
||||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
|
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
|
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
|
||||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
|
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null)
|
||||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
|
||||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
|
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
|
||||||
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
|
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
|
||||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
|
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
|
||||||
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
|
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
|
||||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
||||||
useState<CliSymlinkInstructions | null>(null)
|
useState<CliSymlinkInstructions | null>(null)
|
||||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
|
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
const loadManagedStatus = useCallback(() => {
|
const loadDaemonData = useCallback(() => {
|
||||||
if (!showSection) {
|
if (!showSection) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
|
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
|
||||||
.then(([status, logs]) => {
|
.then(([status, logs, version]) => {
|
||||||
setManagedStatus(status)
|
setDaemonStatus(status)
|
||||||
setManagedLogs(logs)
|
setDaemonLogs(logs)
|
||||||
|
setDaemonVersion(version.version)
|
||||||
setStatusError(null)
|
setStatusError(null)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -86,35 +88,31 @@ export function LocalDaemonSection({
|
|||||||
if (!showSection) {
|
if (!showSection) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
void loadManagedStatus()
|
void loadDaemonData()
|
||||||
return undefined
|
return undefined
|
||||||
}, [loadManagedStatus, showSection])
|
}, [loadDaemonData, showSection])
|
||||||
)
|
)
|
||||||
|
|
||||||
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
|
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
|
||||||
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
|
|
||||||
const daemonStatusStateText =
|
const daemonStatusStateText =
|
||||||
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
|
statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
|
||||||
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
|
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
|
||||||
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
|
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
|
||||||
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
|
const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
|
||||||
const daemonActionMessage =
|
const daemonActionMessage =
|
||||||
managedStatus?.status === 'running'
|
daemonStatus?.status === 'running'
|
||||||
? 'Restarts the built-in daemon.'
|
? 'Restarts the built-in daemon.'
|
||||||
: 'Starts the built-in daemon.'
|
: 'Starts the built-in daemon.'
|
||||||
|
|
||||||
const handleUpdateLocalDaemon = useCallback(() => {
|
const handleUpdateLocalDaemon = useCallback(() => {
|
||||||
if (!showSection) {
|
if (!showSection || isRestartingDaemon) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isRestartingDaemon) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void confirmDialog({
|
void confirmDialog({
|
||||||
title: daemonActionLabel,
|
title: daemonActionLabel,
|
||||||
message:
|
message:
|
||||||
managedStatus?.status === 'running'
|
daemonStatus?.status === 'running'
|
||||||
? 'This will restart the built-in daemon. The app will reconnect automatically.'
|
? 'This will restart the built-in daemon. The app will reconnect automatically.'
|
||||||
: 'This will start the built-in daemon.',
|
: 'This will start the built-in daemon.',
|
||||||
confirmLabel: daemonActionLabel,
|
confirmLabel: daemonActionLabel,
|
||||||
@@ -128,19 +126,18 @@ export function LocalDaemonSection({
|
|||||||
setIsRestartingDaemon(true)
|
setIsRestartingDaemon(true)
|
||||||
setStatusMessage(null)
|
setStatusMessage(null)
|
||||||
|
|
||||||
const action =
|
const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
|
||||||
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
|
|
||||||
|
|
||||||
void action()
|
void action()
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
setManagedStatus(status)
|
setDaemonStatus(status)
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
|
daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
|
||||||
)
|
)
|
||||||
return loadManagedStatus()
|
return loadDaemonData()
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('[Settings] Failed to change managed daemon state', error)
|
console.error('[Settings] Failed to change desktop daemon state', error)
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
|
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
|
||||||
})
|
})
|
||||||
@@ -149,10 +146,10 @@ export function LocalDaemonSection({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('[Settings] Failed to open managed daemon action confirmation', error)
|
console.error('[Settings] Failed to open desktop daemon action confirmation', error)
|
||||||
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
||||||
})
|
})
|
||||||
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
|
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection])
|
||||||
|
|
||||||
const handleToggleDaemonManagement = useCallback(() => {
|
const handleToggleDaemonManagement = useCallback(() => {
|
||||||
if (isUpdatingDaemonManagement) {
|
if (isUpdatingDaemonManagement) {
|
||||||
@@ -193,13 +190,13 @@ export function LocalDaemonSection({
|
|||||||
setStatusMessage(null)
|
setStatusMessage(null)
|
||||||
|
|
||||||
const stopPromise =
|
const stopPromise =
|
||||||
managedStatus?.status === 'running'
|
daemonStatus?.status === 'running'
|
||||||
? stopManagedDaemon()
|
? stopDesktopDaemon()
|
||||||
: Promise.resolve(managedStatus ?? null)
|
: Promise.resolve(daemonStatus ?? null)
|
||||||
|
|
||||||
void stopPromise
|
void stopPromise
|
||||||
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||||
.then(() => loadManagedStatus())
|
.then(() => loadDaemonData())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setStatusMessage('Built-in daemon paused and stopped.')
|
setStatusMessage('Built-in daemon paused and stopped.')
|
||||||
})
|
})
|
||||||
@@ -216,9 +213,9 @@ export function LocalDaemonSection({
|
|||||||
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
|
daemonStatus,
|
||||||
isUpdatingDaemonManagement,
|
isUpdatingDaemonManagement,
|
||||||
loadManagedStatus,
|
loadDaemonData,
|
||||||
managedStatus,
|
|
||||||
settings.manageBuiltInDaemon,
|
settings.manageBuiltInDaemon,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
])
|
])
|
||||||
@@ -258,7 +255,7 @@ export function LocalDaemonSection({
|
|||||||
}, [cliSymlinkInstructions?.commands])
|
}, [cliSymlinkInstructions?.commands])
|
||||||
|
|
||||||
const handleCopyLogPath = useCallback(() => {
|
const handleCopyLogPath = useCallback(() => {
|
||||||
const logPath = managedLogs?.logPath
|
const logPath = daemonLogs?.logPath
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,14 +268,14 @@ export function LocalDaemonSection({
|
|||||||
console.error('[Settings] Failed to copy log path', error)
|
console.error('[Settings] Failed to copy log path', error)
|
||||||
Alert.alert('Error', 'Unable to copy log path.')
|
Alert.alert('Error', 'Unable to copy log path.')
|
||||||
})
|
})
|
||||||
}, [managedLogs?.logPath])
|
}, [daemonLogs?.logPath])
|
||||||
|
|
||||||
const handleOpenLogs = useCallback(() => {
|
const handleOpenLogs = useCallback(() => {
|
||||||
if (!managedLogs) {
|
if (!daemonLogs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsLogsModalOpen(true)
|
setIsLogsModalOpen(true)
|
||||||
}, [managedLogs])
|
}, [daemonLogs])
|
||||||
|
|
||||||
const handleOpenPairingModal = useCallback(() => {
|
const handleOpenPairingModal = useCallback(() => {
|
||||||
if (isLoadingPairing) {
|
if (isLoadingPairing) {
|
||||||
@@ -289,7 +286,7 @@ export function LocalDaemonSection({
|
|||||||
setIsLoadingPairing(true)
|
setIsLoadingPairing(true)
|
||||||
setPairingStatusMessage(null)
|
setPairingStatusMessage(null)
|
||||||
|
|
||||||
void getManagedDaemonPairing()
|
void getDesktopDaemonPairing()
|
||||||
.then((pairing) => {
|
.then((pairing) => {
|
||||||
setPairingOffer(pairing)
|
setPairingOffer(pairing)
|
||||||
if (!pairing.relayEnabled || !pairing.url) {
|
if (!pairing.relayEnabled || !pairing.url) {
|
||||||
@@ -344,7 +341,7 @@ export function LocalDaemonSection({
|
|||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Status</Text>
|
<Text style={styles.rowTitle}>Status</Text>
|
||||||
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
|
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statusValueGroup}>
|
<View style={styles.statusValueGroup}>
|
||||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||||
@@ -398,7 +395,7 @@ export function LocalDaemonSection({
|
|||||||
disabled={isRestartingDaemon}
|
disabled={isRestartingDaemon}
|
||||||
>
|
>
|
||||||
{isRestartingDaemon
|
{isRestartingDaemon
|
||||||
? managedStatus?.status === 'running'
|
? daemonStatus?.status === 'running'
|
||||||
? 'Restarting...'
|
? 'Restarting...'
|
||||||
: 'Starting...'
|
: 'Starting...'
|
||||||
: daemonActionLabel}
|
: daemonActionLabel}
|
||||||
@@ -425,10 +422,10 @@ export function LocalDaemonSection({
|
|||||||
<View style={[styles.row, styles.rowBorder]}>
|
<View style={[styles.row, styles.rowBorder]}>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Log file</Text>
|
<Text style={styles.rowTitle}>Log file</Text>
|
||||||
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actionGroup}>
|
<View style={styles.actionGroup}>
|
||||||
{managedLogs?.logPath ? (
|
{daemonLogs?.logPath ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -443,7 +440,7 @@ export function LocalDaemonSection({
|
|||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
onPress={handleOpenLogs}
|
onPress={handleOpenLogs}
|
||||||
disabled={!managedLogs}
|
disabled={!daemonLogs}
|
||||||
>
|
>
|
||||||
Open logs
|
Open logs
|
||||||
</Button>
|
</Button>
|
||||||
@@ -523,9 +520,9 @@ export function LocalDaemonSection({
|
|||||||
snapPoints={['70%', '92%']}
|
snapPoints={['70%', '92%']}
|
||||||
>
|
>
|
||||||
<View style={styles.modalBody}>
|
<View style={styles.modalBody}>
|
||||||
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
|
||||||
<Text style={styles.logOutput} selectable>
|
<Text style={styles.logOutput} selectable>
|
||||||
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
|
{daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
@@ -537,7 +534,7 @@ const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
|
|||||||
|
|
||||||
function PairingOfferDialogContent(input: {
|
function PairingOfferDialogContent(input: {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
pairingOffer: ManagedPairingOffer | null
|
pairingOffer: DesktopPairingOffer | null
|
||||||
statusMessage: string | null
|
statusMessage: string | null
|
||||||
onCopyLink: () => void
|
onCopyLink: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const managedRuntimeMock = vi.hoisted(() => {
|
const desktopDaemonMock = vi.hoisted(() => {
|
||||||
let eventHandler: ((payload: {
|
let eventHandler: ((payload: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
kind: "open" | "message" | "close" | "error";
|
kind: "open" | "message" | "close" | "error";
|
||||||
@@ -40,32 +40,32 @@ const managedRuntimeMock = vi.hoisted(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
|
vi.mock("./desktop-daemon", () => ({
|
||||||
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
|
openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
|
||||||
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
|
listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
|
||||||
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
|
sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
|
||||||
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
|
closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("managed-tauri-daemon-transport", () => {
|
describe("desktop-daemon-transport", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
managedRuntimeMock.openLocalTransportSession.mockReset();
|
desktopDaemonMock.openLocalTransportSession.mockReset();
|
||||||
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
|
desktopDaemonMock.listenToLocalTransportEvents.mockClear();
|
||||||
managedRuntimeMock.sendLocalTransportMessage.mockClear();
|
desktopDaemonMock.sendLocalTransportMessage.mockClear();
|
||||||
managedRuntimeMock.closeLocalTransportSession.mockClear();
|
desktopDaemonMock.closeLocalTransportSession.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
|
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
|
||||||
let resolveSession!: (sessionId: string) => void;
|
let resolveSession!: (sessionId: string) => void;
|
||||||
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
desktopDaemonMock.openLocalTransportSession.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
resolveSession = resolve;
|
resolveSession = resolve;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const mod = await import("./managed-tauri-daemon-transport");
|
const mod = await import("./desktop-daemon-transport");
|
||||||
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
|
||||||
expect(transportFactory).not.toBeNull();
|
expect(transportFactory).not.toBeNull();
|
||||||
|
|
||||||
const transport = transportFactory!({
|
const transport = transportFactory!({
|
||||||
@@ -75,7 +75,7 @@ describe("managed-tauri-daemon-transport", () => {
|
|||||||
const onOpen = vi.fn();
|
const onOpen = vi.fn();
|
||||||
transport.onOpen(onOpen);
|
transport.onOpen(onOpen);
|
||||||
|
|
||||||
managedRuntimeMock.emitEvent({
|
desktopDaemonMock.emitEvent({
|
||||||
sessionId: "local-session-1",
|
sessionId: "local-session-1",
|
||||||
kind: "open",
|
kind: "open",
|
||||||
});
|
});
|
||||||
@@ -93,21 +93,21 @@ describe("managed-tauri-daemon-transport", () => {
|
|||||||
let resolveListen!: (cleanup: () => void) => void;
|
let resolveListen!: (cleanup: () => void) => void;
|
||||||
const cleanup = vi.fn();
|
const cleanup = vi.fn();
|
||||||
|
|
||||||
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
desktopDaemonMock.openLocalTransportSession.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
resolveSession = resolve;
|
resolveSession = resolve;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
|
desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
new Promise<() => void>((resolve) => {
|
new Promise<() => void>((resolve) => {
|
||||||
resolveListen = resolve;
|
resolveListen = resolve;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const mod = await import("./managed-tauri-daemon-transport");
|
const mod = await import("./desktop-daemon-transport");
|
||||||
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
|
||||||
expect(transportFactory).not.toBeNull();
|
expect(transportFactory).not.toBeNull();
|
||||||
|
|
||||||
const transport = transportFactory!({
|
const transport = transportFactory!({
|
||||||
@@ -121,7 +121,7 @@ describe("managed-tauri-daemon-transport", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
|
expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
|
||||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
openLocalTransportSession,
|
openLocalTransportSession,
|
||||||
sendLocalTransportMessage,
|
sendLocalTransportMessage,
|
||||||
type LocalTransportTarget,
|
type LocalTransportTarget,
|
||||||
} from "@/desktop/managed-runtime/managed-runtime";
|
} from "./desktop-daemon";
|
||||||
|
|
||||||
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
|
export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null {
|
||||||
return ({ url }) => {
|
return ({ url }) => {
|
||||||
const target = parseLocalDaemonTransportUrl(url);
|
const target = parseLocalDaemonTransportUrl(url);
|
||||||
let sessionId: string | null = null;
|
let sessionId: string | null = null;
|
||||||
@@ -1,29 +1,24 @@
|
|||||||
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
|
import { getDesktopHost, isDesktop } from '@/desktop/host'
|
||||||
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
|
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
|
||||||
|
|
||||||
export type ManagedRuntimeStatus = {
|
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
|
||||||
runtimeId: string
|
|
||||||
runtimeVersion: string
|
|
||||||
runtimeRoot: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ManagedDaemonStatus = {
|
export type DesktopDaemonStatus = {
|
||||||
runtimeId: string
|
|
||||||
runtimeVersion: string
|
|
||||||
serverId: string
|
serverId: string
|
||||||
status: string
|
status: DesktopDaemonState
|
||||||
listen: string
|
listen: string
|
||||||
hostname: string | null
|
hostname: string | null
|
||||||
pid: number | null
|
pid: number | null
|
||||||
home: string
|
home: string
|
||||||
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManagedDaemonLogs = {
|
export type DesktopDaemonLogs = {
|
||||||
logPath: string
|
logPath: string
|
||||||
contents: string
|
contents: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManagedPairingOffer = {
|
export type DesktopPairingOffer = {
|
||||||
relayEnabled: boolean
|
relayEnabled: boolean
|
||||||
url: string | null
|
url: string | null
|
||||||
qr: string | null
|
qr: string | null
|
||||||
@@ -35,12 +30,6 @@ export type CliSymlinkInstructions = {
|
|||||||
commands: string
|
commands: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManagedTcpSettings = {
|
|
||||||
enabled: boolean
|
|
||||||
host: string
|
|
||||||
port: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalTransportTarget = {
|
export type LocalTransportTarget = {
|
||||||
transportType: 'socket' | 'pipe'
|
transportType: 'socket' | 'pipe'
|
||||||
transportPath: string
|
transportPath: string
|
||||||
@@ -68,36 +57,42 @@ function toNumberOrNull(value: unknown): number | null {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
|
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
|
||||||
if (!isRecord(raw)) {
|
const normalized = toStringOrNull(value)?.toLowerCase()
|
||||||
throw new Error('Unexpected managed runtime status response.')
|
switch (normalized) {
|
||||||
}
|
case 'starting':
|
||||||
return {
|
return 'starting'
|
||||||
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
|
case 'running':
|
||||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
|
return 'running'
|
||||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
|
case 'errored':
|
||||||
|
case 'error':
|
||||||
|
return 'errored'
|
||||||
|
case 'stopped':
|
||||||
|
case 'stopping':
|
||||||
|
case 'unknown':
|
||||||
|
default:
|
||||||
|
return 'stopped'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
|
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
||||||
if (!isRecord(raw)) {
|
if (!isRecord(raw)) {
|
||||||
throw new Error('Unexpected managed daemon status response.')
|
throw new Error('Unexpected desktop daemon status response.')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
|
|
||||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
|
|
||||||
serverId: toStringOrNull(raw.serverId) ?? '',
|
serverId: toStringOrNull(raw.serverId) ?? '',
|
||||||
status: toStringOrNull(raw.status) ?? 'unknown',
|
status: parseDesktopDaemonState(raw.status),
|
||||||
listen: toStringOrNull(raw.listen) ?? '',
|
listen: toStringOrNull(raw.listen) ?? '',
|
||||||
hostname: toStringOrNull(raw.hostname),
|
hostname: toStringOrNull(raw.hostname),
|
||||||
pid: toNumberOrNull(raw.pid),
|
pid: toNumberOrNull(raw.pid),
|
||||||
home: toStringOrNull(raw.home) ?? '',
|
home: toStringOrNull(raw.home) ?? '',
|
||||||
|
error: toStringOrNull(raw.error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
|
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
|
||||||
if (!isRecord(raw)) {
|
if (!isRecord(raw)) {
|
||||||
throw new Error('Unexpected managed daemon logs response.')
|
throw new Error('Unexpected desktop daemon logs response.')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
logPath: toStringOrNull(raw.logPath) ?? '',
|
logPath: toStringOrNull(raw.logPath) ?? '',
|
||||||
@@ -105,9 +100,9 @@ function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
|
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
|
||||||
if (!isRecord(raw)) {
|
if (!isRecord(raw)) {
|
||||||
throw new Error('Unexpected managed daemon pairing response.')
|
throw new Error('Unexpected desktop daemon pairing response.')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
relayEnabled: raw.relayEnabled === true,
|
relayEnabled: raw.relayEnabled === true,
|
||||||
@@ -127,36 +122,32 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldUseManagedDesktopDaemon(): boolean {
|
export function shouldUseDesktopDaemon(): boolean {
|
||||||
return isTauriEnvironment() && getTauri() !== null
|
return isDesktop()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
|
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
||||||
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
|
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
|
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
|
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
|
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
|
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
|
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
|
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
|
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
|
||||||
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
|
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
|
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||||
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
|
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
|
||||||
}
|
|
||||||
|
|
||||||
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
|
|
||||||
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
||||||
@@ -171,27 +162,18 @@ export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstruction
|
|||||||
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
|
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateManagedDaemonTcpSettings(
|
|
||||||
settings: ManagedTcpSettings
|
|
||||||
): Promise<ManagedDaemonStatus> {
|
|
||||||
return parseManagedDaemonStatus(
|
|
||||||
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalTransportEventUnlisten = () => void
|
export type LocalTransportEventUnlisten = () => void
|
||||||
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
|
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
|
||||||
|
|
||||||
export async function listenToLocalTransportEvents(
|
export async function listenToLocalTransportEvents(
|
||||||
handler: LocalTransportEventHandler
|
handler: LocalTransportEventHandler
|
||||||
): Promise<LocalTransportEventUnlisten> {
|
): Promise<LocalTransportEventUnlisten> {
|
||||||
const listen = getTauri()?.event?.listen
|
const listen = getDesktopHost()?.events?.on
|
||||||
if (typeof listen !== 'function') {
|
if (typeof listen !== 'function') {
|
||||||
throw new Error('Tauri event API is unavailable.')
|
throw new Error('Desktop events API is unavailable.')
|
||||||
}
|
}
|
||||||
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
|
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
|
||||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
|
if (!isRecord(payload)) {
|
||||||
if (!payload) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler({
|
handler({
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
|
||||||
|
export type DesktopEventUnlisten = () => void;
|
||||||
|
|
||||||
|
type EventEnvelope = {
|
||||||
|
payload?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listenToDesktopEvent<TPayload>(
|
||||||
|
event: string,
|
||||||
|
handler: (payload: TPayload) => void
|
||||||
|
): Promise<DesktopEventUnlisten> {
|
||||||
|
const listen = getDesktopHost()?.events?.on;
|
||||||
|
if (typeof listen !== "function") {
|
||||||
|
throw new Error("Desktop event API is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlisten = await listen(event, (rawEvent: unknown) => {
|
||||||
|
const payload =
|
||||||
|
typeof rawEvent === "object" && rawEvent !== null && "payload" in rawEvent
|
||||||
|
? (rawEvent as EventEnvelope).payload
|
||||||
|
: rawEvent;
|
||||||
|
handler(payload as TPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
return typeof unlisten === "function" ? unlisten : () => {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { DesktopHostBridge } from "@/desktop/host";
|
||||||
|
|
||||||
|
export function getElectronHost(): DesktopHostBridge | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const host = window.paseoDesktop;
|
||||||
|
if (!host || typeof host !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { getTauri } from "@/utils/tauri";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
|
||||||
export async function invokeDesktopCommand<T>(
|
export async function invokeDesktopCommand<T>(
|
||||||
command: string,
|
command: string,
|
||||||
args?: Record<string, unknown>
|
args?: Record<string, unknown>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const invoke = getTauri()?.core?.invoke;
|
const invoke = getDesktopHost()?.invoke;
|
||||||
if (typeof invoke !== "function") {
|
if (typeof invoke !== "function") {
|
||||||
throw new Error("Tauri invoke() is unavailable in this environment.");
|
throw new Error("Desktop invoke() is unavailable in this environment.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await invoke(command, args)) as T;
|
return (await invoke(command, args)) as T;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
|
||||||
|
|
||||||
|
export function getDesktopWindow(): DesktopWindowBridge | null {
|
||||||
|
const getter = getDesktopHost()?.window?.getCurrentWindow;
|
||||||
|
if (typeof getter !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return getter() ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDesktopDragging(): Promise<void> {
|
||||||
|
const win = getDesktopWindow();
|
||||||
|
if (!win || typeof win.startDragging !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await win.startDragging();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDesktopMaximize(): Promise<void> {
|
||||||
|
const win = getDesktopWindow();
|
||||||
|
if (!win || typeof win.toggleMaximize !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await win.toggleMaximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDesktopFullscreen(): Promise<boolean> {
|
||||||
|
const win = getDesktopWindow();
|
||||||
|
if (!win || typeof win.isFullscreen !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await win.isFullscreen();
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
import { getElectronHost } from "@/desktop/electron/host";
|
||||||
|
|
||||||
|
export type DesktopNotificationPermission = "granted" | "denied" | "default";
|
||||||
|
|
||||||
|
export interface DesktopDialogAskOptions {
|
||||||
|
title?: string;
|
||||||
|
okLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
kind?: "info" | "warning" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopDialogOpenOptions {
|
||||||
|
title?: string;
|
||||||
|
defaultPath?: string;
|
||||||
|
directory?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
filters?: Array<{
|
||||||
|
name: string;
|
||||||
|
extensions: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopDialogBridge {
|
||||||
|
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
|
||||||
|
open?: (
|
||||||
|
options?: DesktopDialogOpenOptions
|
||||||
|
) => Promise<string | string[] | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopNotificationBridge {
|
||||||
|
isSupported?: () => Promise<boolean>;
|
||||||
|
sendNotification?: (
|
||||||
|
payload: string | { title: string; body?: string; data?: Record<string, unknown> }
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopOpenerBridge {
|
||||||
|
openUrl?: (url: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopWindowBridge {
|
||||||
|
label?: string;
|
||||||
|
startDragging?: () => Promise<void>;
|
||||||
|
toggleMaximize?: () => Promise<void>;
|
||||||
|
isFullscreen?: () => Promise<boolean>;
|
||||||
|
onResized?: <TEvent = unknown>(
|
||||||
|
handler: (event: TEvent) => void
|
||||||
|
) => Promise<() => void> | (() => void);
|
||||||
|
setBadgeCount?: (count?: number) => Promise<void>;
|
||||||
|
onDragDropEvent?: <TEvent = unknown>(
|
||||||
|
handler: (event: TEvent) => void
|
||||||
|
) => Promise<() => void> | (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopWindowModuleBridge {
|
||||||
|
getCurrentWindow?: () => DesktopWindowBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopEventsBridge {
|
||||||
|
on?: (
|
||||||
|
event: string,
|
||||||
|
handler: (payload: unknown) => void
|
||||||
|
) => Promise<() => void> | (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopInvokeBridge {
|
||||||
|
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopHostBridge {
|
||||||
|
platform?: string;
|
||||||
|
invoke?: DesktopInvokeBridge["invoke"];
|
||||||
|
events?: DesktopEventsBridge;
|
||||||
|
window?: DesktopWindowModuleBridge;
|
||||||
|
dialog?: DesktopDialogBridge;
|
||||||
|
notification?: DesktopNotificationBridge;
|
||||||
|
opener?: DesktopOpenerBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
paseoDesktop?: DesktopHostBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopHost(): DesktopHostBridge | null {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getElectronHost();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktop(): boolean {
|
||||||
|
return getDesktopHost() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktopMac(): boolean {
|
||||||
|
if (!isDesktop()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hostPlatform = getDesktopHost()?.platform?.toLowerCase();
|
||||||
|
if (hostPlatform === "darwin" || hostPlatform === "mac" || hostPlatform === "macos") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
return ua.includes("Mac OS") || ua.includes("Macintosh");
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { parseCliSymlinkInstructions } from "./managed-runtime";
|
|
||||||
|
|
||||||
describe("parseCliSymlinkInstructions", () => {
|
|
||||||
it("parses CLI symlink instructions from the desktop backend", () => {
|
|
||||||
expect(
|
|
||||||
parseCliSymlinkInstructions({
|
|
||||||
title: "Add paseo to your shell",
|
|
||||||
detail: "Create a symlink to the Paseo desktop executable.",
|
|
||||||
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
title: "Add paseo to your shell",
|
|
||||||
detail: "Create a symlink to the Paseo desktop executable.",
|
|
||||||
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-object payloads", () => {
|
|
||||||
expect(() => parseCliSymlinkInstructions(null)).toThrow(
|
|
||||||
"Unexpected CLI symlink instructions response."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
|
||||||
import { getTauri } from "@/utils/tauri";
|
|
||||||
|
|
||||||
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
|
|
||||||
|
|
||||||
export interface DesktopNotificationInput {
|
|
||||||
title: string;
|
|
||||||
body?: string;
|
|
||||||
data?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DesktopNotificationClickPayload {
|
|
||||||
data?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DesktopNotificationClickHandler = (
|
|
||||||
payload: DesktopNotificationClickPayload
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export type DesktopNotificationClickUnlisten = () => void;
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendDesktopNotification(
|
|
||||||
input: DesktopNotificationInput
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await invokeDesktopCommand("send_desktop_notification", { input });
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listenToDesktopNotificationClicks(
|
|
||||||
handler: DesktopNotificationClickHandler
|
|
||||||
): Promise<DesktopNotificationClickUnlisten> {
|
|
||||||
const listen = getTauri()?.event?.listen;
|
|
||||||
if (typeof listen !== "function") {
|
|
||||||
throw new Error("Tauri event API is unavailable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
|
|
||||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
|
|
||||||
if (!payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handler({
|
|
||||||
data: isRecord(payload.data) ? payload.data : undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return typeof unlisten === "function" ? unlisten : () => {};
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,17 @@ type MockPlatform = 'web' | 'ios' | 'android'
|
|||||||
|
|
||||||
type GlobalSnapshot = {
|
type GlobalSnapshot = {
|
||||||
Notification: unknown
|
Notification: unknown
|
||||||
__TAURI__: unknown
|
|
||||||
navigatorDescriptor?: PropertyDescriptor
|
navigatorDescriptor?: PropertyDescriptor
|
||||||
|
paseoDesktop: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalGlobals: GlobalSnapshot = {
|
const originalGlobals: GlobalSnapshot = {
|
||||||
Notification: (globalThis as { Notification?: unknown }).Notification,
|
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
|
||||||
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
||||||
|
paseoDesktop:
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? undefined
|
||||||
|
: (window as { paseoDesktop?: unknown }).paseoDesktop,
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNavigator(value: unknown): void {
|
function setNavigator(value: unknown): void {
|
||||||
@@ -24,13 +27,16 @@ function setNavigator(value: unknown): void {
|
|||||||
|
|
||||||
function restoreGlobals(): void {
|
function restoreGlobals(): void {
|
||||||
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
|
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
|
||||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
|
|
||||||
|
|
||||||
if (originalGlobals.navigatorDescriptor) {
|
if (originalGlobals.navigatorDescriptor) {
|
||||||
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
|
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
|
||||||
} else {
|
} else {
|
||||||
delete (globalThis as { navigator?: unknown }).navigator
|
delete (globalThis as { navigator?: unknown }).navigator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModuleForPlatform(platform: MockPlatform) {
|
async function loadModuleForPlatform(platform: MockPlatform) {
|
||||||
@@ -47,20 +53,20 @@ describe('desktop-permissions', () => {
|
|||||||
restoreGlobals()
|
restoreGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows section only in Tauri web runtime', async () => {
|
it('shows section only in desktop web runtime', async () => {
|
||||||
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
|
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
|
||||||
|
|
||||||
expect(shouldShowDesktopPermissionSection()).toBe(false)
|
expect(shouldShowDesktopPermissionSection()).toBe(false)
|
||||||
|
|
||||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
|
;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
|
||||||
expect(shouldShowDesktopPermissionSection()).toBe(true)
|
expect(shouldShowDesktopPermissionSection()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads notification and microphone status', async () => {
|
it('reads notification and microphone status', async () => {
|
||||||
const isPermissionGranted = vi.fn(async () => false)
|
class MockNotification {
|
||||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
static permission = 'default'
|
||||||
notification: { isPermissionGranted },
|
|
||||||
}
|
}
|
||||||
|
;(globalThis as { Notification?: unknown }).Notification = MockNotification
|
||||||
setNavigator({
|
setNavigator({
|
||||||
permissions: {
|
permissions: {
|
||||||
query: vi.fn(async () => ({ state: 'granted' })),
|
query: vi.fn(async () => ({ state: 'granted' })),
|
||||||
@@ -73,9 +79,8 @@ describe('desktop-permissions', () => {
|
|||||||
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
|
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
|
||||||
const snapshot = await getDesktopPermissionSnapshot()
|
const snapshot = await getDesktopPermissionSnapshot()
|
||||||
|
|
||||||
expect(snapshot.notifications.state).toBe('not-granted')
|
expect(snapshot.notifications.state).toBe('prompt')
|
||||||
expect(snapshot.microphone.state).toBe('granted')
|
expect(snapshot.microphone.state).toBe('granted')
|
||||||
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
|
|
||||||
expect(snapshot.checkedAt).toBeTypeOf('number')
|
expect(snapshot.checkedAt).toBeTypeOf('number')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,20 +132,21 @@ describe('desktop-permissions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requests notification permission via Tauri', async () => {
|
it('requests notification permission via the browser Notification API', async () => {
|
||||||
const requestPermission = vi.fn(async () => 'granted')
|
class MockNotification {
|
||||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
static permission = 'default'
|
||||||
notification: { requestPermission },
|
static requestPermission = vi.fn(async () => 'granted')
|
||||||
}
|
}
|
||||||
|
;(globalThis as { Notification?: unknown }).Notification = MockNotification
|
||||||
|
|
||||||
const { requestDesktopPermission } = await loadModuleForPlatform('web')
|
const { requestDesktopPermission } = await loadModuleForPlatform('web')
|
||||||
const result = await requestDesktopPermission({ kind: 'notifications' })
|
const result = await requestDesktopPermission({ kind: 'notifications' })
|
||||||
|
|
||||||
expect(result.state).toBe('granted')
|
expect(result.state).toBe('granted')
|
||||||
expect(requestPermission).toHaveBeenCalledTimes(1)
|
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
|
it('reads browser Notification permission when available', async () => {
|
||||||
class MockNotification {
|
class MockNotification {
|
||||||
static permission = 'denied'
|
static permission = 'denied'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from 'react-native'
|
import { Platform } from 'react-native'
|
||||||
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
|
import { getDesktopHost } from '@/desktop/host'
|
||||||
|
|
||||||
export type DesktopPermissionKind = 'notifications' | 'microphone'
|
export type DesktopPermissionKind = 'notifications' | 'microphone'
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ type NavigatorLike = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowDesktopPermissionSection(): boolean {
|
export function shouldShowDesktopPermissionSection(): boolean {
|
||||||
return Platform.OS === 'web' && getTauri() !== null
|
return Platform.OS === 'web' && getDesktopHost() !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
||||||
@@ -132,27 +132,6 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapTauriNotificationPermissionResult(
|
|
||||||
permission: TauriNotificationPermission
|
|
||||||
): DesktopPermissionStatus {
|
|
||||||
if (permission === 'granted') {
|
|
||||||
return status({
|
|
||||||
state: 'granted',
|
|
||||||
detail: 'Notifications are allowed by the OS.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (permission === 'denied') {
|
|
||||||
return status({
|
|
||||||
state: 'denied',
|
|
||||||
detail: 'Notifications are denied in system settings.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return status({
|
|
||||||
state: 'prompt',
|
|
||||||
detail: 'Notifications have not been granted yet.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== 'web') {
|
if (Platform.OS !== 'web') {
|
||||||
return status({
|
return status({
|
||||||
@@ -161,44 +140,30 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tauriNotification = getTauri()?.notification
|
const desktopHost = getDesktopHost()
|
||||||
if (tauriNotification) {
|
if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
|
||||||
if (typeof tauriNotification.isPermissionGranted !== 'function') {
|
|
||||||
return status({
|
|
||||||
state: 'unavailable',
|
|
||||||
detail: 'Tauri notification plugin is missing isPermissionGranted().',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const granted = await tauriNotification.isPermissionGranted()
|
const supported = await desktopHost.notification.isSupported()
|
||||||
if (granted) {
|
|
||||||
return status({
|
|
||||||
state: 'granted',
|
|
||||||
detail: 'Tauri reports notifications are granted.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return status({
|
return status({
|
||||||
state: 'not-granted',
|
state: supported ? 'granted' : 'unavailable',
|
||||||
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
|
detail: supported
|
||||||
})
|
? 'Desktop notifications are supported.'
|
||||||
} catch (error) {
|
: 'Desktop notifications are not supported on this platform.',
|
||||||
return status({
|
|
||||||
state: 'unknown',
|
|
||||||
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
|
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
// Fall through to web API check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationConstructor = getWebNotificationConstructor()
|
const NotificationConstructor = getWebNotificationConstructor()
|
||||||
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') {
|
if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
|
||||||
return status({
|
return mapNotificationPermissionString(NotificationConstructor.permission)
|
||||||
state: 'unavailable',
|
|
||||||
detail: 'Web Notification API is unavailable in this environment.',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapNotificationPermissionString(NotificationConstructor.permission)
|
return status({
|
||||||
|
state: 'unavailable',
|
||||||
|
detail: 'Web Notification API is unavailable in this environment.',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
@@ -279,18 +244,11 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tauriNotification = getTauri()?.notification
|
const NotificationConstructor = getWebNotificationConstructor()
|
||||||
if (tauriNotification) {
|
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
|
||||||
if (typeof tauriNotification.requestPermission !== 'function') {
|
|
||||||
return status({
|
|
||||||
state: 'unavailable',
|
|
||||||
detail: 'Tauri notification plugin is missing requestPermission().',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const permission = await tauriNotification.requestPermission()
|
const permission = await NotificationConstructor.requestPermission()
|
||||||
return mapTauriNotificationPermissionResult(permission)
|
return mapNotificationPermissionString(permission)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return status({
|
return status({
|
||||||
state: 'unknown',
|
state: 'unknown',
|
||||||
@@ -299,23 +257,10 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationConstructor = getWebNotificationConstructor()
|
return status({
|
||||||
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
|
state: 'unavailable',
|
||||||
return status({
|
detail: 'Web Notification API requestPermission() is unavailable.',
|
||||||
state: 'unavailable',
|
})
|
||||||
detail: 'Web Notification API requestPermission() is unavailable.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const permission = await NotificationConstructor.requestPermission()
|
|
||||||
return mapNotificationPermissionString(permission)
|
|
||||||
} catch (error) {
|
|
||||||
return status({
|
|
||||||
state: 'unknown',
|
|
||||||
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
try {
|
try {
|
||||||
const sent = await sendOsNotification({
|
const sent = await sendOsNotification({
|
||||||
title: "Paseo notification test",
|
title: "Paseo notification test",
|
||||||
body: "If you can see this, delivery works. Click it to verify the open flow.",
|
body: "If you can see this, desktop notifications work.",
|
||||||
});
|
});
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
console.warn("[Settings] Desktop test notification was not delivered");
|
console.warn("[Settings] Desktop test notification was not delivered");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getTauri } from '@/utils/tauri'
|
import { getDesktopHost } from '@/desktop/host'
|
||||||
|
|
||||||
export async function pickDirectory(): Promise<string | null> {
|
export async function pickDirectory(): Promise<string | null> {
|
||||||
const open = getTauri()?.dialog?.open
|
const open = getDesktopHost()?.dialog?.open
|
||||||
if (typeof open !== 'function') {
|
if (typeof open !== 'function') {
|
||||||
throw new Error('Tauri dialog open() is unavailable in this environment.')
|
throw new Error('Desktop dialog open() is unavailable in this environment.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = await open({
|
const selection = await open({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Platform } from 'react-native'
|
import { Platform } from 'react-native'
|
||||||
import { getTauri } from '@/utils/tauri'
|
import { isDesktop } from '@/desktop/host'
|
||||||
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
|
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
|
||||||
|
|
||||||
export interface DesktopAppUpdateCheckResult {
|
export interface DesktopAppUpdateCheckResult {
|
||||||
hasUpdate: boolean
|
hasUpdate: boolean
|
||||||
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowDesktopUpdateSection(): boolean {
|
export function shouldShowDesktopUpdateSection(): boolean {
|
||||||
return Platform.OS === 'web' && getTauri() !== null
|
return Platform.OS === 'web' && isDesktop()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const tauriState = vi.hoisted(() => ({
|
const desktopHostState = vi.hoisted(() => ({
|
||||||
api: null as any,
|
api: null as any,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/tauri", () => ({
|
vi.mock("@/desktop/host", () => ({
|
||||||
getTauri: () => tauriState.api,
|
getDesktopHost: () => desktopHostState.api,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
normalizePickedImageAssets,
|
normalizePickedImageAssets,
|
||||||
openImagePathsWithTauriDialog,
|
openImagePathsWithDesktopDialog,
|
||||||
} from "./image-attachment-picker";
|
} from "./image-attachment-picker";
|
||||||
|
|
||||||
describe("image-attachment-picker", () => {
|
describe("image-attachment-picker", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tauriState.api = null;
|
desktopHostState.api = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes a picked File into a blob source", async () => {
|
it("normalizes a picked File into a blob source", async () => {
|
||||||
@@ -69,13 +69,13 @@ describe("image-attachment-picker", () => {
|
|||||||
expect(result[0]?.mimeType).toBe("image/png");
|
expect(result[0]?.mimeType).toBe("image/png");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the tauri dialog api when available", async () => {
|
it("uses the desktop dialog api when available", async () => {
|
||||||
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
|
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||||
tauriState.api = {
|
desktopHostState.api = {
|
||||||
dialog: { open },
|
dialog: { open },
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await openImagePathsWithTauriDialog();
|
const result = await openImagePathsWithDesktopDialog();
|
||||||
|
|
||||||
expect(open).toHaveBeenCalledWith(
|
expect(open).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -87,21 +87,11 @@ describe("image-attachment-picker", () => {
|
|||||||
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to core invoke for the tauri dialog plugin", async () => {
|
it("throws when desktop dialog API is not available", async () => {
|
||||||
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
|
desktopHostState.api = {};
|
||||||
tauriState.api = {
|
|
||||||
core: { invoke },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await openImagePathsWithTauriDialog();
|
await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
|
||||||
|
"Desktop dialog API is not available."
|
||||||
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
|
);
|
||||||
options: expect.objectContaining({
|
|
||||||
multiple: true,
|
|
||||||
directory: false,
|
|
||||||
title: "Attach images",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(result).toEqual(["/tmp/one.png"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getTauri } from "@/utils/tauri";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
|
||||||
export type PickedImageSource =
|
export type PickedImageSource =
|
||||||
| { kind: "file_uri"; uri: string }
|
| { kind: "file_uri"; uri: string }
|
||||||
@@ -77,15 +77,15 @@ export async function normalizePickedImageAssets(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
|
function normalizeDesktopDialogSelection(selection: string | string[] | null): string[] {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return Array.isArray(selection) ? selection : [selection];
|
return Array.isArray(selection) ? selection : [selection];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
|
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
|
||||||
const tauri = getTauri();
|
const desktop = getDesktopHost();
|
||||||
const options = {
|
const options = {
|
||||||
directory: false,
|
directory: false,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
@@ -93,18 +93,10 @@ export async function openImagePathsWithTauriDialog(): Promise<string[]> {
|
|||||||
title: "Attach images",
|
title: "Attach images",
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogOpen = tauri?.dialog?.open;
|
const dialogOpen = desktop?.dialog?.open;
|
||||||
if (typeof dialogOpen === "function") {
|
if (typeof dialogOpen !== "function") {
|
||||||
return normalizeTauriDialogSelection(await dialogOpen(options));
|
throw new Error("Desktop dialog API is not available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoke = tauri?.core?.invoke;
|
return normalizeDesktopDialogSelection(await dialogOpen(options));
|
||||||
if (typeof invoke !== "function") {
|
|
||||||
throw new Error("Tauri dialog API is not available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await invoke("plugin:dialog|open", { options });
|
|
||||||
return normalizeTauriDialogSelection(
|
|
||||||
Array.isArray(result) || typeof result === "string" || result === null ? result : null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
|
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { isDesktop } from "@/desktop/host";
|
||||||
|
|
||||||
export interface AudioCaptureConfig {
|
export interface AudioCaptureConfig {
|
||||||
sampleRate?: number;
|
sampleRate?: number;
|
||||||
@@ -157,20 +157,20 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
: true;
|
: true;
|
||||||
const currentOrigin =
|
const currentOrigin =
|
||||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||||
const isTauri = getTauri() !== null;
|
const isDesktopApp = isDesktop();
|
||||||
|
|
||||||
if (missingNavigator) {
|
if (missingNavigator) {
|
||||||
throw new Error("Microphone capture is not supported in this environment");
|
throw new Error("Microphone capture is not supported in this environment");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!secureContext && !isTauri) {
|
if (!secureContext && !isDesktopApp) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!secureContext && isTauri) {
|
if (!secureContext && isDesktopApp) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
|
"[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
|
||||||
{ currentOrigin }
|
{ currentOrigin }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { parsePcm16Wav } from "@/utils/pcm16-wav";
|
import { parsePcm16Wav } from "@/utils/pcm16-wav";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { isDesktop } from "@/desktop/host";
|
||||||
|
|
||||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||||
|
|
||||||
@@ -158,17 +158,17 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
: true;
|
: true;
|
||||||
const currentOrigin =
|
const currentOrigin =
|
||||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||||
const isTauri = getTauri() !== null;
|
const isDesktopApp = isDesktop();
|
||||||
|
|
||||||
if (missingNavigator) {
|
if (missingNavigator) {
|
||||||
throw new Error("Microphone capture is not supported in this environment");
|
throw new Error("Microphone capture is not supported in this environment");
|
||||||
}
|
}
|
||||||
if (!secureContext && !isTauri) {
|
if (!secureContext && !isDesktopApp) {
|
||||||
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
|
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
|
||||||
}
|
}
|
||||||
if (!secureContext && isTauri) {
|
if (!secureContext && isDesktopApp) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
|
"[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
|
||||||
{ currentOrigin }
|
{ currentOrigin }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getIsTauriMac } from "@/constants/layout";
|
import { getIsDesktopMac } from "@/constants/layout";
|
||||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||||
import { getCurrentTauriWindow } from "@/utils/tauri";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
|
||||||
type FaviconStatus = "none" | "running" | "attention";
|
type FaviconStatus = "none" | "running" | "attention";
|
||||||
type ColorScheme = "dark" | "light";
|
type ColorScheme = "dark" | "light";
|
||||||
@@ -92,15 +92,15 @@ function getSystemColorScheme(): ColorScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateMacDockBadge(count?: number) {
|
async function updateMacDockBadge(count?: number) {
|
||||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
||||||
|
|
||||||
const tauriWindow = getCurrentTauriWindow();
|
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
|
||||||
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
|
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tauriWindow.setBadgeCount(count);
|
await desktopWindow.setBadgeCount(count);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
|
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import {
|
import {
|
||||||
persistAttachmentFromBlob,
|
persistAttachmentFromBlob,
|
||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
@@ -33,7 +33,7 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
|||||||
".tiff": "image/tiff",
|
".tiff": "image/tiff",
|
||||||
};
|
};
|
||||||
|
|
||||||
type TauriDragDropPayload =
|
type DesktopDragDropPayload =
|
||||||
| {
|
| {
|
||||||
type: "enter";
|
type: "enter";
|
||||||
paths: string[];
|
paths: string[];
|
||||||
@@ -49,8 +49,8 @@ type TauriDragDropPayload =
|
|||||||
type: "leave";
|
type: "leave";
|
||||||
};
|
};
|
||||||
|
|
||||||
type TauriDragDropEvent = {
|
type DesktopDragDropEvent = {
|
||||||
payload: TauriDragDropPayload;
|
payload: DesktopDragDropPayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isImageFile(file: File): boolean {
|
function isImageFile(file: File): boolean {
|
||||||
@@ -121,26 +121,27 @@ export function useFileDropZone({
|
|||||||
didCleanup = true;
|
didCleanup = true;
|
||||||
try {
|
try {
|
||||||
void Promise.resolve(cleanupFn()).catch((error) => {
|
void Promise.resolve(cleanupFn()).catch((error) => {
|
||||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupTauriDragDrop(): Promise<boolean> {
|
async function setupDesktopDragDrop(): Promise<boolean> {
|
||||||
if (getTauri() === null) {
|
const desktopHost = getDesktopHost();
|
||||||
|
if (desktopHost === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tauriWindow = getCurrentTauriWindow();
|
const desktopWindow = desktopHost.window?.getCurrentWindow?.();
|
||||||
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const unlisten = await tauriWindow.onDragDropEvent(
|
const unlisten = await desktopWindow.onDragDropEvent(
|
||||||
(event: TauriDragDropEvent) => {
|
(event: DesktopDragDropEvent) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
if (payload.type === "leave") {
|
if (payload.type === "leave") {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
@@ -185,7 +186,7 @@ export function useFileDropZone({
|
|||||||
cleanup = unlisten;
|
cleanup = unlisten;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
|
console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,8 +270,8 @@ export function useFileDropZone({
|
|||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const tauriListenersAttached = await setupTauriDragDrop();
|
const desktopListenersAttached = await setupDesktopDragDrop();
|
||||||
if (disposed || tauriListenersAttached) {
|
if (disposed || desktopListenersAttached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setupDomDragDrop();
|
setupDomDragDrop();
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useCallback, useRef } from "react";
|
|||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { isTauriEnvironment } from "@/utils/tauri";
|
import { isDesktop } from "@/desktop/host";
|
||||||
import {
|
import {
|
||||||
normalizePickedImageAssets,
|
normalizePickedImageAssets,
|
||||||
openImagePathsWithTauriDialog,
|
openImagePathsWithDesktopDialog,
|
||||||
type PickedImageAttachmentInput,
|
type PickedImageAttachmentInput,
|
||||||
} from "@/hooks/image-attachment-picker";
|
} from "@/hooks/image-attachment-picker";
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
|||||||
isPickingRef.current = true;
|
isPickingRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Platform.OS === "web" && isTauriEnvironment()) {
|
if (Platform.OS === "web" && isDesktop()) {
|
||||||
const selectedPaths = await openImagePathsWithTauriDialog();
|
const selectedPaths = await openImagePathsWithDesktopDialog();
|
||||||
if (selectedPaths.length === 0) {
|
if (selectedPaths.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime'
|
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
|
||||||
import { isTauriEnvironment } from '@/utils/tauri'
|
|
||||||
|
|
||||||
const MANAGED_DAEMON_SERVER_ID_QUERY_KEY = ['managed-daemon-server-id'] as const
|
const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ['desktop-daemon-server-id'] as const
|
||||||
|
|
||||||
interface ManagedDaemonServerIdResult {
|
interface DesktopDaemonServerIdResult {
|
||||||
serverId: string | null
|
serverId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult> {
|
async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult> {
|
||||||
const status = await getManagedDaemonStatus()
|
const status = await getDesktopDaemonStatus()
|
||||||
const serverId = status.serverId.trim()
|
const serverId = status.serverId.trim()
|
||||||
return {
|
return {
|
||||||
serverId: serverId.length > 0 ? serverId : null,
|
serverId: serverId.length > 0 ? serverId : null,
|
||||||
@@ -18,14 +17,15 @@ async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult>
|
|||||||
|
|
||||||
export function useIsLocalDaemon(serverId: string): boolean {
|
export function useIsLocalDaemon(serverId: string): boolean {
|
||||||
const normalizedServerId = serverId.trim()
|
const normalizedServerId = serverId.trim()
|
||||||
const isDesktop = isTauriEnvironment()
|
const isDesktop = shouldUseDesktopDaemon()
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY,
|
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
|
||||||
queryFn: loadManagedDaemonServerId,
|
queryFn: loadDesktopDaemonServerId,
|
||||||
enabled: isDesktop,
|
enabled: isDesktop,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
gcTime: Infinity,
|
gcTime: Infinity,
|
||||||
|
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { usePathname } from "expo-router";
|
import { usePathname } from "expo-router";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsDesktop } from "@/constants/layout";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
@@ -50,7 +50,7 @@ export function useKeyboardShortcuts({
|
|||||||
if (Platform.OS !== "web") return;
|
if (Platform.OS !== "web") return;
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
|
|
||||||
const isTauri = getIsTauri();
|
const isDesktopApp = getIsDesktop();
|
||||||
const isMac = getShortcutOs() === "mac";
|
const isMac = getShortcutOs() === "mac";
|
||||||
|
|
||||||
const shouldHandle = () => {
|
const shouldHandle = () => {
|
||||||
@@ -259,7 +259,7 @@ export function useKeyboardShortcuts({
|
|||||||
if (key === "Alt" && !event.shiftKey) {
|
if (key === "Alt" && !event.shiftKey) {
|
||||||
useKeyboardShortcutsStore.getState().setAltDown(true);
|
useKeyboardShortcutsStore.getState().setAltDown(true);
|
||||||
}
|
}
|
||||||
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
if (isDesktopApp && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
||||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
|
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
|
||||||
}
|
}
|
||||||
if (key === "Shift") {
|
if (key === "Shift") {
|
||||||
@@ -278,7 +278,7 @@ export function useKeyboardShortcuts({
|
|||||||
event,
|
event,
|
||||||
context: {
|
context: {
|
||||||
isMac,
|
isMac,
|
||||||
isTauri,
|
isDesktop: isDesktopApp,
|
||||||
focusScope,
|
focusScope,
|
||||||
commandCenterOpen: store.commandCenterOpen,
|
commandCenterOpen: store.commandCenterOpen,
|
||||||
hasSelectedAgent: canToggleFileExplorerShortcut({
|
hasSelectedAgent: canToggleFileExplorerShortcut({
|
||||||
@@ -314,7 +314,7 @@ export function useKeyboardShortcuts({
|
|||||||
if (key === "Alt") {
|
if (key === "Alt") {
|
||||||
useKeyboardShortcutsStore.getState().setAltDown(false);
|
useKeyboardShortcutsStore.getState().setAltDown(false);
|
||||||
}
|
}
|
||||||
if (isTauri && (key === "Meta" || key === "Control")) {
|
if (isDesktopApp && (key === "Meta" || key === "Control")) {
|
||||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
|
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function shortcutContext(
|
|||||||
): KeyboardShortcutContext {
|
): KeyboardShortcutContext {
|
||||||
return {
|
return {
|
||||||
isMac: false,
|
isMac: false,
|
||||||
isTauri: false,
|
isDesktop: false,
|
||||||
focusScope: "other",
|
focusScope: "other",
|
||||||
commandCenterOpen: false,
|
commandCenterOpen: false,
|
||||||
hasSelectedAgent: true,
|
hasSelectedAgent: true,
|
||||||
@@ -84,7 +84,7 @@ type HelpSectionCase = {
|
|||||||
name: string;
|
name: string;
|
||||||
context: {
|
context: {
|
||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
isTauri: boolean;
|
isDesktop: boolean;
|
||||||
};
|
};
|
||||||
expectedKeys: Record<string, string[]>;
|
expectedKeys: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
@@ -106,42 +106,42 @@ describe("keyboard-shortcuts", () => {
|
|||||||
{
|
{
|
||||||
name: "matches workspace index jump on web via Alt+digit",
|
name: "matches workspace index jump on web via Alt+digit",
|
||||||
event: { key: "2", code: "Digit2", altKey: true },
|
event: { key: "2", code: "Digit2", altKey: true },
|
||||||
context: { isTauri: false },
|
context: { isDesktop: false },
|
||||||
action: "workspace.navigate.index",
|
action: "workspace.navigate.index",
|
||||||
payload: { index: 2 },
|
payload: { index: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches workspace index jump on tauri via Mod+digit",
|
name: "matches workspace index jump on desktop via Mod+digit",
|
||||||
event: { key: "2", code: "Digit2", metaKey: true },
|
event: { key: "2", code: "Digit2", metaKey: true },
|
||||||
context: { isMac: true, isTauri: true },
|
context: { isMac: true, isDesktop: true },
|
||||||
action: "workspace.navigate.index",
|
action: "workspace.navigate.index",
|
||||||
payload: { index: 2 },
|
payload: { index: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches tab index jump on tauri via Alt+digit",
|
name: "matches tab index jump on desktop via Alt+digit",
|
||||||
event: { key: "2", code: "Digit2", altKey: true },
|
event: { key: "2", code: "Digit2", altKey: true },
|
||||||
context: { isTauri: true },
|
context: { isDesktop: true },
|
||||||
action: "workspace.tab.navigate.index",
|
action: "workspace.tab.navigate.index",
|
||||||
payload: { index: 2 },
|
payload: { index: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches tab index jump on web via Alt+Shift+digit",
|
name: "matches tab index jump on web via Alt+Shift+digit",
|
||||||
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
|
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
|
||||||
context: { isTauri: false },
|
context: { isDesktop: false },
|
||||||
action: "workspace.tab.navigate.index",
|
action: "workspace.tab.navigate.index",
|
||||||
payload: { index: 2 },
|
payload: { index: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches workspace relative navigation on web via Alt+[",
|
name: "matches workspace relative navigation on web via Alt+[",
|
||||||
event: { key: "[", code: "BracketLeft", altKey: true },
|
event: { key: "[", code: "BracketLeft", altKey: true },
|
||||||
context: { isTauri: false },
|
context: { isDesktop: false },
|
||||||
action: "workspace.navigate.relative",
|
action: "workspace.navigate.relative",
|
||||||
payload: { delta: -1 },
|
payload: { delta: -1 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches workspace relative navigation on tauri via Mod+]",
|
name: "matches workspace relative navigation on desktop via Mod+]",
|
||||||
event: { key: "]", code: "BracketRight", ctrlKey: true },
|
event: { key: "]", code: "BracketRight", ctrlKey: true },
|
||||||
context: { isTauri: true },
|
context: { isDesktop: true },
|
||||||
action: "workspace.navigate.relative",
|
action: "workspace.navigate.relative",
|
||||||
payload: { delta: 1 },
|
payload: { delta: 1 },
|
||||||
},
|
},
|
||||||
@@ -160,13 +160,13 @@ describe("keyboard-shortcuts", () => {
|
|||||||
{
|
{
|
||||||
name: "matches Alt+Shift+W to close current tab on web",
|
name: "matches Alt+Shift+W to close current tab on web",
|
||||||
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
|
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
|
||||||
context: { isTauri: false },
|
context: { isDesktop: false },
|
||||||
action: "workspace.tab.close.current",
|
action: "workspace.tab.close.current",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches Mod+W to close current tab on tauri",
|
name: "matches Mod+W to close current tab on desktop",
|
||||||
event: { key: "w", code: "KeyW", metaKey: true },
|
event: { key: "w", code: "KeyW", metaKey: true },
|
||||||
context: { isMac: true, isTauri: true },
|
context: { isMac: true, isDesktop: true },
|
||||||
action: "workspace.tab.close.current",
|
action: "workspace.tab.close.current",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -312,7 +312,7 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
const helpCases: HelpSectionCase[] = [
|
const helpCases: HelpSectionCase[] = [
|
||||||
{
|
{
|
||||||
name: "uses web defaults for workspace and tab jump",
|
name: "uses web defaults for workspace and tab jump",
|
||||||
context: { isMac: true, isTauri: false },
|
context: { isMac: true, isDesktop: false },
|
||||||
expectedKeys: {
|
expectedKeys: {
|
||||||
"new-agent": ["mod", "shift", "O"],
|
"new-agent": ["mod", "shift", "O"],
|
||||||
"workspace-tab-new": ["mod", "T"],
|
"workspace-tab-new": ["mod", "T"],
|
||||||
@@ -324,8 +324,8 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "uses tauri defaults for workspace and tab jump",
|
name: "uses desktop defaults for workspace and tab jump",
|
||||||
context: { isMac: true, isTauri: true },
|
context: { isMac: true, isDesktop: true },
|
||||||
expectedKeys: {
|
expectedKeys: {
|
||||||
"new-agent": ["mod", "shift", "O"],
|
"new-agent": ["mod", "shift", "O"],
|
||||||
"workspace-tab-new": ["mod", "T"],
|
"workspace-tab-new": ["mod", "T"],
|
||||||
@@ -338,7 +338,7 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "uses mod+period as non-mac left sidebar shortcut",
|
name: "uses mod+period as non-mac left sidebar shortcut",
|
||||||
context: { isMac: false, isTauri: false },
|
context: { isMac: false, isDesktop: false },
|
||||||
expectedKeys: {
|
expectedKeys: {
|
||||||
"toggle-left-sidebar": ["mod", "."],
|
"toggle-left-sidebar": ["mod", "."],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
|
|
||||||
export type KeyboardShortcutContext = {
|
export type KeyboardShortcutContext = {
|
||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
isTauri: boolean;
|
isDesktop: boolean;
|
||||||
focusScope: KeyboardFocusScope;
|
focusScope: KeyboardFocusScope;
|
||||||
commandCenterOpen: boolean;
|
commandCenterOpen: boolean;
|
||||||
hasSelectedAgent: boolean;
|
hasSelectedAgent: boolean;
|
||||||
@@ -36,7 +36,7 @@ export type KeyboardShortcutHelpSection = {
|
|||||||
|
|
||||||
type KeyboardShortcutPlatformContext = {
|
type KeyboardShortcutPlatformContext = {
|
||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
isTauri: boolean;
|
isDesktop: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type KeyboardShortcutHelpEntry = {
|
type KeyboardShortcutHelpEntry = {
|
||||||
@@ -158,20 +158,20 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "workspace-tab-close-current-mod-w-tauri",
|
id: "workspace-tab-close-current-mod-w-desktop",
|
||||||
action: "workspace.tab.close.current",
|
action: "workspace.tab.close.current",
|
||||||
matches: (event) =>
|
matches: (event) =>
|
||||||
isMod(event) &&
|
isMod(event) &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
||||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-close-current",
|
id: "workspace-tab-close-current",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Close current tab",
|
label: "Close current tab",
|
||||||
keys: ["mod", "W"],
|
keys: ["mod", "W"],
|
||||||
when: (context) => context.isTauri,
|
when: (context) => context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -183,17 +183,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
event.altKey &&
|
event.altKey &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
||||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-close-current",
|
id: "workspace-tab-close-current",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Close current tab",
|
label: "Close current tab",
|
||||||
keys: ["alt", "shift", "W"],
|
keys: ["alt", "shift", "W"],
|
||||||
when: (context) => !context.isTauri,
|
when: (context) => !context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "workspace-navigate-index-mod-digit-tauri",
|
id: "workspace-navigate-index-mod-digit-desktop",
|
||||||
action: "workspace.navigate.index",
|
action: "workspace.navigate.index",
|
||||||
matches: (event) =>
|
matches: (event) =>
|
||||||
isMod(event) &&
|
isMod(event) &&
|
||||||
@@ -201,13 +201,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
hasDigit(event),
|
hasDigit(event),
|
||||||
payload: withIndexPayload,
|
payload: withIndexPayload,
|
||||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-jump-index",
|
id: "workspace-jump-index",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Jump to workspace",
|
label: "Jump to workspace",
|
||||||
keys: ["mod", "1-9"],
|
keys: ["mod", "1-9"],
|
||||||
when: (context) => context.isTauri,
|
when: (context) => context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -220,17 +220,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
hasDigit(event),
|
hasDigit(event),
|
||||||
payload: withIndexPayload,
|
payload: withIndexPayload,
|
||||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-jump-index",
|
id: "workspace-jump-index",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Jump to workspace",
|
label: "Jump to workspace",
|
||||||
keys: ["alt", "1-9"],
|
keys: ["alt", "1-9"],
|
||||||
when: (context) => !context.isTauri,
|
when: (context) => !context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "workspace-tab-navigate-index-alt-digit-tauri",
|
id: "workspace-tab-navigate-index-alt-digit-desktop",
|
||||||
action: "workspace.tab.navigate.index",
|
action: "workspace.tab.navigate.index",
|
||||||
matches: (event) =>
|
matches: (event) =>
|
||||||
!event.metaKey &&
|
!event.metaKey &&
|
||||||
@@ -239,13 +239,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
hasDigit(event),
|
hasDigit(event),
|
||||||
payload: withIndexPayload,
|
payload: withIndexPayload,
|
||||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-jump-index",
|
id: "workspace-tab-jump-index",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Jump to tab",
|
label: "Jump to tab",
|
||||||
keys: ["alt", "1-9"],
|
keys: ["alt", "1-9"],
|
||||||
when: (context) => context.isTauri,
|
when: (context) => context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -258,13 +258,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
hasDigit(event),
|
hasDigit(event),
|
||||||
payload: withIndexPayload,
|
payload: withIndexPayload,
|
||||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-jump-index",
|
id: "workspace-tab-jump-index",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Jump to tab",
|
label: "Jump to tab",
|
||||||
keys: ["alt", "shift", "1-9"],
|
keys: ["alt", "shift", "1-9"],
|
||||||
when: (context) => !context.isTauri,
|
when: (context) => !context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -276,13 +276,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "BracketLeft" || event.key === "["),
|
(event.code === "BracketLeft" || event.key === "["),
|
||||||
payload: withRelativeDelta(-1),
|
payload: withRelativeDelta(-1),
|
||||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-prev",
|
id: "workspace-prev",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Previous workspace",
|
label: "Previous workspace",
|
||||||
keys: ["mod", "["],
|
keys: ["mod", "["],
|
||||||
when: (context) => context.isTauri,
|
when: (context) => context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -294,13 +294,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "BracketRight" || event.key === "]"),
|
(event.code === "BracketRight" || event.key === "]"),
|
||||||
payload: withRelativeDelta(1),
|
payload: withRelativeDelta(1),
|
||||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
when: (context) => context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-next",
|
id: "workspace-next",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Next workspace",
|
label: "Next workspace",
|
||||||
keys: ["mod", "]"],
|
keys: ["mod", "]"],
|
||||||
when: (context) => context.isTauri,
|
when: (context) => context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -313,13 +313,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "BracketLeft" || event.key === "["),
|
(event.code === "BracketLeft" || event.key === "["),
|
||||||
payload: withRelativeDelta(-1),
|
payload: withRelativeDelta(-1),
|
||||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-prev",
|
id: "workspace-prev",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Previous workspace",
|
label: "Previous workspace",
|
||||||
keys: ["alt", "["],
|
keys: ["alt", "["],
|
||||||
when: (context) => !context.isTauri,
|
when: (context) => !context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -332,13 +332,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "BracketRight" || event.key === "]"),
|
(event.code === "BracketRight" || event.key === "]"),
|
||||||
payload: withRelativeDelta(1),
|
payload: withRelativeDelta(1),
|
||||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
when: (context) => !context.isDesktop && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "workspace-next",
|
id: "workspace-next",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Next workspace",
|
label: "Next workspace",
|
||||||
keys: ["alt", "]"],
|
keys: ["alt", "]"],
|
||||||
when: (context) => !context.isTauri,
|
when: (context) => !context.isDesktop,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import {
|
|||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||||
import {
|
import {
|
||||||
shouldUseManagedDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
startManagedDaemon,
|
startDesktopDaemon,
|
||||||
} from "@/desktop/managed-runtime/managed-runtime";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import {
|
import {
|
||||||
buildDaemonWebSocketUrl,
|
buildDaemonWebSocketUrl,
|
||||||
@@ -32,8 +32,8 @@ import {
|
|||||||
} from "@/utils/connection-selection";
|
} from "@/utils/connection-selection";
|
||||||
import {
|
import {
|
||||||
buildLocalDaemonTransportUrl,
|
buildLocalDaemonTransportUrl,
|
||||||
createTauriLocalDaemonTransportFactory,
|
createDesktopLocalDaemonTransportFactory,
|
||||||
} from "@/utils/managed-tauri-daemon-transport";
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@ function probeIntervalForConnection(
|
|||||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||||
return {
|
return {
|
||||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||||
const base = {
|
const base = {
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -1188,7 +1188,7 @@ export class HostRuntimeStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUseManagedDesktopDaemon()) {
|
if (shouldUseDesktopDaemon()) {
|
||||||
await this.bootstrapDesktop();
|
await this.bootstrapDesktop();
|
||||||
} else {
|
} else {
|
||||||
await this.bootstrapLocalhost();
|
await this.bootstrapLocalhost();
|
||||||
@@ -1197,43 +1197,22 @@ export class HostRuntimeStore {
|
|||||||
|
|
||||||
private async bootstrapDesktop(): Promise<void> {
|
private async bootstrapDesktop(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
const daemon = await startDesktopDaemon();
|
||||||
(async () => {
|
const connection = connectionFromListen(daemon.listen);
|
||||||
const daemon = await startManagedDaemon();
|
if (!connection) {
|
||||||
if (!daemon.serverId) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
||||||
const connection = connectionFromListen(daemon.listen);
|
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||||
if (!connection) {
|
});
|
||||||
return;
|
await this.upsertHostConnection({
|
||||||
}
|
serverId,
|
||||||
await this.upsertHostConnection({
|
label: hostname ?? daemon.hostname ?? undefined,
|
||||||
serverId: daemon.serverId,
|
connection,
|
||||||
label: daemon.hostname ?? undefined,
|
existingClient: client,
|
||||||
connection,
|
});
|
||||||
});
|
|
||||||
})().catch((error) => {
|
|
||||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
|
||||||
}),
|
|
||||||
(async () => {
|
|
||||||
const { client, serverId, hostname } = await connectToDaemon(
|
|
||||||
{
|
|
||||||
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
|
||||||
type: "directTcp",
|
|
||||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
|
||||||
},
|
|
||||||
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
await this.upsertDirectConnection({
|
|
||||||
serverId,
|
|
||||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
|
||||||
label: hostname ?? undefined,
|
|
||||||
existingClient: client,
|
|
||||||
});
|
|
||||||
})().catch(() => undefined),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[HostRuntime] Failed to bootstrap desktop startup host connections", error);
|
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import type {
|
|||||||
} from '@server/server/agent/agent-sdk-types'
|
} from '@server/server/agent/agent-sdk-types'
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
|
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
|
||||||
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
||||||
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
import { useDesktopDragHandlers } from '@/utils/desktop-window'
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||||
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
||||||
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
||||||
@@ -238,7 +238,7 @@ function DraftAgentScreenContent({
|
|||||||
const activateExplorerTabForCheckout = usePanelStore(
|
const activateExplorerTabForCheckout = usePanelStore(
|
||||||
(state) => state.activateExplorerTabForCheckout
|
(state) => state.activateExplorerTabForCheckout
|
||||||
)
|
)
|
||||||
const tauriDragHandlers = useTauriDragHandlers()
|
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
|
||||||
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
||||||
const draftIdRef = useRef(generateDraftId())
|
const draftIdRef = useRef(generateDraftId())
|
||||||
const draftAgentIdRef = useRef(generateDraftId())
|
const draftAgentIdRef = useRef(generateDraftId())
|
||||||
@@ -974,7 +974,7 @@ function DraftAgentScreenContent({
|
|||||||
const explorerServerId = draftExplorerCheckout?.serverId ?? null
|
const explorerServerId = draftExplorerCheckout?.serverId ?? null
|
||||||
const explorerIsGit = draftExplorerCheckout?.isGit ?? false
|
const explorerIsGit = draftExplorerCheckout?.isGit ?? false
|
||||||
const mainContent = (
|
const mainContent = (
|
||||||
<View style={styles.container} {...tauriDragHandlers}>
|
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||||
<View style={styles.outerContainer}>
|
<View style={styles.outerContainer}>
|
||||||
<View style={styles.agentPanel}>
|
<View style={styles.agentPanel}>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { getIsTauriMac } from "@/constants/layout";
|
import { getIsDesktopMac } from "@/constants/layout";
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -19,9 +19,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||||
|
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
|
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
|
||||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||||
const dragHandlers = useTauriDragHandlers();
|
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
@@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
}, [isMobile, openAgentList]);
|
}, [isMobile, openAgentList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} {...dragHandlers}>
|
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||||
|
import { isDesktop as isDesktopHost } from "@/desktop/host";
|
||||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
@@ -513,7 +514,7 @@ export default function SettingsScreen() {
|
|||||||
const isLoading = settingsLoading;
|
const isLoading = settingsLoading;
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||||
const isDesktop = Platform.OS === "web";
|
const isDesktop = isDesktopHost();
|
||||||
const isLocalDaemon = useIsLocalDaemon(routeServerId);
|
const isLocalDaemon = useIsLocalDaemon(routeServerId);
|
||||||
const appVersion = resolveAppVersion();
|
const appVersion = resolveAppVersion();
|
||||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export function StartupSplashScreen() {
|
export function StartupSplashScreen() {
|
||||||
const dragHandlers = useTauriDragHandlers();
|
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} {...dragHandlers}>
|
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||||
<PaseoLogo size={96} />
|
<PaseoLogo size={96} />
|
||||||
<Text style={styles.status}>Starting up…</Text>
|
<Text style={styles.status}>Starting up…</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean
|
|||||||
return JSON.stringify(left) === JSON.stringify(right)
|
return JSON.stringify(left) === JSON.stringify(right)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupeHostConnections(connections: HostConnection[]): HostConnection[] {
|
||||||
|
const next: HostConnection[] = []
|
||||||
|
for (const connection of connections) {
|
||||||
|
if (next.some((existing) => hostConnectionEquals(existing, connection))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next.push(connection)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
export function upsertHostConnectionInProfiles(input: {
|
export function upsertHostConnectionInProfiles(input: {
|
||||||
profiles: HostProfile[]
|
profiles: HostProfile[]
|
||||||
serverId: string
|
serverId: string
|
||||||
@@ -96,9 +107,17 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
const labelTrimmed = input.label?.trim() ?? ''
|
const labelTrimmed = input.label?.trim() ?? ''
|
||||||
const derivedLabel = labelTrimmed || serverId
|
const derivedLabel = labelTrimmed || serverId
|
||||||
const existing = input.profiles
|
const existing = input.profiles
|
||||||
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
|
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
|
||||||
|
if (
|
||||||
|
daemon.serverId === serverId ||
|
||||||
|
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
|
||||||
|
) {
|
||||||
|
matches.push(index)
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (idx === -1) {
|
if (matchingIndexes.length === 0) {
|
||||||
const profile: HostProfile = {
|
const profile: HostProfile = {
|
||||||
serverId,
|
serverId,
|
||||||
label: derivedLabel,
|
label: derivedLabel,
|
||||||
@@ -111,31 +130,37 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
return [...existing, profile]
|
return [...existing, profile]
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = existing[idx]!
|
const matchedProfiles = matchingIndexes.map((index) => existing[index]!)
|
||||||
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
|
const prev =
|
||||||
const hadConnection = connectionIdx !== -1
|
matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]!
|
||||||
const connectionChanged =
|
const nextConnections = dedupeHostConnections([
|
||||||
connectionIdx === -1
|
...matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||||
? true
|
input.connection,
|
||||||
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
|
])
|
||||||
const nextConnections =
|
|
||||||
connectionIdx === -1
|
|
||||||
? [...prev.connections, input.connection]
|
|
||||||
: connectionChanged
|
|
||||||
? prev.connections.map((connection, index) =>
|
|
||||||
index === connectionIdx ? input.connection : connection
|
|
||||||
)
|
|
||||||
: prev.connections
|
|
||||||
|
|
||||||
const nextLifecycle = prev.lifecycle
|
const nextLifecycle = prev.lifecycle
|
||||||
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
|
const nextLabel =
|
||||||
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
|
labelTrimmed || (prev.label === prev.serverId ? derivedLabel : prev.label)
|
||||||
|
const nextPreferredConnectionId =
|
||||||
|
prev.preferredConnectionId &&
|
||||||
|
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
|
||||||
|
? prev.preferredConnectionId
|
||||||
|
: input.connection.id
|
||||||
|
const nextCreatedAt = matchedProfiles.reduce(
|
||||||
|
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
|
||||||
|
prev.createdAt
|
||||||
|
)
|
||||||
const changed =
|
const changed =
|
||||||
|
matchingIndexes.length > 1 ||
|
||||||
|
prev.serverId !== serverId ||
|
||||||
|
nextCreatedAt !== prev.createdAt ||
|
||||||
nextLabel !== prev.label ||
|
nextLabel !== prev.label ||
|
||||||
nextPreferredConnectionId !== prev.preferredConnectionId ||
|
nextPreferredConnectionId !== prev.preferredConnectionId ||
|
||||||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
|
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
|
||||||
!hadConnection ||
|
nextConnections.length !== prev.connections.length ||
|
||||||
connectionChanged
|
nextConnections.some((connection, index) => {
|
||||||
|
const previousConnection = prev.connections[index]
|
||||||
|
return !previousConnection || !hostConnectionEquals(connection, previousConnection)
|
||||||
|
})
|
||||||
|
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return existing
|
return existing
|
||||||
@@ -143,15 +168,19 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
|
|
||||||
const nextProfile: HostProfile = {
|
const nextProfile: HostProfile = {
|
||||||
...prev,
|
...prev,
|
||||||
|
serverId,
|
||||||
label: nextLabel,
|
label: nextLabel,
|
||||||
lifecycle: nextLifecycle,
|
lifecycle: nextLifecycle,
|
||||||
connections: nextConnections,
|
connections: nextConnections,
|
||||||
preferredConnectionId: nextPreferredConnectionId,
|
preferredConnectionId: nextPreferredConnectionId,
|
||||||
|
createdAt: nextCreatedAt,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = [...existing]
|
const firstIndex = matchingIndexes[0]!
|
||||||
next[idx] = nextProfile
|
const matchingIndexSet = new Set(matchingIndexes)
|
||||||
|
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index))
|
||||||
|
next.splice(firstIndex, 0, nextProfile)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const desktopHostState = {
|
||||||
|
api: null as {
|
||||||
|
dialog?: {
|
||||||
|
ask?: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
} | null,
|
||||||
|
};
|
||||||
|
|
||||||
type MockPlatform = "web" | "ios" | "android";
|
type MockPlatform = "web" | "ios" | "android";
|
||||||
|
|
||||||
type AlertButton = {
|
type AlertButton = {
|
||||||
@@ -19,13 +27,16 @@ async function loadModuleForPlatform(platform: MockPlatform): Promise<{
|
|||||||
},
|
},
|
||||||
Platform: { OS: platform },
|
Platform: { OS: platform },
|
||||||
}));
|
}));
|
||||||
|
vi.doMock("@/desktop/host", () => ({
|
||||||
|
getDesktopHost: () => desktopHostState.api,
|
||||||
|
}));
|
||||||
|
|
||||||
const module = await import("./confirm-dialog");
|
const module = await import("./confirm-dialog");
|
||||||
return { confirmDialog: module.confirmDialog, alertMock };
|
return { confirmDialog: module.confirmDialog, alertMock };
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDialogGlobals(): void {
|
function clearDialogGlobals(): void {
|
||||||
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
desktopHostState.api = null;
|
||||||
delete (globalThis as { confirm?: unknown }).confirm;
|
delete (globalThis as { confirm?: unknown }).confirm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +48,13 @@ describe("confirmDialog", () => {
|
|||||||
clearDialogGlobals();
|
clearDialogGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses Tauri dialog.ask on web when available", async () => {
|
it("uses the desktop dialog bridge on web when available", async () => {
|
||||||
const askMock = vi.fn(async () => true);
|
const askMock = vi.fn(async () => true);
|
||||||
const blurMock = vi.fn();
|
const blurMock = vi.fn();
|
||||||
(globalThis as { document?: unknown }).document = {
|
(globalThis as { document?: unknown }).document = {
|
||||||
activeElement: { blur: blurMock },
|
activeElement: { blur: blurMock },
|
||||||
} as unknown as Document;
|
} as unknown as Document;
|
||||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
desktopHostState.api = {
|
||||||
dialog: { ask: askMock },
|
dialog: { ask: askMock },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +78,7 @@ describe("confirmDialog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
|
it("falls back to browser confirm on web when desktop APIs are unavailable", async () => {
|
||||||
const browserConfirm = vi.fn(() => true);
|
const browserConfirm = vi.fn(() => true);
|
||||||
const blurMock = vi.fn();
|
const blurMock = vi.fn();
|
||||||
(globalThis as { document?: unknown }).document = {
|
(globalThis as { document?: unknown }).document = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alert, Platform } from "react-native";
|
import { Alert, Platform } from "react-native";
|
||||||
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
|
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
|
||||||
|
|
||||||
export interface ConfirmDialogInput {
|
export interface ConfirmDialogInput {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,14 +48,14 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTauriApi() {
|
function getDesktopApi() {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return getTauri();
|
return getDesktopHost();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOptions {
|
||||||
const labels = resolveButtonLabels(input);
|
const labels = resolveButtonLabels(input);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -74,35 +74,18 @@ function blurActiveWebElement(): void {
|
|||||||
(activeElement as HTMLElement | null)?.blur?.();
|
(activeElement as HTMLElement | null)?.blur?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||||
const tauriApi = getTauriApi();
|
const desktopApi = getDesktopApi();
|
||||||
if (!tauriApi) {
|
if (!desktopApi) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
blurActiveWebElement();
|
blurActiveWebElement();
|
||||||
const options = buildTauriAskOptions(input);
|
const options = buildDesktopAskOptions(input);
|
||||||
const tauriAsk = tauriApi.dialog?.ask;
|
const desktopAsk = desktopApi.dialog?.ask;
|
||||||
|
|
||||||
if (typeof tauriAsk === "function") {
|
if (typeof desktopAsk === "function") {
|
||||||
try {
|
return Boolean(await desktopAsk(input.message, options));
|
||||||
return Boolean(await tauriAsk(input.message, options));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tauriInvoke = tauriApi.core?.invoke;
|
|
||||||
if (typeof tauriInvoke === "function") {
|
|
||||||
try {
|
|
||||||
const result = await tauriInvoke("plugin:dialog|ask", {
|
|
||||||
message: input.message,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return result === true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -124,9 +107,9 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
|
|||||||
return showNativeConfirmDialog(input);
|
return showNativeConfirmDialog(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tauriResult = await showTauriConfirmDialog(input);
|
const desktopResult = await showDesktopConfirmDialog(input);
|
||||||
if (tauriResult !== null) {
|
if (desktopResult !== null) {
|
||||||
return tauriResult;
|
return desktopResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
return showWebConfirmDialog(input);
|
return showWebConfirmDialog(input);
|
||||||
@@ -134,5 +117,5 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
|
|||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
blurActiveWebElement,
|
blurActiveWebElement,
|
||||||
buildTauriAskOptions,
|
buildDesktopAskOptions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 * as Linking from "expo-linking";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
|
||||||
export async function openExternalUrl(url: string): Promise<void> {
|
export async function openExternalUrl(url: string): Promise<void> {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
const opener = getTauri()?.opener?.openUrl;
|
const opener = getDesktopHost()?.opener?.openUrl;
|
||||||
if (typeof opener === "function") {
|
if (typeof opener === "function") {
|
||||||
await opener(url);
|
await opener(url);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
type MockNotificationOptions = {
|
type MockNotificationOptions = {
|
||||||
body?: string;
|
body?: string;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
|
icon?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MockNotificationInstance = {
|
type MockNotificationInstance = {
|
||||||
@@ -18,7 +19,6 @@ type GlobalSnapshot = {
|
|||||||
dispatchEvent: unknown;
|
dispatchEvent: unknown;
|
||||||
focus: unknown;
|
focus: unknown;
|
||||||
location: unknown;
|
location: unknown;
|
||||||
__TAURI__: unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const originalGlobals: GlobalSnapshot = {
|
const originalGlobals: GlobalSnapshot = {
|
||||||
@@ -27,12 +27,34 @@ const originalGlobals: GlobalSnapshot = {
|
|||||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||||
focus: (globalThis as { focus?: unknown }).focus,
|
focus: (globalThis as { focus?: unknown }).focus,
|
||||||
location: (globalThis as { location?: unknown }).location,
|
location: (globalThis as { location?: unknown }).location,
|
||||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
async function loadModuleForPlatform(
|
||||||
|
platform: "web" | "ios" | "android",
|
||||||
|
options?: {
|
||||||
|
desktopHost?: {
|
||||||
|
notification?: {
|
||||||
|
sendNotification?: (payload: {
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
|
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
|
||||||
|
vi.doMock("@/desktop/host", () => ({
|
||||||
|
getDesktopHost: () => options?.desktopHost ?? null,
|
||||||
|
}));
|
||||||
|
vi.doMock("expo-asset", () => ({
|
||||||
|
Asset: {
|
||||||
|
fromModule: vi.fn(() => ({
|
||||||
|
uri: "http://localhost:8081/packages/app/assets/images/notification-icon.png",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
return import("./os-notifications");
|
return import("./os-notifications");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +64,6 @@ function restoreGlobals(): void {
|
|||||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("sendOsNotification", () => {
|
describe("sendOsNotification", () => {
|
||||||
@@ -71,7 +92,6 @@ describe("sendOsNotification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.doUnmock("@/desktop/notifications/desktop-notifications");
|
|
||||||
vi.doUnmock("react-native");
|
vi.doUnmock("react-native");
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -168,14 +188,8 @@ describe("sendOsNotification", () => {
|
|||||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the desktop notification bridge when Tauri is available", async () => {
|
it("returns false when the Notification API is unavailable", async () => {
|
||||||
const sendDesktopNotification = vi.fn(async () => true);
|
(globalThis as { Notification?: unknown }).Notification = undefined;
|
||||||
vi.doMock("@/desktop/notifications/desktop-notifications", () => ({
|
|
||||||
sendDesktopNotification,
|
|
||||||
}));
|
|
||||||
|
|
||||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {};
|
|
||||||
|
|
||||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||||
const sent = await sendOsNotification({
|
const sent = await sendOsNotification({
|
||||||
title: "Agent finished",
|
title: "Agent finished",
|
||||||
@@ -183,11 +197,61 @@ describe("sendOsNotification", () => {
|
|||||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(sent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not attach a click handler when there is no route target", async () => {
|
||||||
|
const created: MockNotificationInstance[] = [];
|
||||||
|
|
||||||
|
class MockNotification implements MockNotificationInstance {
|
||||||
|
static permission = "granted";
|
||||||
|
static requestPermission = vi.fn(async () => "granted");
|
||||||
|
onclick: ((event: Event) => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
|
||||||
|
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||||
|
created.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(globalThis as { Notification?: unknown }).Notification = MockNotification;
|
||||||
|
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = vi.fn();
|
||||||
|
(globalThis as { location?: unknown }).location = { assign: vi.fn() };
|
||||||
|
|
||||||
|
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||||
|
|
||||||
|
const sent = await sendOsNotification({
|
||||||
|
title: "Paseo notification test",
|
||||||
|
body: "If you can see this, desktop notifications work.",
|
||||||
|
});
|
||||||
|
|
||||||
expect(sent).toBe(true);
|
expect(sent).toBe(true);
|
||||||
expect(sendDesktopNotification).toHaveBeenCalledWith({
|
expect(created).toHaveLength(1);
|
||||||
title: "Agent finished",
|
expect(created[0]?.onclick).toBeNull();
|
||||||
body: "Done",
|
});
|
||||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
|
||||||
|
it("uses the desktop notification bridge when available", async () => {
|
||||||
|
const sendNotification = vi.fn(async () => true);
|
||||||
|
|
||||||
|
const { sendOsNotification } = await loadModuleForPlatform("web", {
|
||||||
|
desktopHost: {
|
||||||
|
notification: {
|
||||||
|
sendNotification,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = await sendOsNotification({
|
||||||
|
title: "Paseo notification test",
|
||||||
|
body: "If you can see this, desktop notifications work.",
|
||||||
|
data: { serverId: "srv-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sent).toBe(true);
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith({
|
||||||
|
title: "Paseo notification test",
|
||||||
|
body: "If you can see this, desktop notifications work.",
|
||||||
|
data: { serverId: "srv-1" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { Asset } from "expo-asset";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { sendDesktopNotification } from "@/desktop/notifications/desktop-notifications";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { buildNotificationRoute } from "./notification-routing";
|
import {
|
||||||
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
|
buildNotificationRoute,
|
||||||
|
resolveNotificationTarget,
|
||||||
|
} from "./notification-routing";
|
||||||
|
|
||||||
type OsNotificationPayload = {
|
type OsNotificationPayload = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,28 +18,41 @@ export type WebNotificationClickDetail = {
|
|||||||
|
|
||||||
type WebNotificationInstance = {
|
type WebNotificationInstance = {
|
||||||
onclick?: ((event: Event) => void) | null;
|
onclick?: ((event: Event) => void) | null;
|
||||||
onclose?: ((event: Event) => void) | null;
|
|
||||||
addEventListener?: (type: string, listener: (event: Event) => void) => void;
|
|
||||||
removeEventListener?: (type: string, listener: (event: Event) => void) => void;
|
|
||||||
close?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||||
|
|
||||||
let permissionRequest: Promise<boolean> | null = null;
|
let permissionRequest: Promise<boolean> | null = null;
|
||||||
const activeWebNotifications = new Set<WebNotificationInstance>();
|
let notificationIconUrl: string | null | undefined;
|
||||||
|
|
||||||
function getTauriNotificationModule(): TauriNotificationApi | null {
|
function getDesktopNotificationSender():
|
||||||
if (Platform.OS !== "web") {
|
| ((payload: {
|
||||||
return null;
|
title: string;
|
||||||
}
|
body?: string;
|
||||||
return getTauri()?.notification ?? null;
|
data?: Record<string, unknown>;
|
||||||
|
}) => Promise<boolean>)
|
||||||
|
| null {
|
||||||
|
const sendNotification = getDesktopHost()?.notification?.sendNotification;
|
||||||
|
return typeof sendNotification === "function"
|
||||||
|
? (sendNotification as (payload: {
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}) => Promise<boolean>)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWebNotificationConstructor(): {
|
function getWebNotificationConstructor(): {
|
||||||
permission: string;
|
permission: string;
|
||||||
requestPermission?: () => Promise<string>;
|
requestPermission?: () => Promise<string>;
|
||||||
new (title: string, options?: { body?: string; data?: Record<string, unknown> }): unknown;
|
new (
|
||||||
|
title: string,
|
||||||
|
options?: {
|
||||||
|
body?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
): unknown;
|
||||||
} | null {
|
} | null {
|
||||||
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
|
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
|
||||||
return NotificationConstructor ?? null;
|
return NotificationConstructor ?? null;
|
||||||
@@ -70,49 +86,31 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
|
|||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tauriNotification = getTauriNotificationModule();
|
|
||||||
if (tauriNotification) {
|
|
||||||
return await ensureTauriNotificationPermission(tauriNotification);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await ensureNotificationPermission();
|
return await ensureNotificationPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTauriNotificationPermission(
|
function hasNotificationClickTarget(
|
||||||
notificationModule: TauriNotificationApi
|
data: Record<string, unknown> | undefined
|
||||||
): Promise<boolean> {
|
): boolean {
|
||||||
if (typeof notificationModule.isPermissionGranted === "function") {
|
const target = resolveNotificationTarget(data);
|
||||||
try {
|
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
|
||||||
const granted = await notificationModule.isPermissionGranted();
|
}
|
||||||
if (granted) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[OSNotifications][Tauri] Failed to check notification permission",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof notificationModule.requestPermission !== "function") {
|
function getWebNotificationIconUrl(): string | undefined {
|
||||||
console.warn(
|
if (notificationIconUrl !== undefined) {
|
||||||
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
|
return notificationIconUrl ?? undefined;
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await notificationModule.requestPermission();
|
const asset = Asset.fromModule(
|
||||||
return result === "granted";
|
require("../../assets/images/notification-icon.png")
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[OSNotifications][Tauri] Failed to request notification permission",
|
|
||||||
error
|
|
||||||
);
|
);
|
||||||
return false;
|
notificationIconUrl = asset.uri ?? null;
|
||||||
|
} catch {
|
||||||
|
notificationIconUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return notificationIconUrl ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||||
@@ -156,41 +154,12 @@ function attachWebClickHandler(
|
|||||||
notification: WebNotificationInstance,
|
notification: WebNotificationInstance,
|
||||||
data: Record<string, unknown> | undefined
|
data: Record<string, unknown> | undefined
|
||||||
): void {
|
): void {
|
||||||
activeWebNotifications.add(notification);
|
notification.onclick = () => {
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
activeWebNotifications.delete(notification);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
const focus = (globalThis as { focus?: () => void }).focus;
|
|
||||||
if (typeof focus === "function") {
|
|
||||||
focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const handledByApp = dispatchWebNotificationClick({ data });
|
const handledByApp = dispatchWebNotificationClick({ data });
|
||||||
if (!handledByApp) {
|
if (!handledByApp) {
|
||||||
fallbackNavigateToNotificationTarget(data);
|
fallbackNavigateToNotificationTarget(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup();
|
|
||||||
if (typeof notification.close === "function") {
|
|
||||||
notification.close();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof notification.addEventListener === "function") {
|
|
||||||
notification.addEventListener("click", onClick);
|
|
||||||
notification.addEventListener("close", onClose);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.onclick = onClick;
|
|
||||||
notification.onclose = onClose;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendOsNotification(
|
export async function sendOsNotification(
|
||||||
@@ -201,8 +170,9 @@ export async function sendOsNotification(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTauri()) {
|
const desktopNotificationSender = getDesktopNotificationSender();
|
||||||
return await sendDesktopNotification(payload);
|
if (desktopNotificationSender) {
|
||||||
|
return await desktopNotificationSender(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationConstructor = getWebNotificationConstructor();
|
const NotificationConstructor = getWebNotificationConstructor();
|
||||||
@@ -212,8 +182,11 @@ export async function sendOsNotification(
|
|||||||
const notification = new NotificationConstructor(payload.title, {
|
const notification = new NotificationConstructor(payload.title, {
|
||||||
body: payload.body,
|
body: payload.body,
|
||||||
data: payload.data,
|
data: payload.data,
|
||||||
|
icon: getWebNotificationIconUrl(),
|
||||||
}) as WebNotificationInstance;
|
}) as WebNotificationInstance;
|
||||||
attachWebClickHandler(notification, payload.data);
|
if (hasNotificationClickTarget(payload.data)) {
|
||||||
|
attachWebClickHandler(notification, payload.data);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getIsTauriMac } from "@/constants/layout";
|
import { getIsDesktopMac } from "@/constants/layout";
|
||||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||||
|
|
||||||
export function getShortcutOs(): ShortcutOs {
|
export function getShortcutOs(): ShortcutOs {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||||
}
|
}
|
||||||
if (getIsTauriMac()) return "mac";
|
if (getIsDesktopMac()) return "mac";
|
||||||
if (typeof navigator === "undefined") return "non-mac";
|
if (typeof navigator === "undefined") return "non-mac";
|
||||||
const ua = navigator.userAgent ?? "";
|
const ua = navigator.userAgent ?? "";
|
||||||
const platform = (navigator as any).platform ?? "";
|
const platform = (navigator as any).platform ?? "";
|
||||||
@@ -15,4 +15,3 @@ export function getShortcutOs(): ShortcutOs {
|
|||||||
/Mac|iPhone|iPad|iPod/i.test(platform);
|
/Mac|iPhone|iPad|iPod/i.test(platform);
|
||||||
return isApple ? "mac" : "non-mac";
|
return isApple ? "mac" : "non-mac";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { getTauri } from "@/utils/tauri";
|
|
||||||
|
|
||||||
let attached = false;
|
|
||||||
|
|
||||||
export function attachConsole(): void {
|
|
||||||
if (attached || !getTauri()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
attached = true;
|
|
||||||
|
|
||||||
const originalLog = console.log;
|
|
||||||
const originalInfo = console.info;
|
|
||||||
const originalWarn = console.warn;
|
|
||||||
const originalError = console.error;
|
|
||||||
const originalDebug = console.debug;
|
|
||||||
|
|
||||||
function formatArgs(args: unknown[]): string {
|
|
||||||
return args
|
|
||||||
.map((a) =>
|
|
||||||
typeof a === "string" ? a : JSON.stringify(a, null, 0) ?? String(a)
|
|
||||||
)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function forwardToRust(level: number, args: unknown[]): void {
|
|
||||||
const invoke = getTauri()?.core?.invoke;
|
|
||||||
if (typeof invoke !== "function") return;
|
|
||||||
try {
|
|
||||||
void invoke("webview_log", { level, message: formatArgs(args) });
|
|
||||||
} catch {
|
|
||||||
// silently ignore IPC errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log = (...args: unknown[]) => {
|
|
||||||
originalLog.apply(console, args);
|
|
||||||
forwardToRust(1, args); // info
|
|
||||||
};
|
|
||||||
console.info = (...args: unknown[]) => {
|
|
||||||
originalInfo.apply(console, args);
|
|
||||||
forwardToRust(1, args); // info
|
|
||||||
};
|
|
||||||
console.warn = (...args: unknown[]) => {
|
|
||||||
originalWarn.apply(console, args);
|
|
||||||
forwardToRust(2, args); // warn
|
|
||||||
};
|
|
||||||
console.error = (...args: unknown[]) => {
|
|
||||||
originalError.apply(console, args);
|
|
||||||
forwardToRust(3, args); // error
|
|
||||||
};
|
|
||||||
console.debug = (...args: unknown[]) => {
|
|
||||||
originalDebug.apply(console, args);
|
|
||||||
forwardToRust(0, args); // debug
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||||
import {
|
import {
|
||||||
buildLocalDaemonTransportUrl,
|
buildLocalDaemonTransportUrl,
|
||||||
createTauriLocalDaemonTransportFactory,
|
createDesktopLocalDaemonTransportFactory,
|
||||||
} from "./managed-tauri-daemon-transport";
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||||
|
|
||||||
function normalizeNonEmptyString(value: unknown): string | null {
|
function normalizeNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
@@ -49,7 +49,7 @@ export async function buildClientConfig(
|
|||||||
serverId?: string
|
serverId?: string
|
||||||
): Promise<DaemonClientConfig> {
|
): Promise<DaemonClientConfig> {
|
||||||
const clientId = await getOrCreateClientId();
|
const clientId = await getOrCreateClientId();
|
||||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||||
const base = {
|
const base = {
|
||||||
clientId,
|
clientId,
|
||||||
clientType: "mobile" as const,
|
clientType: "mobile" as const,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getTauri } from "@/utils/tauri";
|
import { isDesktop } from "@/desktop/host";
|
||||||
import type {
|
import type {
|
||||||
AudioEngine,
|
AudioEngine,
|
||||||
AudioEngineCallbacks,
|
AudioEngineCallbacks,
|
||||||
@@ -300,12 +300,12 @@ export function createAudioEngine(
|
|||||||
typeof window !== "undefined" && window.location
|
typeof window !== "undefined" && window.location
|
||||||
? window.location.origin
|
? window.location.origin
|
||||||
: "unknown";
|
: "unknown";
|
||||||
const isTauri = getTauri() !== null;
|
const isDesktopApp = isDesktop();
|
||||||
|
|
||||||
if (missingNavigator) {
|
if (missingNavigator) {
|
||||||
throw new Error("Microphone capture is not supported in this environment");
|
throw new Error("Microphone capture is not supported in this environment");
|
||||||
}
|
}
|
||||||
if (!secureContext && !isTauri) {
|
if (!secureContext && !isDesktopApp) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -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 |
@@ -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
|
||||||
@@ -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",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.30",
|
"version": "0.1.30",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Tauri wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
|
"build": "npm --prefix ../.. run build:daemon && npm run build:main && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.yml",
|
||||||
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime && npm run validate:managed-runtime",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
"dev": "tauri dev",
|
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
|
||||||
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",
|
},
|
||||||
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
|
"dependencies": {
|
||||||
"tauri": "tauri"
|
"@getpaseo/cli": "0.1.30",
|
||||||
|
"@getpaseo/server": "0.1.30",
|
||||||
|
"electron-updater": "^6.6.2",
|
||||||
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.6"
|
"@types/node": "24.6.0",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
|
"electron": "41.0.3",
|
||||||
|
"electron-builder": "26.8.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"wait-on": "8.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,590 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import process from "node:process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
|
||||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
|
||||||
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
|
|
||||||
const cacheRoot = path.join(desktopRoot, ".cache", "managed-runtime");
|
|
||||||
const packageVersion = JSON.parse(
|
|
||||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
|
||||||
).version;
|
|
||||||
|
|
||||||
const workspaces = [
|
|
||||||
{ name: "@getpaseo/relay", root: path.join(repoRoot, "packages", "relay") },
|
|
||||||
{ name: "@getpaseo/server", root: path.join(repoRoot, "packages", "server") },
|
|
||||||
{ name: "@getpaseo/cli", root: path.join(repoRoot, "packages", "cli") },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ripgrepPlatformDirMap = {
|
|
||||||
darwin: {
|
|
||||||
arm64: "arm64-darwin",
|
|
||||||
x64: "x64-darwin",
|
|
||||||
},
|
|
||||||
linux: {
|
|
||||||
arm64: "arm64-linux",
|
|
||||||
x64: "x64-linux",
|
|
||||||
},
|
|
||||||
win32: {
|
|
||||||
arm64: "arm64-win32",
|
|
||||||
x64: "x64-win32",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
async function rmSafe(target) {
|
|
||||||
await fs.rm(target, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureDir(target) {
|
|
||||||
await fs.mkdir(target, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyDir(source, target) {
|
|
||||||
await ensureDir(path.dirname(target));
|
|
||||||
await fs.cp(source, target, { recursive: true, dereference: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyFile(source, target, mode) {
|
|
||||||
await ensureDir(path.dirname(target));
|
|
||||||
await fs.copyFile(source, target);
|
|
||||||
if (mode != null) {
|
|
||||||
await fs.chmod(target, mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathExists(target) {
|
|
||||||
try {
|
|
||||||
await fs.access(target);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeIfExists(target) {
|
|
||||||
if (await pathExists(target)) {
|
|
||||||
await rmSafe(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readToolVersions() {
|
|
||||||
const raw = await fs.readFile(path.join(repoRoot, ".tool-versions"), "utf8");
|
|
||||||
const entries = new Map();
|
|
||||||
for (const line of raw.split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const [tool, ...rest] = trimmed.split(/\s+/);
|
|
||||||
if (!tool || rest.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
entries.set(tool, rest.join(" "));
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveNodeVersion(toolVersions) {
|
|
||||||
const raw = toolVersions.get("nodejs");
|
|
||||||
if (!raw) {
|
|
||||||
throw new Error("Missing nodejs entry in .tool-versions.");
|
|
||||||
}
|
|
||||||
const version = raw.trim().replace(/^v/, "");
|
|
||||||
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
||||||
throw new Error(`Unsupported nodejs version in .tool-versions: ${raw}`);
|
|
||||||
}
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveNodeArtifact(version) {
|
|
||||||
const platformMap = {
|
|
||||||
darwin: "darwin",
|
|
||||||
linux: "linux",
|
|
||||||
win32: "win",
|
|
||||||
};
|
|
||||||
const archMap = {
|
|
||||||
arm64: "arm64",
|
|
||||||
x64: "x64",
|
|
||||||
};
|
|
||||||
const platform = platformMap[process.platform];
|
|
||||||
const arch = archMap[process.arch];
|
|
||||||
if (!platform || !arch) {
|
|
||||||
throw new Error(`Managed runtime is not implemented for ${process.platform}-${process.arch}.`);
|
|
||||||
}
|
|
||||||
const baseName = `node-v${version}-${platform}-${arch}`;
|
|
||||||
const extension = process.platform === "win32" ? "zip" : process.platform === "linux" ? "tar.xz" : "tar.gz";
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
baseName,
|
|
||||||
archiveName: `${baseName}.${extension}`,
|
|
||||||
extension,
|
|
||||||
downloadUrl: `https://nodejs.org/dist/v${version}/${baseName}.${extension}`,
|
|
||||||
checksumsUrl: `https://nodejs.org/dist/v${version}/SHASUMS256.txt`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadFile(url, target) {
|
|
||||||
await ensureDir(path.dirname(target));
|
|
||||||
runCommand("curl", [
|
|
||||||
"--fail",
|
|
||||||
"--location",
|
|
||||||
"--retry",
|
|
||||||
"3",
|
|
||||||
"--silent",
|
|
||||||
"--show-error",
|
|
||||||
"--output",
|
|
||||||
target,
|
|
||||||
url,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sha256File(target) {
|
|
||||||
const hash = createHash("sha256");
|
|
||||||
hash.update(await fs.readFile(target));
|
|
||||||
return hash.digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function computeRuntimeContentHash(input) {
|
|
||||||
const hash = createHash("sha256");
|
|
||||||
hash.update(`packageVersion:${packageVersion}\n`);
|
|
||||||
hash.update(`nodeVersion:${input.nodeVersion}\n`);
|
|
||||||
hash.update(`platform:${process.platform}\n`);
|
|
||||||
hash.update(`arch:${process.arch}\n`);
|
|
||||||
for (const tarball of input.tarballs) {
|
|
||||||
hash.update(`workspace:${tarball.name}\n`);
|
|
||||||
hash.update(`filename:${path.basename(tarball.path)}\n`);
|
|
||||||
hash.update(`sha256:${await sha256File(tarball.path)}\n`);
|
|
||||||
}
|
|
||||||
return hash.digest("hex").slice(0, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureNodeArchive(nodeArtifact) {
|
|
||||||
const versionCacheRoot = path.join(cacheRoot, `node-v${nodeArtifact.version}`);
|
|
||||||
const archivePath = path.join(versionCacheRoot, nodeArtifact.archiveName);
|
|
||||||
const checksumsPath = path.join(versionCacheRoot, "SHASUMS256.txt");
|
|
||||||
if (!(await pathExists(archivePath))) {
|
|
||||||
await downloadFile(nodeArtifact.downloadUrl, archivePath);
|
|
||||||
}
|
|
||||||
if (!(await pathExists(checksumsPath))) {
|
|
||||||
await downloadFile(nodeArtifact.checksumsUrl, checksumsPath);
|
|
||||||
}
|
|
||||||
const checksums = await fs.readFile(checksumsPath, "utf8");
|
|
||||||
const expectedLine = checksums
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find((line) => line.endsWith(` ${nodeArtifact.archiveName}`));
|
|
||||||
if (!expectedLine) {
|
|
||||||
throw new Error(`Missing checksum for ${nodeArtifact.archiveName} in SHASUMS256.txt`);
|
|
||||||
}
|
|
||||||
const expectedSha = expectedLine.split(/\s+/)[0];
|
|
||||||
const actualSha = await sha256File(archivePath);
|
|
||||||
if (actualSha !== expectedSha) {
|
|
||||||
throw new Error(
|
|
||||||
`Checksum mismatch for ${nodeArtifact.archiveName}: expected ${expectedSha}, got ${actualSha}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return archivePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommand(command, args, options = {}) {
|
|
||||||
const result = spawnSync(command, args, {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf8",
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
if (result.status !== 0 || result.error) {
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`Command failed: ${command} ${args.join(" ")}`,
|
|
||||||
result.error ? String(result.error) : null,
|
|
||||||
result.signal ? `signal: ${result.signal}` : null,
|
|
||||||
result.status != null ? `exit code: ${result.status}` : null,
|
|
||||||
result.stdout?.trim(),
|
|
||||||
result.stderr?.trim(),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withManagedRuntimeNodeOptions(env = process.env) {
|
|
||||||
const existing = env.NODE_OPTIONS?.trim() ?? "";
|
|
||||||
if (existing.includes("--max-old-space-size")) {
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
const nodeOptions = existing ? `${existing} --max-old-space-size=8192` : "--max-old-space-size=8192";
|
|
||||||
return {
|
|
||||||
...env,
|
|
||||||
NODE_OPTIONS: nodeOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function extractNodeDistribution(archivePath, nodeArtifact) {
|
|
||||||
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
|
|
||||||
if (nodeArtifact.extension === "zip") {
|
|
||||||
runCommand("powershell", [
|
|
||||||
"-NoProfile",
|
|
||||||
"-Command",
|
|
||||||
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractionRoot.replace(/'/g, "''")}' -Force`,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const tarFlag = nodeArtifact.extension === "tar.xz" ? "-xJf" : "-xzf";
|
|
||||||
runCommand("tar", [tarFlag, archivePath, "-C", extractionRoot]);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
extractionRoot,
|
|
||||||
extractedRoot: path.join(extractionRoot, nodeArtifact.baseName),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureWorkspaceBuilds() {
|
|
||||||
const requiredPaths = [
|
|
||||||
path.join(repoRoot, "packages", "relay", "dist"),
|
|
||||||
path.join(repoRoot, "packages", "server", "dist"),
|
|
||||||
path.join(repoRoot, "packages", "cli", "dist"),
|
|
||||||
];
|
|
||||||
for (const requiredPath of requiredPaths) {
|
|
||||||
if (!(await pathExists(requiredPath))) {
|
|
||||||
throw new Error(
|
|
||||||
`Managed runtime build is missing required path: ${requiredPath}. Run the daemon build first.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function packWorkspace(packageRoot, tarballRoot) {
|
|
||||||
await ensureDir(tarballRoot);
|
|
||||||
const npmExecPath = process.env.npm_execpath;
|
|
||||||
if (!npmExecPath) {
|
|
||||||
throw new Error("Missing npm_execpath while building managed runtime.");
|
|
||||||
}
|
|
||||||
const result = runCommand(process.execPath, [
|
|
||||||
npmExecPath,
|
|
||||||
"pack",
|
|
||||||
"--json",
|
|
||||||
"--pack-destination",
|
|
||||||
tarballRoot,
|
|
||||||
], {
|
|
||||||
cwd: packageRoot,
|
|
||||||
env: withManagedRuntimeNodeOptions(process.env),
|
|
||||||
});
|
|
||||||
const [{ filename }] = JSON.parse(result.stdout.trim());
|
|
||||||
if (!filename) {
|
|
||||||
throw new Error(`npm pack did not produce a filename for ${packageRoot}`);
|
|
||||||
}
|
|
||||||
return path.join(tarballRoot, filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installPackedWorkspaces(runtimeRoot, bundledNodeRoot, tarballs) {
|
|
||||||
const nodeExecutable = process.platform === "win32"
|
|
||||||
? path.join(bundledNodeRoot, "node.exe")
|
|
||||||
: path.join(bundledNodeRoot, "bin", "node");
|
|
||||||
const npmCli = process.platform === "win32"
|
|
||||||
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
|
|
||||||
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
||||||
const install = spawnSync(nodeExecutable, [
|
|
||||||
npmCli,
|
|
||||||
"install",
|
|
||||||
"--include=optional",
|
|
||||||
"--omit=dev",
|
|
||||||
"--no-package-lock",
|
|
||||||
"--no-save",
|
|
||||||
...tarballs,
|
|
||||||
], {
|
|
||||||
cwd: runtimeRoot,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
npm_config_audit: "false",
|
|
||||||
npm_config_fund: "false",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (install.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Managed runtime dependency install failed with exit code ${install.status ?? 1}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSherpaNativePackageName() {
|
|
||||||
const sherpaPlatformMap = {
|
|
||||||
darwin: "darwin",
|
|
||||||
linux: "linux",
|
|
||||||
win32: "win",
|
|
||||||
};
|
|
||||||
const sherpaPlatform = sherpaPlatformMap[process.platform];
|
|
||||||
if (!sherpaPlatform) {
|
|
||||||
throw new Error(`Managed runtime sherpa package is not implemented for ${process.platform}.`);
|
|
||||||
}
|
|
||||||
return `sherpa-onnx-${sherpaPlatform}-${process.arch}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSherpaNativePackage(runtimeRoot, bundledNodeRoot) {
|
|
||||||
const packageName = resolveSherpaNativePackageName();
|
|
||||||
const packageDir = path.join(runtimeRoot, "node_modules", packageName);
|
|
||||||
if (await pathExists(packageDir)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sherpaNodePackageJson = JSON.parse(
|
|
||||||
await fs.readFile(
|
|
||||||
path.join(runtimeRoot, "node_modules", "sherpa-onnx-node", "package.json"),
|
|
||||||
"utf8"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const packageVersion = sherpaNodePackageJson.version;
|
|
||||||
if (!packageVersion) {
|
|
||||||
throw new Error("Missing version in sherpa-onnx-node package.json.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeExecutable = process.platform === "win32"
|
|
||||||
? path.join(bundledNodeRoot, "node.exe")
|
|
||||||
: path.join(bundledNodeRoot, "bin", "node");
|
|
||||||
const npmCli = process.platform === "win32"
|
|
||||||
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
|
|
||||||
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
||||||
const install = spawnSync(nodeExecutable, [
|
|
||||||
npmCli,
|
|
||||||
"install",
|
|
||||||
"--include=optional",
|
|
||||||
"--omit=dev",
|
|
||||||
"--no-package-lock",
|
|
||||||
"--no-save",
|
|
||||||
`${packageName}@${packageVersion}`,
|
|
||||||
], {
|
|
||||||
cwd: runtimeRoot,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
npm_config_audit: "false",
|
|
||||||
npm_config_fund: "false",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (install.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Managed runtime sherpa package install failed with exit code ${install.status ?? 1}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeRuntimePackageJson(runtimeRoot) {
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(runtimeRoot, "package.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
name: "paseo-managed-runtime",
|
|
||||||
private: true,
|
|
||||||
version: packageVersion,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
) + "\n",
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneChildrenExcept(parent, keepNames) {
|
|
||||||
if (!(await pathExists(parent))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const entries = await fs.readdir(parent);
|
|
||||||
await Promise.all(
|
|
||||||
entries
|
|
||||||
.filter((entry) => !keepNames.has(entry))
|
|
||||||
.map((entry) => rmSafe(path.join(parent, entry)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneNodeDistribution(runtimeRoot) {
|
|
||||||
const nodeRoot = path.join(runtimeRoot, "node");
|
|
||||||
await Promise.all([
|
|
||||||
removeIfExists(path.join(nodeRoot, "include")),
|
|
||||||
removeIfExists(path.join(nodeRoot, "share")),
|
|
||||||
removeIfExists(path.join(nodeRoot, "CHANGELOG.md")),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const nodeGlobalModulesRoot = path.join(nodeRoot, "lib", "node_modules");
|
|
||||||
if (await pathExists(nodeGlobalModulesRoot)) {
|
|
||||||
const keepNames = new Set(["npm"]);
|
|
||||||
await pruneChildrenExcept(nodeGlobalModulesRoot, keepNames);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneOnnxRuntime(runtimeRoot) {
|
|
||||||
const onnxRoot = path.join(runtimeRoot, "node_modules", "onnxruntime-node", "bin", "napi-v6");
|
|
||||||
if (!(await pathExists(onnxRoot))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
await removeIfExists(path.join(onnxRoot, "linux"));
|
|
||||||
await removeIfExists(path.join(onnxRoot, "win32"));
|
|
||||||
await pruneChildrenExcept(path.join(onnxRoot, "darwin"), new Set([process.arch]));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (process.platform === "linux") {
|
|
||||||
await removeIfExists(path.join(onnxRoot, "darwin"));
|
|
||||||
await removeIfExists(path.join(onnxRoot, "win32"));
|
|
||||||
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
|
|
||||||
const archDir = path.join(onnxRoot, "linux", process.arch);
|
|
||||||
if (await pathExists(archDir)) {
|
|
||||||
const entries = await fs.readdir(archDir);
|
|
||||||
await Promise.all(
|
|
||||||
entries
|
|
||||||
.filter((name) => name.includes("cuda") || name.includes("tensorrt"))
|
|
||||||
.map((name) => fs.rm(path.join(archDir, name), { force: true }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
await removeIfExists(path.join(onnxRoot, "darwin"));
|
|
||||||
await removeIfExists(path.join(onnxRoot, "linux"));
|
|
||||||
await pruneChildrenExcept(path.join(onnxRoot, "win32"), new Set([process.arch]));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneNodePty(runtimeRoot) {
|
|
||||||
const prebuildsRoot = path.join(runtimeRoot, "node_modules", "node-pty", "prebuilds");
|
|
||||||
const keepName = `${process.platform}-${process.arch}`;
|
|
||||||
await pruneChildrenExcept(prebuildsRoot, new Set([keepName]));
|
|
||||||
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
await removeIfExists(path.join(runtimeRoot, "node_modules", "node-pty", "third_party"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneClaudeAgentSdk(runtimeRoot) {
|
|
||||||
const vendorRoot = path.join(
|
|
||||||
runtimeRoot,
|
|
||||||
"node_modules",
|
|
||||||
"@anthropic-ai",
|
|
||||||
"claude-agent-sdk",
|
|
||||||
"vendor"
|
|
||||||
);
|
|
||||||
const ripgrepRoot = path.join(vendorRoot, "ripgrep");
|
|
||||||
const keepName = ripgrepPlatformDirMap[process.platform]?.[process.arch];
|
|
||||||
if (keepName) {
|
|
||||||
await pruneChildrenExcept(ripgrepRoot, new Set(["COPYING", keepName]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const treeSitterBashRoot = path.join(vendorRoot, "tree-sitter-bash");
|
|
||||||
if (keepName) {
|
|
||||||
await pruneChildrenExcept(treeSitterBashRoot, new Set([keepName]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneManagedRuntime(runtimeRoot) {
|
|
||||||
await Promise.all([
|
|
||||||
pruneNodeDistribution(runtimeRoot),
|
|
||||||
pruneOnnxRuntime(runtimeRoot),
|
|
||||||
pruneNodePty(runtimeRoot),
|
|
||||||
pruneClaudeAgentSdk(runtimeRoot),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await ensureWorkspaceBuilds();
|
|
||||||
|
|
||||||
const toolVersions = await readToolVersions();
|
|
||||||
const nodeVersion = resolveNodeVersion(toolVersions);
|
|
||||||
const nodeArtifact = resolveNodeArtifact(nodeVersion);
|
|
||||||
|
|
||||||
await rmSafe(resourcesRoot);
|
|
||||||
await ensureDir(resourcesRoot);
|
|
||||||
|
|
||||||
const archivePath = await ensureNodeArchive(nodeArtifact);
|
|
||||||
const { extractionRoot, extractedRoot } = await extractNodeDistribution(archivePath, nodeArtifact);
|
|
||||||
const tarballRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-pack-"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tarballs = [];
|
|
||||||
for (const workspace of workspaces) {
|
|
||||||
tarballs.push({
|
|
||||||
name: workspace.name,
|
|
||||||
path: await packWorkspace(workspace.root, tarballRoot),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const runtimeContentHash = await computeRuntimeContentHash({
|
|
||||||
nodeVersion,
|
|
||||||
tarballs,
|
|
||||||
});
|
|
||||||
const runtimeId =
|
|
||||||
`${packageVersion}-node-${nodeVersion}-${process.platform}-${process.arch}-${runtimeContentHash}`;
|
|
||||||
const runtimeRoot = path.join(resourcesRoot, runtimeId);
|
|
||||||
|
|
||||||
await ensureDir(runtimeRoot);
|
|
||||||
await copyDir(extractedRoot, path.join(runtimeRoot, "node"));
|
|
||||||
await writeRuntimePackageJson(runtimeRoot);
|
|
||||||
await installPackedWorkspaces(
|
|
||||||
runtimeRoot,
|
|
||||||
path.join(runtimeRoot, "node"),
|
|
||||||
tarballs.map((entry) => entry.path)
|
|
||||||
);
|
|
||||||
await ensureSherpaNativePackage(runtimeRoot, path.join(runtimeRoot, "node"));
|
|
||||||
await pruneManagedRuntime(runtimeRoot);
|
|
||||||
|
|
||||||
const nodeRelativePath = process.platform === "win32"
|
|
||||||
? path.join("node", "node.exe")
|
|
||||||
: path.join("node", "bin", "node");
|
|
||||||
const manifest = {
|
|
||||||
runtimeId,
|
|
||||||
runtimeVersion: packageVersion,
|
|
||||||
platform: process.platform,
|
|
||||||
arch: process.arch,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
nodeRelativePath,
|
|
||||||
cliEntrypointRelativePath: path.join(
|
|
||||||
"node_modules",
|
|
||||||
"@getpaseo",
|
|
||||||
"cli",
|
|
||||||
"dist",
|
|
||||||
"index.js"
|
|
||||||
),
|
|
||||||
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
|
|
||||||
serverRunnerRelativePath: path.join(
|
|
||||||
"node_modules",
|
|
||||||
"@getpaseo",
|
|
||||||
"server",
|
|
||||||
"dist",
|
|
||||||
"scripts",
|
|
||||||
"daemon-runner.js"
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(runtimeRoot, "runtime-manifest.json"),
|
|
||||||
JSON.stringify(manifest, null, 2) + "\n",
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(resourcesRoot, "current-runtime.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
runtimeId,
|
|
||||||
runtimeVersion: packageVersion,
|
|
||||||
relativeRoot: runtimeId,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
) + "\n",
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
await fs.chmod(path.join(runtimeRoot, nodeRelativePath), 0o755);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await rmSafe(extractionRoot);
|
|
||||||
await rmSafe(tarballRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await main();
|
|
||||||
@@ -1,807 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { Buffer } from "node:buffer";
|
|
||||||
import { execFile, spawn } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import net from "node:net";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import WebSocket from "ws";
|
|
||||||
import { createClientChannel } from "@getpaseo/relay/e2ee";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const COMMAND_TIMEOUT_MS = 120_000;
|
|
||||||
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
|
|
||||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
|
||||||
const relayRoot = path.join(repoRoot, "packages", "relay");
|
|
||||||
const desktopPackageJson = JSON.parse(
|
|
||||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
|
||||||
);
|
|
||||||
const currentRuntimeVersion = desktopPackageJson.version;
|
|
||||||
const currentRuntimePointer = JSON.parse(
|
|
||||||
await fs.readFile(
|
|
||||||
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
|
|
||||||
"utf8"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const currentRuntimeId = currentRuntimePointer.runtimeId;
|
|
||||||
|
|
||||||
function resolvePackagedBinary() {
|
|
||||||
const targetRoot = process.env.PASEO_MANAGED_SMOKE_RUST_TARGET
|
|
||||||
? path.join(
|
|
||||||
desktopRoot,
|
|
||||||
"src-tauri",
|
|
||||||
"target",
|
|
||||||
process.env.PASEO_MANAGED_SMOKE_RUST_TARGET,
|
|
||||||
"release"
|
|
||||||
)
|
|
||||||
: path.join(desktopRoot, "src-tauri", "target", "release");
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
return path.join(
|
|
||||||
targetRoot,
|
|
||||||
"bundle",
|
|
||||||
"macos",
|
|
||||||
"Paseo.app",
|
|
||||||
"Contents",
|
|
||||||
"MacOS",
|
|
||||||
"Paseo"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (process.platform === "linux") {
|
|
||||||
return path.join(
|
|
||||||
targetRoot,
|
|
||||||
"bundle",
|
|
||||||
"appimage",
|
|
||||||
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return path.join(
|
|
||||||
targetRoot,
|
|
||||||
"Paseo.exe"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathExists(target) {
|
|
||||||
try {
|
|
||||||
await fs.access(target);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshotTree(root) {
|
|
||||||
if (!(await pathExists(root))) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const entries = [];
|
|
||||||
async function walk(current) {
|
|
||||||
const stat = await fs.stat(current);
|
|
||||||
const relative = path.relative(root, current) || ".";
|
|
||||||
entries.push({
|
|
||||||
relative,
|
|
||||||
kind: stat.isDirectory() ? "dir" : "file",
|
|
||||||
size: stat.size,
|
|
||||||
mtimeMs: stat.mtimeMs,
|
|
||||||
});
|
|
||||||
if (!stat.isDirectory()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const children = await fs.readdir(current);
|
|
||||||
children.sort();
|
|
||||||
for (const child of children) {
|
|
||||||
await walk(path.join(current, child));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await walk(root);
|
|
||||||
entries.sort((left, right) => left.relative.localeCompare(right.relative));
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sleep(ms) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeForRegExp(value) {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForChildExit(child, timeoutMs) {
|
|
||||||
if (!child || child.exitCode !== null || child.killed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
const timeout = setTimeout(resolve, timeoutMs);
|
|
||||||
child.once("exit", () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function execFileWithTimeout(command, args, options, label) {
|
|
||||||
try {
|
|
||||||
return await execFileAsync(command, args, {
|
|
||||||
timeout: COMMAND_TIMEOUT_MS,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.killed && error?.signal === "SIGTERM") {
|
|
||||||
const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
|
|
||||||
throw new Error(
|
|
||||||
`Timed out after ${COMMAND_TIMEOUT_MS}ms running ${label}: ${JSON.stringify(command)} ${renderedArgs}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBinary(binaryPath, args, env) {
|
|
||||||
const { stdout, stderr } = await execFileWithTimeout(binaryPath, args, {
|
|
||||||
env,
|
|
||||||
cwd: repoRoot,
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
}, "packaged desktop binary");
|
|
||||||
const trimmed = stdout.trim();
|
|
||||||
return {
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
json: trimmed ? JSON.parse(trimmed) : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminateChildProcess(child, label) {
|
|
||||||
if (!child || child.exitCode !== null || child.killed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (process.platform === "win32" && typeof child.pid === "number") {
|
|
||||||
try {
|
|
||||||
await execFileWithTimeout(
|
|
||||||
"taskkill",
|
|
||||||
["/pid", String(child.pid), "/T", "/F"],
|
|
||||||
{ maxBuffer: 1024 * 1024 },
|
|
||||||
`${label} taskkill`
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
await waitForChildExit(child, 5_000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
} catch {}
|
|
||||||
await waitForChildExit(child, 5_000);
|
|
||||||
if (child.exitCode === null && !child.killed) {
|
|
||||||
try {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
} catch {}
|
|
||||||
await waitForChildExit(child, 5_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runWorkspaceCli(args, env) {
|
|
||||||
const { stdout, stderr } = await execFileWithTimeout(
|
|
||||||
process.execPath,
|
|
||||||
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
|
|
||||||
{
|
|
||||||
cwd: repoRoot,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...env,
|
|
||||||
},
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
"workspace CLI"
|
|
||||||
);
|
|
||||||
let json = null;
|
|
||||||
if (stdout.trim()) {
|
|
||||||
try {
|
|
||||||
json = JSON.parse(stdout.trim());
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { stdout, stderr, json };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
|
|
||||||
const manifest = JSON.parse(
|
|
||||||
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
|
|
||||||
);
|
|
||||||
const { stdout, stderr } = await execFileWithTimeout(
|
|
||||||
path.join(runtimeRoot, manifest.nodeRelativePath),
|
|
||||||
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
|
|
||||||
{
|
|
||||||
cwd: repoRoot,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...env,
|
|
||||||
PASEO_HOME: managedHome,
|
|
||||||
},
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
"bundled runtime CLI"
|
|
||||||
);
|
|
||||||
return { stdout, stderr };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolvePackagedRuntimeStatus(binaryPath) {
|
|
||||||
const binaryDir = path.dirname(binaryPath);
|
|
||||||
const resourceDirs = [
|
|
||||||
path.join(binaryDir, "resources"),
|
|
||||||
binaryDir,
|
|
||||||
path.join(binaryDir, "..", "Resources"),
|
|
||||||
path.join(binaryDir, "..", "resources"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let bundledRoot = null;
|
|
||||||
for (const resourceDir of resourceDirs) {
|
|
||||||
for (const candidate of [
|
|
||||||
path.join(resourceDir, "resources", "managed-runtime"),
|
|
||||||
path.join(resourceDir, "managed-runtime"),
|
|
||||||
]) {
|
|
||||||
if (await pathExists(candidate)) {
|
|
||||||
bundledRoot = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bundledRoot) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bundledRoot) {
|
|
||||||
throw new Error("Managed runtime resources are not bundled with this desktop build.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const pointer = JSON.parse(
|
|
||||||
await fs.readFile(path.join(bundledRoot, "current-runtime.json"), "utf8")
|
|
||||||
);
|
|
||||||
const runtimeRoot = path.join(bundledRoot, pointer.relativeRoot);
|
|
||||||
const manifest = JSON.parse(
|
|
||||||
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
runtimeId: manifest.runtimeId,
|
|
||||||
runtimeVersion: manifest.runtimeVersion,
|
|
||||||
runtimeRoot,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readDaemonStatus(home, env) {
|
|
||||||
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
|
|
||||||
return result.json ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pidListeningOnPort(port) {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
});
|
|
||||||
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
|
|
||||||
return Number.isFinite(pid) ? pid : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAvailablePort() {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.unref();
|
|
||||||
server.on("error", reject);
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { port } = address;
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
|
|
||||||
const url = new URL(`ws://${endpoint}/ws`);
|
|
||||||
url.searchParams.set("serverId", serverId);
|
|
||||||
url.searchParams.set("role", role);
|
|
||||||
url.searchParams.set("v", "2");
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOfferUrlFromCommandOutput(stdout) {
|
|
||||||
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
|
|
||||||
}
|
|
||||||
return match[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeOfferFromFragmentUrl(url) {
|
|
||||||
const marker = "#offer=";
|
|
||||||
const index = url.indexOf(marker);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
|
|
||||||
}
|
|
||||||
const encoded = url.slice(index + marker.length);
|
|
||||||
const raw = Buffer.from(encoded, "base64url").toString("utf8");
|
|
||||||
const offer = JSON.parse(raw);
|
|
||||||
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
|
|
||||||
throw new Error(`Unexpected relay offer payload: ${raw}`);
|
|
||||||
}
|
|
||||||
return offer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
|
|
||||||
await waitFor(async () => {
|
|
||||||
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
const url = buildRelayWebSocketUrl({
|
|
||||||
endpoint,
|
|
||||||
serverId: probeServerId,
|
|
||||||
role: "server",
|
|
||||||
});
|
|
||||||
const opened = await new Promise((resolve) => {
|
|
||||||
const ws = new WebSocket(url);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
ws.terminate();
|
|
||||||
resolve(false);
|
|
||||||
}, 5_000);
|
|
||||||
ws.once("open", () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
ws.close(1000, "probe");
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
ws.once("error", () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
assert.equal(opened, true);
|
|
||||||
}, timeoutMs, "relay websocket endpoint to accept connections");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectViaRelay(endpoint, offer) {
|
|
||||||
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
|
|
||||||
const ws = new WebSocket(
|
|
||||||
buildRelayWebSocketUrl({
|
|
||||||
endpoint,
|
|
||||||
serverId: offer.serverId,
|
|
||||||
role: "client",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
ws.close();
|
|
||||||
reject(new Error("Timed out waiting for managed relay pong."));
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
const transport = {
|
|
||||||
send: (data) => ws.send(data),
|
|
||||||
close: (code, reason) => ws.close(code, reason),
|
|
||||||
onmessage: null,
|
|
||||||
onclose: null,
|
|
||||||
onerror: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.on("message", (data) => {
|
|
||||||
transport.onmessage?.(typeof data === "string" ? data : data.toString());
|
|
||||||
});
|
|
||||||
ws.on("close", (code, reason) => {
|
|
||||||
transport.onclose?.(code, reason.toString());
|
|
||||||
});
|
|
||||||
ws.on("error", (error) => {
|
|
||||||
transport.onerror?.(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("open", async () => {
|
|
||||||
try {
|
|
||||||
let pingSent = false;
|
|
||||||
let channelRef = null;
|
|
||||||
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
|
|
||||||
onmessage: (data) => {
|
|
||||||
try {
|
|
||||||
const payload = typeof data === "string" ? JSON.parse(data) : data;
|
|
||||||
if (payload?.type === "welcome") {
|
|
||||||
if (!pingSent && channelRef) {
|
|
||||||
pingSent = true;
|
|
||||||
void channelRef.send(JSON.stringify({ type: "ping" }));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payload?.type === "pong") {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(payload);
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onerror: (error) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
channelRef = channel;
|
|
||||||
await channel.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
clientId: stableClientId,
|
|
||||||
clientType: "cli",
|
|
||||||
protocolVersion: 1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNoForbiddenPathsOrPorts(value, forbidden) {
|
|
||||||
const text =
|
|
||||||
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
||||||
for (const candidate of forbidden) {
|
|
||||||
assert.ok(
|
|
||||||
!text.includes(candidate),
|
|
||||||
`Unexpected forbidden managed smoke reference: ${candidate}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensurePackagedArtifact(binaryPath) {
|
|
||||||
if (process.env.PASEO_MANAGED_SMOKE_SKIP_BUILD === "1") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const npmExecPath = process.env.npm_execpath;
|
|
||||||
if (!npmExecPath) {
|
|
||||||
throw new Error("npm_execpath is required to build the packaged desktop artifact during smoke tests.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await execFileWithTimeout(process.execPath, [npmExecPath, "run", "build"], {
|
|
||||||
cwd: desktopRoot,
|
|
||||||
env: process.env,
|
|
||||||
maxBuffer: 20 * 1024 * 1024,
|
|
||||||
}, "desktop smoke artifact build");
|
|
||||||
} catch (error) {
|
|
||||||
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
|
|
||||||
const signingBlocked =
|
|
||||||
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
|
|
||||||
(await pathExists(binaryPath));
|
|
||||||
if (!signingBlocked) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitFor(assertion, timeoutMs, label) {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
|
||||||
try {
|
|
||||||
return await assertion();
|
|
||||||
} catch {
|
|
||||||
await sleep(250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for ${label}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function logStep(label) {
|
|
||||||
currentStepLabel = label;
|
|
||||||
currentStepStartedAt = Date.now();
|
|
||||||
console.log(`\n[managed-smoke] ${label}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const packagedBinary = resolvePackagedBinary();
|
|
||||||
await ensurePackagedArtifact(packagedBinary);
|
|
||||||
if (!(await pathExists(packagedBinary))) {
|
|
||||||
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
|
|
||||||
const testRoot = path.join(tmpRoot, "managed-test-root");
|
|
||||||
const fakeHome = path.join(tmpRoot, "fake-home");
|
|
||||||
const fakePaseoHome = path.join(fakeHome, ".paseo");
|
|
||||||
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
|
|
||||||
const externalHome = path.join(tmpRoot, "external-daemon-home");
|
|
||||||
const externalPort = await getAvailablePort();
|
|
||||||
const externalEndpoint = `127.0.0.1:${externalPort}`;
|
|
||||||
const relayPort = await getAvailablePort();
|
|
||||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
|
||||||
const managedRuntimeDir = path.join(testRoot, "runtime");
|
|
||||||
await fs.mkdir(testRoot, { recursive: true });
|
|
||||||
await fs.mkdir(fakePaseoHome, { recursive: true });
|
|
||||||
await fs.mkdir(cliScratchHome, { recursive: true });
|
|
||||||
await fs.mkdir(externalHome, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
|
|
||||||
|
|
||||||
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
|
|
||||||
const smokeStartedAt = Date.now();
|
|
||||||
const smokeDeadlineMs = 15 * 60 * 1000;
|
|
||||||
let currentStepLabel = "initializing";
|
|
||||||
let currentStepStartedAt = smokeStartedAt;
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
const elapsedSeconds = Math.floor((Date.now() - smokeStartedAt) / 1000);
|
|
||||||
const currentStepSeconds = Math.floor((Date.now() - currentStepStartedAt) / 1000);
|
|
||||||
console.log(
|
|
||||||
`[managed-smoke] heartbeat elapsed=${elapsedSeconds}s currentStep=${JSON.stringify(currentStepLabel)} stepElapsed=${currentStepSeconds}s`
|
|
||||||
);
|
|
||||||
if (Date.now() - smokeStartedAt > smokeDeadlineMs) {
|
|
||||||
console.error(
|
|
||||||
`[managed-smoke] FAIL exceeded ${smokeDeadlineMs / 1000}s overall deadline during ${JSON.stringify(currentStepLabel)}`
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}, 30_000);
|
|
||||||
const managedEnv = {
|
|
||||||
...process.env,
|
|
||||||
HOME: fakeHome,
|
|
||||||
PASEO_HOME: cliScratchHome,
|
|
||||||
PASEO_DESKTOP_TEST_ROOT: testRoot,
|
|
||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
|
|
||||||
PASEO_DICTATION_ENABLED: "0",
|
|
||||||
PASEO_VOICE_MODE_ENABLED: "0",
|
|
||||||
PASEO_RELAY_ENABLED: "true",
|
|
||||||
PASEO_RELAY_ENDPOINT: relayEndpoint,
|
|
||||||
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
|
|
||||||
PASEO_APP_BASE_URL: "https://app.paseo.test",
|
|
||||||
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
|
|
||||||
CI: "true",
|
|
||||||
};
|
|
||||||
let externalPid = null;
|
|
||||||
let startedTemporaryExternalDaemon = false;
|
|
||||||
let relayProcess = null;
|
|
||||||
let runtimeStatus = null;
|
|
||||||
let managedStart = null;
|
|
||||||
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
|
|
||||||
const npmExecPath = process.env.npm_execpath;
|
|
||||||
if (!npmExecPath) {
|
|
||||||
throw new Error("npm_execpath is required to launch wrangler during managed desktop smoke tests.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logStep(`Starting isolated local relay on ${relayEndpoint}`);
|
|
||||||
relayProcess = spawn(process.execPath, [
|
|
||||||
npmExecPath,
|
|
||||||
"exec",
|
|
||||||
"--",
|
|
||||||
"wrangler",
|
|
||||||
"dev",
|
|
||||||
"--local",
|
|
||||||
"--ip",
|
|
||||||
"127.0.0.1",
|
|
||||||
"--port",
|
|
||||||
String(relayPort),
|
|
||||||
"--live-reload=false",
|
|
||||||
"--show-interactive-dev-session=false",
|
|
||||||
], {
|
|
||||||
cwd: relayRoot,
|
|
||||||
env: process.env,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
relayProcess.stdout?.on("data", (chunk) => {
|
|
||||||
process.stdout.write(`[managed-smoke relay] ${chunk}`);
|
|
||||||
});
|
|
||||||
relayProcess.stderr?.on("data", (chunk) => {
|
|
||||||
process.stderr.write(`[managed-smoke relay] ${chunk}`);
|
|
||||||
});
|
|
||||||
await waitFor(async () => {
|
|
||||||
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
|
|
||||||
}, 60_000, "relay HTTP endpoint to start");
|
|
||||||
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
|
|
||||||
|
|
||||||
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
|
|
||||||
startedTemporaryExternalDaemon = true;
|
|
||||||
await runWorkspaceCli(
|
|
||||||
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
|
|
||||||
managedEnv
|
|
||||||
);
|
|
||||||
const externalStatus = await waitFor(
|
|
||||||
async () => {
|
|
||||||
const status = await readDaemonStatus(externalHome, managedEnv);
|
|
||||||
assert.equal(status.status, "running");
|
|
||||||
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
|
|
||||||
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
|
|
||||||
return status;
|
|
||||||
},
|
|
||||||
15_000,
|
|
||||||
"external daemon to start"
|
|
||||||
);
|
|
||||||
externalPid = externalStatus.pid;
|
|
||||||
assert.ok(externalPid, "external daemon pid should be present");
|
|
||||||
|
|
||||||
logStep("Resolving bundled runtime from packaged desktop artifact");
|
|
||||||
runtimeStatus = await resolvePackagedRuntimeStatus(packagedBinary);
|
|
||||||
assert.equal(runtimeStatus.runtimeId, currentRuntimeId);
|
|
||||||
assert.equal(runtimeStatus.runtimeVersion, currentRuntimeVersion);
|
|
||||||
assert.match(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
new RegExp(escapeForRegExp(currentRuntimeId)),
|
|
||||||
"runtime status should point into the bundled runtime selected by current-runtime.json"
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
runtimeStatus.runtimeRoot.startsWith(testRoot),
|
|
||||||
false,
|
|
||||||
"runtime status should not resolve into managed app data"
|
|
||||||
);
|
|
||||||
assertNoForbiddenPathsOrPorts(runtimeStatus, forbiddenManagedReferences);
|
|
||||||
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
|
|
||||||
|
|
||||||
const managedBootstrap = await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
cliScratchHome,
|
|
||||||
["start", "--json"],
|
|
||||||
managedEnv
|
|
||||||
);
|
|
||||||
managedStart = (managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null) ?? await waitFor(
|
|
||||||
async () => {
|
|
||||||
const status = await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
cliScratchHome,
|
|
||||||
["daemon", "status", "--json"],
|
|
||||||
managedEnv
|
|
||||||
);
|
|
||||||
const json = JSON.parse(status.stdout.trim());
|
|
||||||
assert.equal(json.status, "running");
|
|
||||||
assert.ok(json.pid, "managed daemon pid should exist");
|
|
||||||
assert.ok(json.serverId, "managed daemon should expose a server id");
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
10_000,
|
|
||||||
"managed daemon bootstrap status"
|
|
||||||
);
|
|
||||||
assert.equal(managedStart.status, "running");
|
|
||||||
assert.ok(managedStart.pid, "managed daemon pid should exist");
|
|
||||||
assert.ok(managedStart.serverId, "managed daemon should expose a server id");
|
|
||||||
assert.ok(managedStart.home, "managed daemon should expose its home directory");
|
|
||||||
assert.ok(managedStart.listen, "managed daemon should expose its listen target");
|
|
||||||
assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences);
|
|
||||||
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
|
|
||||||
|
|
||||||
const managedPid = managedStart.pid;
|
|
||||||
|
|
||||||
logStep("Verifying the managed daemon stays alive after the packaged command exits");
|
|
||||||
await sleep(1_500);
|
|
||||||
const persistedManagedStatus = JSON.parse(
|
|
||||||
(
|
|
||||||
await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
managedStart.home,
|
|
||||||
["daemon", "status", "--json"],
|
|
||||||
managedEnv
|
|
||||||
)
|
|
||||||
).stdout.trim()
|
|
||||||
);
|
|
||||||
assert.equal(persistedManagedStatus.pid, managedPid);
|
|
||||||
assert.equal(persistedManagedStatus.status, "running");
|
|
||||||
assert.equal(persistedManagedStatus.serverId, managedStart.serverId);
|
|
||||||
assertNoForbiddenPathsOrPorts(persistedManagedStatus, forbiddenManagedReferences);
|
|
||||||
|
|
||||||
logStep("Verifying bundled CLI access without installing a shim");
|
|
||||||
const cliVersion = await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
managedStart.home,
|
|
||||||
["--version"],
|
|
||||||
managedEnv
|
|
||||||
);
|
|
||||||
assert.match(cliVersion.stdout.trim(), /^0\./);
|
|
||||||
const bundledCliStatus = await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
managedStart.home,
|
|
||||||
["daemon", "status", "--json"],
|
|
||||||
managedEnv
|
|
||||||
);
|
|
||||||
const bundledCliDaemonStatus = JSON.parse(bundledCliStatus.stdout.trim());
|
|
||||||
assert.equal(bundledCliDaemonStatus.pid, managedPid);
|
|
||||||
assertNoForbiddenPathsOrPorts(bundledCliDaemonStatus, forbiddenManagedReferences);
|
|
||||||
|
|
||||||
logStep("Verifying relay connectivity still works after the desktop command has exited");
|
|
||||||
const relayPairing = await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
managedStart.home,
|
|
||||||
["daemon", "pair"],
|
|
||||||
managedEnv
|
|
||||||
);
|
|
||||||
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
|
|
||||||
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
|
|
||||||
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
|
|
||||||
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
|
|
||||||
assert.deepEqual(relayPong, { type: "pong" });
|
|
||||||
|
|
||||||
logStep("Re-running managed start without spawning duplicate daemons");
|
|
||||||
const managedRestartless = JSON.parse(
|
|
||||||
(
|
|
||||||
await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
managedStart.home,
|
|
||||||
["start", "--json"],
|
|
||||||
managedEnv
|
|
||||||
)
|
|
||||||
).stdout.trim()
|
|
||||||
);
|
|
||||||
assert.equal(managedRestartless.pid, managedPid);
|
|
||||||
|
|
||||||
logStep("Verifying managed and external daemons coexist");
|
|
||||||
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
|
|
||||||
const externalPidAfter = externalStatusAfter.pid ?? null;
|
|
||||||
assert.equal(externalStatusAfter.status, "running");
|
|
||||||
assert.equal(externalPidAfter, externalPid);
|
|
||||||
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
|
|
||||||
const managedStatus = JSON.parse(
|
|
||||||
(
|
|
||||||
await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
managedStart.home,
|
|
||||||
["daemon", "status", "--json"],
|
|
||||||
managedEnv
|
|
||||||
)
|
|
||||||
).stdout.trim()
|
|
||||||
);
|
|
||||||
assert.ok(managedStatus.serverId, "managed daemon should expose a server id");
|
|
||||||
assertNoForbiddenPathsOrPorts(managedStatus, forbiddenManagedReferences);
|
|
||||||
|
|
||||||
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
|
|
||||||
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
|
|
||||||
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
runtimeStatus,
|
|
||||||
managedBootstrap: managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null,
|
|
||||||
managedStart,
|
|
||||||
persistedManagedStatus,
|
|
||||||
managedRestartless,
|
|
||||||
bundledCliDaemonStatus,
|
|
||||||
relayEndpoint,
|
|
||||||
relayOfferUrl,
|
|
||||||
relayPong,
|
|
||||||
externalEndpoint,
|
|
||||||
externalPid,
|
|
||||||
externalPidAfter,
|
|
||||||
managedStatus,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
) + "\n",
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`\n[managed-smoke] PASS (${testRoot})`);
|
|
||||||
} finally {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
try {
|
|
||||||
if (runtimeStatus?.runtimeRoot) {
|
|
||||||
await runBundledRuntimeCli(
|
|
||||||
runtimeStatus.runtimeRoot,
|
|
||||||
managedStart?.home ?? cliScratchHome,
|
|
||||||
["daemon", "stop", "--json"],
|
|
||||||
managedEnv
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
if (startedTemporaryExternalDaemon) {
|
|
||||||
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
await terminateChildProcess(relayProcess, "local relay");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
@@ -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}" "$@"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "APP_EXECUTABLE=%SCRIPT_DIR%Paseo.exe"
|
||||||
|
set "CLI_ENTRY=%SCRIPT_DIR%resources\app.asar\node_modules\@getpaseo\cli\dist\index.js"
|
||||||
|
|
||||||
|
if not exist "%APP_EXECUTABLE%" (
|
||||||
|
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%CLI_ENTRY%" (
|
||||||
|
echo Bundled Paseo CLI entrypoint not found at %CLI_ENTRY% 1>&2
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "ELECTRON_RUN_AS_NODE=1"
|
||||||
|
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
|
|
||||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
|
||||||
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
|
|
||||||
const signingIdentity = process.env.APPLE_SIGNING_IDENTITY;
|
|
||||||
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
throw new Error("sign-managed-runtime-macos.mjs can only run on macOS.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signingIdentity) {
|
|
||||||
throw new Error("APPLE_SIGNING_IDENTITY is required to sign the managed runtime.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathExists(target) {
|
|
||||||
try {
|
|
||||||
await fs.access(target);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walkFiles(root) {
|
|
||||||
const files = [];
|
|
||||||
async function walk(current) {
|
|
||||||
const stat = await fs.stat(current);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
const children = await fs.readdir(current);
|
|
||||||
children.sort();
|
|
||||||
for (const child of children) {
|
|
||||||
await walk(path.join(current, child));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
files.push(current);
|
|
||||||
}
|
|
||||||
await walk(root);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
|
||||||
const result = spawnSync(command, args, {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf8",
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`Command failed: ${command} ${args.join(" ")}`,
|
|
||||||
result.stdout?.trim(),
|
|
||||||
result.stderr?.trim(),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result.stdout.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileKind(target) {
|
|
||||||
return run("file", ["-b", target]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMachO(fileKind) {
|
|
||||||
return fileKind.includes("Mach-O");
|
|
||||||
}
|
|
||||||
|
|
||||||
function needsHardenedRuntime(fileKind) {
|
|
||||||
return fileKind.includes("executable");
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractEntitlements(file) {
|
|
||||||
const result = spawnSync("codesign", ["-d", "--entitlements", "-", "--xml", file], {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf8",
|
|
||||||
});
|
|
||||||
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return result.stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const pointer = JSON.parse(
|
|
||||||
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
|
|
||||||
);
|
|
||||||
const runtimeRoot = path.join(resourcesRoot, pointer.relativeRoot);
|
|
||||||
if (!(await pathExists(runtimeRoot))) {
|
|
||||||
throw new Error(`Managed runtime root does not exist: ${runtimeRoot}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await walkFiles(runtimeRoot);
|
|
||||||
const signTargets = [];
|
|
||||||
for (const file of files) {
|
|
||||||
const fileKind = getFileKind(file);
|
|
||||||
if (!isMachO(fileKind)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
signTargets.push({
|
|
||||||
file,
|
|
||||||
needsRuntime: needsHardenedRuntime(fileKind),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
signTargets.sort((left, right) => left.file.localeCompare(right.file));
|
|
||||||
|
|
||||||
for (const target of signTargets) {
|
|
||||||
let entitlementsFile = null;
|
|
||||||
if (target.needsRuntime) {
|
|
||||||
const entitlements = extractEntitlements(target.file);
|
|
||||||
if (entitlements) {
|
|
||||||
entitlementsFile = `${target.file}.entitlements.plist`;
|
|
||||||
await fs.writeFile(entitlementsFile, entitlements, "utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
"--force",
|
|
||||||
"--sign",
|
|
||||||
signingIdentity,
|
|
||||||
"--timestamp",
|
|
||||||
];
|
|
||||||
if (target.needsRuntime) {
|
|
||||||
args.push("--options", "runtime");
|
|
||||||
if (entitlementsFile) {
|
|
||||||
args.push("--entitlements", entitlementsFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args.push(target.file);
|
|
||||||
console.log(`[managed-runtime-sign] ${path.relative(repoRoot, target.file)}`);
|
|
||||||
run("codesign", args);
|
|
||||||
|
|
||||||
if (entitlementsFile) {
|
|
||||||
await fs.unlink(entitlementsFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await main();
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const desktopRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
||||||
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
|
|
||||||
|
|
||||||
const desktopPackageJson = JSON.parse(
|
|
||||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
|
||||||
);
|
|
||||||
const expectedVersion = desktopPackageJson.version;
|
|
||||||
|
|
||||||
const currentRuntime = JSON.parse(
|
|
||||||
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
|
|
||||||
);
|
|
||||||
assert.equal(currentRuntime.runtimeVersion, expectedVersion, "current-runtime.json version mismatch");
|
|
||||||
assert.ok(currentRuntime.runtimeId, "current-runtime.json missing runtimeId");
|
|
||||||
assert.ok(currentRuntime.relativeRoot, "current-runtime.json missing relativeRoot");
|
|
||||||
|
|
||||||
const runtimeRoot = path.join(resourcesRoot, currentRuntime.relativeRoot);
|
|
||||||
const manifest = JSON.parse(
|
|
||||||
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
|
|
||||||
);
|
|
||||||
assert.equal(manifest.runtimeId, currentRuntime.runtimeId, "manifest runtimeId mismatch");
|
|
||||||
assert.equal(manifest.runtimeVersion, expectedVersion, "manifest version mismatch");
|
|
||||||
assert.ok(manifest.nodeRelativePath, "manifest missing nodeRelativePath");
|
|
||||||
assert.ok(manifest.cliEntrypointRelativePath, "manifest missing cliEntrypointRelativePath");
|
|
||||||
assert.ok(manifest.serverRunnerRelativePath, "manifest missing serverRunnerRelativePath");
|
|
||||||
|
|
||||||
const nodeBinary = path.join(runtimeRoot, manifest.nodeRelativePath);
|
|
||||||
await fs.access(nodeBinary).catch(() => {
|
|
||||||
throw new Error(`Bundled Node binary not found: ${nodeBinary}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cliEntry = path.join(runtimeRoot, manifest.cliEntrypointRelativePath);
|
|
||||||
await fs.access(cliEntry).catch(() => {
|
|
||||||
throw new Error(`CLI entrypoint not found: ${cliEntry}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const serverRunner = path.join(runtimeRoot, manifest.serverRunnerRelativePath);
|
|
||||||
await fs.access(serverRunner).catch(() => {
|
|
||||||
throw new Error(`Server runner not found: ${serverRunner}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtimePackageJson = JSON.parse(
|
|
||||||
await fs.readFile(path.join(runtimeRoot, "package.json"), "utf8")
|
|
||||||
);
|
|
||||||
assert.equal(runtimePackageJson.version, expectedVersion, "runtime package.json version mismatch");
|
|
||||||
|
|
||||||
for (const pkg of ["@getpaseo/relay", "@getpaseo/server", "@getpaseo/cli"]) {
|
|
||||||
const pkgDir = path.join(runtimeRoot, "node_modules", ...pkg.split("/"));
|
|
||||||
await fs.access(pkgDir).catch(() => {
|
|
||||||
throw new Error(`Missing bundled dependency: ${pkg} (expected at ${pkgDir})`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sherpaPlatformMap = {
|
|
||||||
darwin: "darwin",
|
|
||||||
linux: "linux",
|
|
||||||
win32: "win",
|
|
||||||
};
|
|
||||||
const sherpaPlatform = sherpaPlatformMap[manifest.platform];
|
|
||||||
assert.ok(
|
|
||||||
sherpaPlatform,
|
|
||||||
`Unsupported sherpa platform mapping for managed runtime validation: ${manifest.platform}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const sherpaNativePackage = `sherpa-onnx-${sherpaPlatform}-${manifest.arch}`;
|
|
||||||
const sherpaNativePackageDir = path.join(runtimeRoot, "node_modules", sherpaNativePackage);
|
|
||||||
await fs.access(sherpaNativePackageDir).catch(() => {
|
|
||||||
throw new Error(
|
|
||||||
`Missing bundled native speech dependency: ${sherpaNativePackage} (expected at ${sherpaNativePackageDir})`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[validate-managed-runtime] PASS`);
|
|
||||||
console.log(` runtimeId: ${manifest.runtimeId}`);
|
|
||||||
console.log(` version: ${expectedVersion}`);
|
|
||||||
console.log(` platform: ${manifest.platform}`);
|
|
||||||
console.log(` arch: ${manifest.arch}`);
|
|
||||||
console.log(` sherpaNativePackage: ${sherpaNativePackage}`);
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
|
||||||
|
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
|
||||||
|
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
|
||||||
|
const REQUIRED_DESKTOP_KEYS = [
|
||||||
|
"invoke",
|
||||||
|
"events",
|
||||||
|
"window",
|
||||||
|
"dialog",
|
||||||
|
"notification",
|
||||||
|
"opener",
|
||||||
|
];
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(dirPath) {
|
||||||
|
await fs.mkdir(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureScreenshot(page, fileName) {
|
||||||
|
const filePath = path.join(OUTPUT_DIR, fileName);
|
||||||
|
await page.screenshot({ path: filePath, fullPage: true });
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAppPage(browser) {
|
||||||
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
|
for (const context of browser.contexts()) {
|
||||||
|
for (const page of context.pages()) {
|
||||||
|
if (page.url().includes(APP_URL_FRAGMENT) && !page.url().startsWith("devtools://")) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Unable to find Electron app page for ${APP_URL_FRAGMENT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureDir(OUTPUT_DIR);
|
||||||
|
|
||||||
|
const browser = await chromium.connectOverCDP(CDP_URL);
|
||||||
|
const page = await findAppPage(browser);
|
||||||
|
const consoleMessages = [];
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
page.on("console", (message) => {
|
||||||
|
consoleMessages.push({
|
||||||
|
type: message.type(),
|
||||||
|
text: message.text(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
consoleMessages.push({
|
||||||
|
type: "pageerror",
|
||||||
|
text: String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.waitForLoadState("domcontentloaded");
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
if (!page.url().endsWith("/welcome")) {
|
||||||
|
await page.goto(`http://${APP_URL_FRAGMENT}/welcome`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png");
|
||||||
|
|
||||||
|
const desktopDetection = await page.evaluate(() => {
|
||||||
|
const bridge = window.paseoDesktop;
|
||||||
|
const keys = bridge && typeof bridge === "object" ? Object.keys(bridge) : [];
|
||||||
|
const keyTypes = bridge && typeof bridge === "object"
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(bridge).map(([key, value]) => [key, typeof value])
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
exists: Boolean(bridge && typeof bridge === "object"),
|
||||||
|
keys,
|
||||||
|
keyTypes,
|
||||||
|
platform: bridge?.platform ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasExpectedDesktopShape =
|
||||||
|
desktopDetection.exists &&
|
||||||
|
REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key));
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
check: "desktop-detection",
|
||||||
|
pass: hasExpectedDesktopShape,
|
||||||
|
details: desktopDetection,
|
||||||
|
screenshot: welcomeScreenshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const desktopStatus = await page.evaluate(() => window.paseoDesktop.invoke("desktop_daemon_status"));
|
||||||
|
assert(
|
||||||
|
typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0,
|
||||||
|
"desktop_daemon_status did not return a serverId"
|
||||||
|
);
|
||||||
|
|
||||||
|
const serverId = desktopStatus.serverId.trim();
|
||||||
|
await page.evaluate((nextServerId) => {
|
||||||
|
window.location.href = `/h/${nextServerId}/settings`;
|
||||||
|
}, serverId);
|
||||||
|
await page.waitForURL(new RegExp(`/h/${escapeRegExp(serverId)}/settings$`), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByText("Daemon management", { exact: true }).waitFor({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsScreenshot = await captureScreenshot(page, "02-settings-page.png");
|
||||||
|
|
||||||
|
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first();
|
||||||
|
const menuToggle = page.locator('[data-testid="menu-button"]').first();
|
||||||
|
if (
|
||||||
|
(await sidebarSettingsButton.isVisible().catch(() => false)) &&
|
||||||
|
(await menuToggle.isVisible().catch(() => false))
|
||||||
|
) {
|
||||||
|
await menuToggle.click();
|
||||||
|
await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => undefined);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragRegionCheck = await page.evaluate(() => {
|
||||||
|
const nodes = Array.from(document.querySelectorAll("*"));
|
||||||
|
const annotationId = "electron-verify-drag-style";
|
||||||
|
const existingAnnotation = document.getElementById(annotationId);
|
||||||
|
existingAnnotation?.remove();
|
||||||
|
|
||||||
|
const annotationStyle = document.createElement("style");
|
||||||
|
annotationStyle.id = annotationId;
|
||||||
|
annotationStyle.textContent = `
|
||||||
|
[data-electron-verify-drag="true"] {
|
||||||
|
outline: 3px solid #ff4d4f !important;
|
||||||
|
outline-offset: -3px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(annotationStyle);
|
||||||
|
|
||||||
|
function isVisible(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return (
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
style.display !== "none" &&
|
||||||
|
style.visibility !== "hidden" &&
|
||||||
|
style.opacity !== "0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeText(element) {
|
||||||
|
return (element.textContent ?? "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = [];
|
||||||
|
let candidate = null;
|
||||||
|
|
||||||
|
for (const element of nodes) {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
const appRegion =
|
||||||
|
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||||
|
if (appRegion !== "drag" || !isVisible(element)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const text = summarizeText(element);
|
||||||
|
const info = {
|
||||||
|
tagName: element.tagName.toLowerCase(),
|
||||||
|
text,
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
||||||
|
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
||||||
|
};
|
||||||
|
regions.push(info);
|
||||||
|
|
||||||
|
const looksLikeHeader =
|
||||||
|
rect.top < 180 &&
|
||||||
|
rect.height >= 40 &&
|
||||||
|
(text.includes("Settings") || text.includes("Sessions"));
|
||||||
|
if (!candidate && looksLikeHeader) {
|
||||||
|
candidate = { ...info };
|
||||||
|
element.setAttribute("data-electron-verify-drag", "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate && regions.length > 0) {
|
||||||
|
candidate = regions
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => left.top - right.top)[0];
|
||||||
|
|
||||||
|
for (const element of nodes) {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
const appRegion =
|
||||||
|
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||||
|
if (appRegion !== "drag" || !isVisible(element)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
Math.abs(rect.top - candidate.top) < 1 &&
|
||||||
|
Math.abs(rect.left - candidate.left) < 1 &&
|
||||||
|
Math.abs(rect.width - candidate.width) < 1 &&
|
||||||
|
Math.abs(rect.height - candidate.height) < 1
|
||||||
|
) {
|
||||||
|
element.setAttribute("data-electron-verify-drag", "true");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: regions.length,
|
||||||
|
candidate,
|
||||||
|
regions: regions.slice(0, 10),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
|
||||||
|
const dragRegionPassed =
|
||||||
|
dragRegionCheck.count > 0 &&
|
||||||
|
Boolean(dragRegionCheck.candidate) &&
|
||||||
|
dragRegionCheck.candidate.top < 180;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
check: "drag-regions",
|
||||||
|
pass: dragRegionPassed,
|
||||||
|
details: dragRegionCheck,
|
||||||
|
screenshot: dragScreenshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
|
||||||
|
const trafficLightPaddingPassed = !isMac
|
||||||
|
? true
|
||||||
|
: typeof observedPaddingLeft === "number" &&
|
||||||
|
observedPaddingLeft >= 78 &&
|
||||||
|
observedPaddingLeft <= 110;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
check: "traffic-light-padding",
|
||||||
|
pass: trafficLightPaddingPassed,
|
||||||
|
details: {
|
||||||
|
platform: process.platform,
|
||||||
|
observedPaddingLeft,
|
||||||
|
expectedApproximatePaddingLeft: 78,
|
||||||
|
candidate: dragRegionCheck.candidate,
|
||||||
|
},
|
||||||
|
screenshot: trafficLightScreenshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const daemonManagementVisible = await Promise.all([
|
||||||
|
page.getByText("Built-in daemon", { exact: true }).isVisible(),
|
||||||
|
page.getByText("Daemon management", { exact: true }).isVisible(),
|
||||||
|
page.getByRole("button", { name: "Restart daemon" }).first().isVisible(),
|
||||||
|
]).then((values) => values.every(Boolean));
|
||||||
|
const daemonManagementScreenshot = await captureScreenshot(
|
||||||
|
page,
|
||||||
|
"05-settings-daemon-management.png"
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
check: "settings-daemon-management",
|
||||||
|
pass: daemonManagementVisible,
|
||||||
|
details: {
|
||||||
|
route: page.url(),
|
||||||
|
serverId,
|
||||||
|
desktopStatus,
|
||||||
|
},
|
||||||
|
screenshot: daemonManagementScreenshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const desktopDetectionScreenshot = await captureScreenshot(
|
||||||
|
page,
|
||||||
|
"06-desktop-detection.png"
|
||||||
|
);
|
||||||
|
results[0].screenshot = desktopDetectionScreenshot;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
cdpUrl: CDP_URL,
|
||||||
|
outputDir: OUTPUT_DIR,
|
||||||
|
pageUrl: page.url(),
|
||||||
|
desktopStatus,
|
||||||
|
results,
|
||||||
|
consoleMessages,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportPath = path.join(OUTPUT_DIR, "report.json");
|
||||||
|
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
const failedChecks = results.filter((result) => !result.pass);
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (failedChecks.length > 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Generated by Cargo
|
|
||||||
# will have compiled files and executables
|
|
||||||
/target/
|
|
||||||
/gen/schemas
|
|
||||||
@@ -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 |