diff --git a/.github/actions/install-desktop-deps/action.yml b/.github/actions/install-desktop-deps/action.yml index 6da52f04fd0..4b86762bce1 100644 --- a/.github/actions/install-desktop-deps/action.yml +++ b/.github/actions/install-desktop-deps/action.yml @@ -19,7 +19,6 @@ runs: run: | sudo apt update sudo apt install -y --no-install-recommends \ - libwebkit2gtk-4.1-dev \ build-essential \ pkg-config \ curl \ diff --git a/.github/assets/electron-vs-tauri-performance.png b/.github/assets/electron-vs-tauri-performance.png new file mode 100644 index 00000000000..893b8f4f06c Binary files /dev/null and b/.github/assets/electron-vs-tauri-performance.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c42007a69ad..119a2ac6381 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,8 @@ name: "CI" + +permissions: + contents: read + on: push: branches: @@ -19,14 +23,14 @@ jobs: runs-on: ubuntu-latest outputs: rust: ${{ steps.filter.outputs.rust }} - tauri-plugins: ${{ steps.filter.outputs.tauri-plugins }} + desktop: ${{ steps.filter.outputs.desktop }} branch: ${{ steps.branch.outputs.branch }} steps: - name: Checkout uses: actions/checkout@v4 - name: Check for changes - uses: dorny/paths-filter@v2 + uses: dorny/paths-filter@v3 id: filter with: filters: | @@ -37,17 +41,14 @@ jobs: - '.github/**' - 'apps/cli/**' - 'crates/**' - - 'apps/desktop/src-tauri/**' + - 'apps/desktop/src-backend/**' - 'Cargo.toml' - 'Cargo.lock' - tauri-plugins: - - 'Cargo.lock' - - 'pnpm-lock.yaml' - name: Output branch name id: branch run: | - echo "branch=${{ env.BRANCH_NAME }}" >> $GITHUB_OUTPUT + echo "branch=${{ env.BRANCH_NAME }}" >> "$GITHUB_OUTPUT" typecheck: name: Typecheck @@ -147,24 +148,22 @@ jobs: - name: Create .env file in root run: | - echo "VITE_ENVIRONMENT=production" >> .env - echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" >> .env - echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}" >> .env - echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env - echo 'VITE_OPENPANEL_CLIENT_ID=${{ secrets.VITE_OPENPANEL_CLIENT_ID }}' >> .env - echo 'VITE_OPENPANEL_API_URL=${{ secrets.VITE_OPENPANEL_API_URL }}' >> .env - echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env - - cat .env >> $GITHUB_ENV + { + echo "VITE_ENVIRONMENT=production" + echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" + echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}" + echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' + echo 'VITE_OPENPANEL_CLIENT_ID=${{ secrets.VITE_OPENPANEL_CLIENT_ID }}' + echo 'VITE_OPENPANEL_API_URL=${{ secrets.VITE_OPENPANEL_API_URL }}' + echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' + } >> .env + + cat .env >> "$GITHUB_ENV" - name: Run setup shell: bash run: node scripts/setup.js - - name: Build desktop binaries - shell: bash - run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} - - name: Run Clippy run: cargo clippy --workspace --all-features --locked -- -D warnings @@ -199,6 +198,10 @@ jobs: env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + # CI validates release-mode compilation and packaging without paying the + # production profile's full-LTO cost on every pull request. + CARGO_PROFILE_RELEASE_LTO: "false" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" steps: - name: Checkout repository uses: actions/checkout@v4 @@ -216,20 +219,19 @@ jobs: - name: Create .env file in root run: | - echo "VITE_ENVIRONMENT=production" >> .env - echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" >> .env - echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}" >> .env - echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env - echo 'VITE_OPENPANEL_CLIENT_ID=${{ secrets.VITE_OPENPANEL_CLIENT_ID }}' >> .env - echo 'VITE_OPENPANEL_API_URL=${{ secrets.VITE_OPENPANEL_API_URL }}' >> .env - echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env + { + echo "VITE_ENVIRONMENT=production" + echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" + echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}" + echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' + echo 'VITE_OPENPANEL_CLIENT_ID=${{ secrets.VITE_OPENPANEL_CLIENT_ID }}' + echo 'VITE_OPENPANEL_API_URL=${{ secrets.VITE_OPENPANEL_API_URL }}' + echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' + } >> .env - name: Copy .env to apps/desktop run: cp .env apps/desktop/.env - - name: Output .env file - run: cat apps/desktop/.env - - name: Run cap-setup shell: bash run: pnpm -w cap-setup @@ -242,27 +244,19 @@ jobs: - name: Build app working-directory: apps/desktop - run: pnpm tauri build --debug --target ${{ matrix.settings.target }} --no-bundle + shell: bash + run: | + pnpm run preparescript + pnpm build + pnpm build:electron-main + if [[ "$RUNNER_OS" == "macOS" ]]; then + pnpm exec electron-builder --dir --mac --arm64 + else + pnpm exec electron-builder --dir --win --x64 + fi env: RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} - - tauri-plugins: - name: Verify Tauri plugin versions - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.tauri-plugins == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup node - uses: actions/setup-node@v3 - with: - node-version: 20 - - - name: Run verify - shell: bash - run: node scripts/check-tauri-plugin-versions.js + CSC_IDENTITY_AUTO_DISCOVERY: false rust-cache: name: Rust cache @@ -294,7 +288,7 @@ jobs: save-cache: true - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 24 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index e22099e885f..58b671d3d23 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -1,44 +1,31 @@ -name: "desktop-release" - -# Reusable core of the desktop release pipeline. Callers: -# - publish.yml -> channel: stable, version from Cargo.toml, manual CN publish -# - publish-nightly.yml -> channel: nightly, generated prerelease version, auto CN publish -# - promote-nightly.yml -> channel: stable, rebuilds a nightly's commit as a plain x.y.z -# -# Stable releases get a cap-v{version} git tag + draft GitHub release and are -# published on CrabNebula manually (Discord bot / dashboard), exactly as before. -# Nightly releases skip the GitHub side entirely and go to the CrabNebula -# "nightly" channel, which is invisible to the default update endpoint and to -# the website's `latest` download URLs. +name: desktop-release on: workflow_call: inputs: version: - description: "Version to stamp into the build and the CrabNebula release" required: true type: string channel: - description: "Release channel: stable or nightly" required: true type: string sha: - description: "Commit SHA to build (and to tag, for stable releases)" required: true type: string auto_publish: - description: "Publish the CrabNebula release automatically after upload" required: false type: boolean default: false interactionId: - description: "Discord Interaction ID" required: false type: string default: "" +concurrency: + group: desktop-release-${{ inputs.channel }}-${{ inputs.version }} + cancel-in-progress: false + env: - CN_APPLICATION: cap/cap SENTRY_ORG: cap-s2 SENTRY_PROJECT: cap-desktop @@ -46,237 +33,200 @@ jobs: draft: runs-on: ubuntu-latest outputs: - tag_name: ${{ steps.create_tag.outputs.tag_name }} - needs_release: ${{ steps.create_tag.outputs.release_published != 'true' }} - cn_release_stdout: ${{ steps.create_cn_release.outputs.stdout }} - gh_release_url: ${{ steps.create_gh_release.outputs.url || steps.create_tag.outputs.release_url }} + tag_name: ${{ steps.release.outputs.tag_name }} + staging_tag: ${{ steps.release.outputs.staging_tag }} + staging_release_id: ${{ steps.release.outputs.staging_release_id }} permissions: contents: write - id-token: write - env: - CN_CHANNEL_FLAG: ${{ inputs.channel == 'nightly' && '--channel nightly' || '' }} steps: - - name: Prepare release - id: create_tag - if: ${{ inputs.channel == 'stable' }} + - name: Require immutable releases + if: ${{ vars.IMMUTABLE_RELEASES_ENABLED != 'true' }} + run: | + echo "Repository immutable releases must be enabled before preparing a desktop release" >&2 + exit 1 + + - name: Prepare immutable release staging draft + id: release uses: actions/github-script@v7 + env: + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_SHA: ${{ inputs.sha }} + RELEASE_VERSION: ${{ inputs.version }} with: script: | - const tag = "cap-v${{ inputs.version }}"; - const buildSha = "${{ inputs.sha }}"; + const channel = process.env.RELEASE_CHANNEL; + const sha = process.env.RELEASE_SHA; + const version = process.env.RELEASE_VERSION; + if (!/^[0-9a-f]{40}$/.test(sha)) + throw new Error(`Invalid release SHA '${sha}'`); + const versionPattern = channel === "nightly" + ? /^\d+\.\d+\.\d+-nightly\.\d{12}$/ + : channel === "stable" + ? /^\d+\.\d+\.\d+$/ + : null; + if (!versionPattern || !versionPattern.test(version)) + throw new Error(`Invalid ${channel} release version '${version}'`); + + const tag = channel === "nightly" + ? `cap-nightly-v${version}` + : `cap-v${version}`; + const stagingTag = `${tag}-staging-${sha}`; const tagRef = `tags/${tag}`; - - const TAG_EXISTED = "tag_existed"; - const TAG_NAME = "tag_name"; - const RELEASE_PUBLISHED = "release_published"; - const RELEASE_URL = "release_url"; - - core.setOutput(TAG_NAME, tag); - - async function main() { - let tagExisted = true; - let tagSha = ""; - let releasePublished = false; - let releaseUrl = ""; - - try { - const tagRefResponse = await github.rest.git.getRef({ - ref: tagRef, - owner: context.repo.owner, - repo: context.repo.repo, - }); - - tagExisted = true; - tagSha = tagRefResponse.data.object.sha; - core.notice(`Tag '${tag}' already exists. Reusing it for this release run.`); - } catch (error) { - if ("status" in error && error.status === 404) tagExisted = false; - else throw error; - } - - if (!tagExisted) - await github.rest.git.createRef({ - ref: `refs/${tagRef}`, - owner: context.repo.owner, - repo: context.repo.repo, - sha: buildSha, - }); - - const releases = await github.paginate(github.rest.repos.listReleases, { + const releases = await github.paginate(github.rest.repos.listReleases, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + + try { + const existingTag = await github.rest.git.getRef({ owner: context.repo.owner, repo: context.repo.repo, - per_page: 100, + ref: tagRef, }); + if (existingTag.data.object.sha !== sha) { + throw new Error( + `Tag '${tag}' already points to ${existingTag.data.object.sha}; refusing to move it to ${sha}`, + ); + } + } catch (error) { + if (error.status !== 404) throw error; + } - const release = releases.find((release) => release.tag_name === tag); + const existingRelease = releases.find( + (release) => release.tag_name === tag, + ); + if (existingRelease) { + throw new Error( + `Release '${tag}' already exists (${existingRelease.draft ? "draft" : "published"}): ${existingRelease.html_url}`, + ); + } - if (release) { - releaseUrl = release.html_url; - releasePublished = !release.draft; + let stagingRelease = releases.find( + (release) => release.tag_name === stagingTag, + ); - if (releasePublished) - throw new Error(`Release '${tag}' is already published: ${releaseUrl}. Bump apps/desktop/src-tauri/Cargo.toml before publishing a new build.`); - else - core.notice(`Draft release '${tag}' already exists. Continuing build and upload.`); + if (stagingRelease) { + if (!stagingRelease.draft) { + throw new Error( + `Staging release '${stagingTag}' is published; refusing to mutate it`, + ); } - - if (tagExisted && !releasePublished && tagSha !== buildSha) { - await github.rest.git.updateRef({ - ref: tagRef, + if (stagingRelease.target_commitish !== sha) { + throw new Error( + `Staging release '${stagingTag}' targets ${stagingRelease.target_commitish}; expected ${sha}`, + ); + } + for (const asset of await github.paginate( + github.rest.repos.listReleaseAssets, + { + owner: context.repo.owner, + repo: context.repo.repo, + release_id: stagingRelease.id, + per_page: 100, + }, + )) { + await github.rest.repos.deleteReleaseAsset({ owner: context.repo.owner, repo: context.repo.repo, - sha: buildSha, - force: true, + asset_id: asset.id, }); - - core.notice(`Updated unpublished tag '${tag}' to ${buildSha}.`); } - - core.setOutput(TAG_EXISTED, tagExisted); - core.setOutput(RELEASE_PUBLISHED, releasePublished); - core.setOutput(RELEASE_URL, releaseUrl); - } - - await main(); - - - name: Create draft CN release - id: create_cn_release - if: ${{ steps.create_tag.outputs.release_published != 'true' }} - uses: crabnebula-dev/cloud-release@v0 - with: - command: release draft ${{ env.CN_APPLICATION }} ${{ inputs.version }} --framework tauri ${{ env.CN_CHANNEL_FLAG }} - api-key: ${{ secrets.CN_API_KEY }} - - - name: Create draft GH release - id: create_gh_release - if: ${{ inputs.channel == 'stable' && steps.create_tag.outputs.release_published != 'true' }} - # TODO: Change to stable version when available - uses: softprops/action-gh-release@v2 - with: - name: ${{ inputs.version }} - tag_name: ${{ steps.create_tag.outputs.tag_name }} - draft: true - generate_release_notes: true - - - name: Update Discord interaction - if: ${{ inputs.channel == 'stable' && inputs.interactionId != '' && steps.create_tag.outputs.release_published != 'true' }} - uses: actions/github-script@v7 - with: - script: | - async function main() { - const token = await core.getIDToken("cap-discord-bot"); - const cnReleaseId = JSON.parse(`${{ steps.create_cn_release.outputs.stdout }}`).id; - - const resp = await fetch("https://cap-discord-bot.brendonovich.workers.dev/github-workflow", { - method: "POST", - body: JSON.stringify({ - type: "release-ready", - tag: "${{ steps.create_tag.outputs.tag_name }}", - version: "${{ inputs.version }}", - releaseUrl: "${{ steps.create_gh_release.outputs.url }}", - interactionId: "${{ inputs.interactionId }}", - cnReleaseId - }), - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - } - }); - - if(resp.status !== 200) throw new Error(await resp.text()); + stagingRelease = (await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: stagingRelease.id, + target_commitish: sha, + name: version, + draft: true, + prerelease: channel === "nightly", + })).data; + } else { + stagingRelease = (await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: stagingTag, + target_commitish: sha, + name: version, + draft: true, + prerelease: channel === "nightly", + generate_release_notes: true, + })).data; } - - main(); + core.setOutput("tag_name", tag); + core.setOutput("staging_tag", stagingTag); + core.setOutput("staging_release_id", stagingRelease.id); build: needs: draft - if: ${{ needs.draft.outputs.needs_release == 'true' }} permissions: - contents: write + contents: read actions: read - id-token: write strategy: fail-fast: false matrix: settings: - target: x86_64-apple-darwin runner: macos-latest-xlarge + builder_args: --mac --x64 - target: aarch64-apple-darwin runner: macos-latest-xlarge + builder_args: --mac --arm64 - target: x86_64-pc-windows-msvc runner: windows-2022 + builder_args: --win --x64 - target: x86_64-unknown-linux-gnu runner: ubuntu-24.04 + builder_args: --linux deb --x64 + runs-on: ${{ matrix.settings.runner }} env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - CN_CHANNEL_FLAG: ${{ inputs.channel == 'nightly' && '--channel nightly' || '' }} - runs-on: ${{ matrix.settings.runner }} steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: ref: ${{ inputs.sha }} - # Nightly and promoted builds carry a version that differs from the - # committed Cargo.toml; Tauri reads the app version from package.version. - - name: Stamp build version + - name: Stamp Electron version shell: bash env: BUILD_VERSION: ${{ inputs.version }} run: | node -e ' const fs = require("fs"); - const path = "apps/desktop/src-tauri/Cargo.toml"; - const version = process.env.BUILD_VERSION; - let toml = fs.readFileSync(path, "utf8"); - const re = /^version = ".*"$/m; - if (!re.test(toml)) { - console.error("package.version line not found in " + path); - process.exit(1); - } - toml = toml.replace(re, `version = "${version}"`); - fs.writeFileSync(path, toml); - console.log("Stamped " + path + " with " + version); + const path = "apps/desktop/package.json"; + const json = JSON.parse(fs.readFileSync(path, "utf8")); + json.version = process.env.BUILD_VERSION; + fs.writeFileSync(path, JSON.stringify(json, null, "\t") + "\n"); ' - name: Install Linux desktop dependencies if: ${{ runner.os == 'Linux' }} uses: ./.github/actions/install-desktop-deps - - name: Create API Key File - if: ${{ runner.os == 'macOS' }} - run: echo "${{ secrets.APPLE_API_KEY_FILE }}" > api.p8 - - - uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2 + - name: Import macOS signing certificate if: ${{ runner.os == 'macOS' }} + uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 with: p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - name: Verify certificate + - name: Write Apple notarization key if: ${{ runner.os == 'macOS' }} - run: security find-identity -v -p codesigning ${{ runner.temp }}/build.keychain + shell: bash + run: echo "${{ secrets.APPLE_API_KEY_FILE }}" > "$RUNNER_TEMP/AuthKey.p8" - - name: Rust setup - uses: dtolnay/rust-toolchain@1.88.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: targets: ${{ matrix.settings.target }} - - name: Verify Rust toolchain - shell: bash - run: | - rustc -Vv - cargo -V - rustup show active-toolchain - - uses: ./.github/actions/setup-rust-cache with: target: ${{ matrix.settings.target }} - uses: ./.github/actions/setup-js - - name: Create .env file in root + - name: Create build environment shell: bash run: | { @@ -291,186 +241,71 @@ jobs: echo 'RUST_TARGET_TRIPLE=${{ matrix.settings.target }}' } >> .env - - name: Run cap-setup + - name: Prepare native dependencies shell: bash run: pnpm -w cap-setup env: RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }} - APPLE_KEYCHAIN: ${{ runner.os == 'macOS' && format('{0}/build.keychain', runner.temp) || '' }} - - name: Build desktop binaries - shell: bash - run: | - if [[ "$RUNNER_OS" == "Linux" ]]; then - export CARGO_PROFILE_RELEASE_DEBUG=0 - fi - ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} - - # The release profile uses fat LTO + full debuginfo, so the final link - # peaks well above the 16GB stock Linux runner and gets OOM-killed - # ("runner received a shutdown signal"). The runner already ships an - # active /swapfile (resizing it fails with "Text file busy"), so add a - # large swapfile on the roomy /mnt disk on top of the existing swap. - # macOS runs on xlarge runners and is unaffected. - - name: Increase swap space (Linux) + - name: Increase Linux swap if: ${{ runner.os == 'Linux' }} shell: bash run: | - sudo swapoff /mnt/swapfile 2>/dev/null || true - sudo rm -f /mnt/swapfile - sudo fallocate -l 16G /mnt/swapfile - sudo chmod 600 /mnt/swapfile - sudo mkswap /mnt/swapfile - sudo swapon /mnt/swapfile - swapon --show - free -h - - # The same fat-LTO link exhausts commit on the 16GB Windows runner - # ("rustc-LLVM ERROR: out of memory" while linking cap-desktop), so - # raise the pagefile on the roomy D: disk — the Windows analog of the - # Linux swapfile above. - - name: Increase pagefile size (Windows) + sudo fallocate -l 16G /mnt/electron-swapfile + sudo chmod 600 /mnt/electron-swapfile + sudo mkswap /mnt/electron-swapfile + sudo swapon /mnt/electron-swapfile + + - name: Increase Windows pagefile if: ${{ runner.os == 'Windows' }} - uses: al-cheb/configure-pagefile-action@a3b6ebd6b634da88790d9c58d4b37a7f4a7b8708 # v1.4 + uses: al-cheb/configure-pagefile-action@a3b6ebd6b634da88790d9c58d4b37a7f4a7b8708 with: minimum-size: 16GB maximum-size: 24GB disk-root: "D:" - - name: Build app + - name: Build Rust backend and sidecars + shell: bash + run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} + env: + CARGO_PROFILE_RELEASE_DEBUG: ${{ runner.os == 'Linux' && '0' || '' }} + + - name: Build renderer and Electron distributable working-directory: apps/desktop - run: ${{ runner.os == 'Linux' && 'CARGO_PROFILE_RELEASE_DEBUG=0 ' || '' }}pnpm build:tauri --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json ${{ runner.os == 'Windows' && '--bundles nsis' || runner.os == 'Linux' && '--bundles deb --verbose' || '' }} + shell: bash + run: | + pnpm run preparescript + pnpm build + pnpm build:electron-main + pnpm exec electron-builder ${{ matrix.settings.builder_args }} --publish never env: - # https://github.com/tauri-apps/tauri-action/issues/740 CI: false - LD_LIBRARY_PATH: ${{ runner.os == 'Linux' && format('{0}/target/{1}/release:{0}/target/native-deps/lib', github.workspace, matrix.settings.target) || '' }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # codesigning - APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }} - APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} - APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }} - # notarization - APPLE_API_ISSUER: ${{ runner.os == 'macOS' && secrets.APPLE_API_ISSUER || '' }} - APPLE_API_KEY: ${{ runner.os == 'macOS' && secrets.APPLE_API_KEY || '' }} - APPLE_API_KEY_PATH: ${{ runner.os == 'macOS' && format('{0}/api.p8', github.workspace) || '' }} - APPLE_KEYCHAIN: ${{ runner.os == 'macOS' && format('{0}/build.keychain', runner.temp) || '' }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} + CSC_NAME: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }} + APPLE_API_KEY: ${{ runner.os == 'macOS' && format('{0}/AuthKey.p8', runner.temp) || '' }} + APPLE_API_KEY_ID: ${{ runner.os == 'macOS' && secrets.APPLE_API_KEY || '' }} + APPLE_API_ISSUER: ${{ runner.os == 'macOS' && secrets.APPLE_API_ISSUER || '' }} - # Guards against the Linux packaging regressions: - # 1. Frontend missing: vinxi must produce apps/desktop/.output/public, - # which Tauri EMBEDS into the binary (it is NOT shipped as a loose - # /usr/lib/Cap/index.html file). A blank webview ships if turbo's cache - # restores an empty .output/public (see @cap/desktop#build outputs in - # turbo.json), so we assert the build output exists and is non-empty. - # 2. FFmpeg soname mismatch: every libav*/libsw* the main binary lists as - # NEEDED must be bundled under /usr/lib/cap/ with the exact same soname. - # The bundled libs come from native-deps (FFmpeg 7); if the binary - # linked system FFmpeg 6 the loader fails at launch. - # 3. Undeclared runtime dependency: a system lib the binary NEEDs that is - # neither bundled nor part of the declared GTK/webkit stack must appear - # in the .deb Depends, or the loader aborts at launch. This is the - # libpipewire-0.3.so.0 crash that shipped in 0.5.2. - - name: Verify Linux deb contents - if: ${{ runner.os == 'Linux' }} + - name: Verify packaged application shell: bash run: | - set -euo pipefail - - # (1) frontend was actually built. Tauri embeds .output/public into the - # binary at build time, so an empty/missing dir => blank webview. - FE="apps/desktop/.output/public/index.html" - if [[ ! -s "$FE" ]]; then - echo "::error::Frontend build output missing/empty ($FE) - webview would be blank." - ls -la apps/desktop/.output/public 2>/dev/null || echo "(no .output/public dir)" - exit 1 - fi - echo "Frontend build output OK ($FE, $(wc -c < "$FE") bytes)" - - UNSUPPORTED_ARTIFACT="$(find target/${{ matrix.settings.target }}/release/bundle -type f \( -name '*.AppImage' -o -name '*.rpm' \) -print -quit)" - if [[ -n "$UNSUPPORTED_ARTIFACT" ]]; then - echo "::error::Unexpected Linux non-deb artifact produced: $UNSUPPORTED_ARTIFACT" - exit 1 - fi - - DEB="$(find target/${{ matrix.settings.target }}/release/bundle/deb -name '*.deb' | head -n1)" - if [[ -z "$DEB" ]]; then echo "::error::No .deb produced"; exit 1; fi - if [[ ! -s "$DEB.sig" ]]; then echo "::error::No .deb updater signature produced next to $DEB"; exit 1; fi - echo "Inspecting $DEB" - WORK="$(mktemp -d)" - dpkg-deb -x "$DEB" "$WORK" - - # Best-effort confirmation that the SPA actually got embedded into the - # binary (Tauri stores asset path keys as plaintext). Informational only. - if strings "$WORK/usr/bin/Cap" 2>/dev/null | grep -q "index.html"; then - echo "Binary appears to embed the frontend (index.html key present)" - else - echo "::warning::Could not confirm embedded frontend key in binary (asset format may differ)" - fi - - # (2) every FFmpeg soname the binary NEEDs is bundled - BIN="$WORK/usr/bin/Cap" - NEEDED="$(objdump -p "$BIN" | awk '/NEEDED/{print $2}' | grep -E '^lib(av|sw|postproc)' || true)" - echo "Binary NEEDs FFmpeg sonames:"; echo "${NEEDED:-(none)}" - echo "Bundled FFmpeg libs:"; ls -1 "$WORK/usr/lib/cap" 2>/dev/null || true - MISSING=0 - for so in $NEEDED; do - if [[ ! -f "$WORK/usr/lib/cap/$so" ]]; then - echo "::error::Binary NEEDs $so but it is not bundled under /usr/lib/cap/" - MISSING=1 - fi - done - if [[ "$MISSING" -ne 0 ]]; then - echo "::error::FFmpeg soname mismatch between binary and bundled libs." - exit 1 - fi - echo "FFmpeg soname bundle OK" - - # (3) runtime libs that are NOT bundled and NOT pulled in by the - # declared GTK/webkit stack must be declared in the .deb Depends, or - # the dynamic loader aborts at launch (the libpipewire-0.3.so.0 crash - # in 0.5.2). Assert the curated set of such sonames the binary links. - DEPENDS="$(dpkg-deb -f "$DEB" Depends || true)" - echo "Declared Depends: ${DEPENDS:-(none)}" - NEEDED_ALL="$(objdump -p "$BIN" | awk '/NEEDED/{print $2}')" - # soname -> a Depends token that satisfies it. The alsa token is a - # substring so it matches both libasound2 and libasound2t64. - declare -A SONAME_DEP=( - [libpipewire-0.3.so.0]="libpipewire-0.3-0" - [libasound.so.2]="libasound2" - ) - DEP_MISSING=0 - for so in "${!SONAME_DEP[@]}"; do - if echo "$NEEDED_ALL" | grep -qx "$so"; then - tok="${SONAME_DEP[$so]}" - if ! echo "$DEPENDS" | grep -q "$tok"; then - echo "::error::Binary NEEDs $so but '$tok' is not declared in .deb Depends (add it to tauri.conf.json bundle.linux.deb.depends)." - DEP_MISSING=1 - else - echo "Runtime dep OK: $so -> $tok" - fi - fi - done - if [[ "$DEP_MISSING" -ne 0 ]]; then - echo "::error::Undeclared runtime dependency in .deb Depends." - exit 1 - fi - echo "Runtime dependency declarations OK" + test -s apps/desktop/.output/public/index.html + test -n "$(find apps/desktop/dist-electron -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.deb' \) -print -quit)" + test -n "$(find apps/desktop/dist-electron -type f -name 'cap-desktop-*' -print -quit)" - name: Upload unsigned Windows installer if: ${{ runner.os == 'Windows' }} - id: upload_unsigned_windows_installer + id: unsigned_windows uses: actions/upload-artifact@v4 with: name: unsigned-windows-installer - path: target/${{ matrix.settings.target }}/release/bundle/nsis/*.exe + path: apps/desktop/dist-electron/*.exe if-no-files-found: error - - name: Submit SignPath signing request + - name: Sign Windows installer with SignPath if: ${{ runner.os == 'Windows' }} - id: submit_signpath_signing_request uses: signpath/github-action-submit-signing-request@v1 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -478,20 +313,8 @@ jobs: project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }} signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }} artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} - github-artifact-id: ${{ steps.upload_unsigned_windows_installer.outputs.artifact-id }} + github-artifact-id: ${{ steps.unsigned_windows.outputs.artifact-id }} wait-for-completion: true - # SignPath Foundation requires a human to approve each signing - # request, so this step blocks until someone clicks approve. Raising - # wait-for-completion-timeout-in-seconds alone did NOT extend the - # window: while a request sits "WaitingForApproval" the action's - # per-call retry budget (service-unavailable-timeout-in-seconds, - # default 600s) is what expires and fails the job at ~10 min, so both - # must be raised. GitHub-hosted jobs are hard-capped at 6h total and - # the Windows build already consumes part of that, so ~4h is the - # practical ceiling for a blocking wait; the action times out cleanly - # here rather than letting GitHub kill the runner mid-build. For a - # genuinely longer (12h+) window the signing has to be decoupled from - # the build job (submit without waiting, finalise in a later job). wait-for-completion-timeout-in-seconds: 14400 service-unavailable-timeout-in-seconds: 14400 output-artifact-directory: signed-windows-installer @@ -500,118 +323,247 @@ jobs: if: ${{ runner.os == 'Windows' }} shell: pwsh run: | - $signedDir = "signed-windows-installer" - $bundleDir = "target/${{ matrix.settings.target }}/release/bundle/nsis" - - if (-not (Test-Path $signedDir)) { - throw "Signed artifact directory '$signedDir' not found." - } - - $executables = Get-ChildItem -Path $signedDir -Filter *.exe -Recurse - if (-not $executables) { - throw "No signed executables found in '$signedDir'." - } - - Write-Host "Copying signed executables to: $bundleDir" - Copy-Item -Path (Join-Path $signedDir '*.exe') -Destination $bundleDir -Force - - Write-Host "Files in bundle directory after signing:" - Get-ChildItem -Path $bundleDir -Filter *.exe | ForEach-Object { Write-Host " - $($_.Name)" } - - - name: Re-sign Windows installer for Tauri updater + $signed = Get-ChildItem signed-windows-installer -Filter *.exe -Recurse | Select-Object -First 1 + if (-not $signed) { throw "No signed installer returned by SignPath" } + $destination = Get-ChildItem apps/desktop/dist-electron -Filter *.exe | Select-Object -First 1 + Copy-Item $signed.FullName $destination.FullName -Force + + # SignPath mutates the installer after electron-builder has produced the + # updater manifest. Recalculate the full-download metadata and remove the + # now-stale differential blockmap before publishing the signed artifact. + - name: Refresh Windows updater metadata if: ${{ runner.os == 'Windows' }} - shell: bash - working-directory: apps/desktop + shell: pwsh run: | - BUNDLE_DIR="../../target/${{ matrix.settings.target }}/release/bundle/nsis" - for exe in "$BUNDLE_DIR"/*.exe; do - echo "Re-signing $(basename "$exe") for Tauri updater..." - rm -f "${exe}.sig" - pnpm tauri signer sign -k "$TAURI_SIGNING_PRIVATE_KEY" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$exe" - done - echo "Signature files after re-signing:" - ls -la "$BUNDLE_DIR"/*.sig - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + $dist = Resolve-Path "apps/desktop/dist-electron" + $installer = Get-ChildItem $dist -Filter *.exe | Select-Object -First 1 + if (-not $installer) { throw "No signed installer found in $dist" } + + $sha512 = [Convert]::ToBase64String( + [Security.Cryptography.SHA512]::HashData( + [IO.File]::ReadAllBytes($installer.FullName) + ) + ) + $size = $installer.Length + $manifests = Get-ChildItem $dist -Filter *.yml + if (-not $manifests) { throw "No Electron updater manifest found in $dist" } + + foreach ($manifest in $manifests) { + $contents = Get-Content $manifest.FullName -Raw + $contents = $contents -replace '(?m)^(\s*)sha512:.*$', "`$1sha512: $sha512" + $contents = $contents -replace '(?m)^(\s*)size:.*$', "`$1size: $size" + $contents = $contents -replace '(?m)^\s*blockMapSize:.*\r?\n?', '' + [IO.File]::WriteAllText($manifest.FullName, $contents) + } - - name: Upload assets - if: ${{ runner.os != 'Linux' }} - uses: crabnebula-dev/cloud-release@v0 - with: - working-directory: apps/desktop - command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --framework tauri ${{ env.CN_CHANNEL_FLAG }} - api-key: ${{ secrets.CN_API_KEY }} - env: - TAURI_BUNDLE_PATH: ../.. + Get-ChildItem $dist -Filter *.blockmap | Remove-Item -Force - - name: Upload Linux deb asset - if: ${{ runner.os == 'Linux' }} - uses: crabnebula-dev/cloud-release@v0 + - name: Stage release assets + uses: actions/upload-artifact@v4 with: - working-directory: apps/desktop - command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ inputs.version }}_amd64.deb" --signature "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ inputs.version }}_amd64.deb.sig" --public-platform deb-x86_64 --update-platform linux-x86_64-deb ${{ env.CN_CHANNEL_FLAG }} - api-key: ${{ secrets.CN_API_KEY }} + name: release-assets-${{ matrix.settings.target }} + if-no-files-found: error + retention-days: 1 + path: | + apps/desktop/dist-electron/*.dmg + apps/desktop/dist-electron/*.zip + apps/desktop/dist-electron/*.exe + apps/desktop/dist-electron/*.deb + apps/desktop/dist-electron/*.yml - uses: matbour/setup-sentry-cli@8ef22a4ff03bcd1ebbcaa3a36a81482ca8e3872e - - name: Upload debug symbols to Sentry - if: ${{ runner.os == 'macOS' }} + - name: Upload Rust debug symbols to Sentry + if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }} shell: bash env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: | - sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} target/Cap.dSYM - - - name: Upload debug symbols to Sentry - if: ${{ runner.os == 'Windows' }} - shell: bash - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: | - sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} target/${{ matrix.settings.target }}/release/cap_desktop.pdb + if [[ "$RUNNER_OS" == "macOS" ]]; then + dsymutil "target/${{ matrix.settings.target }}/release/cap-desktop" -o target/cap-desktop.dSYM + sentry-cli debug-files upload -o "$SENTRY_ORG" -p "$SENTRY_PROJECT" target/cap-desktop.dSYM + else + sentry-cli debug-files upload -o "$SENTRY_ORG" -p "$SENTRY_PROJECT" "target/${{ matrix.settings.target }}/release/cap_desktop.pdb" + fi done: needs: [draft, build] runs-on: ubuntu-latest permissions: contents: write + actions: read id-token: write - env: - CN_CHANNEL_FLAG: ${{ inputs.channel == 'nightly' && '--channel nightly' || '' }} steps: - - name: Publish CN release - if: ${{ inputs.auto_publish }} - uses: crabnebula-dev/cloud-release@v0 + - uses: actions/checkout@v4 with: - command: release publish ${{ env.CN_APPLICATION }} "${{ inputs.version }}" ${{ env.CN_CHANNEL_FLAG }} - api-key: ${{ secrets.CN_API_KEY }} + ref: ${{ inputs.sha }} - - name: Send Discord notification - if: ${{ inputs.channel == 'stable' && inputs.interactionId != '' }} + - uses: ./.github/actions/setup-js + + - name: Download staged platform assets + uses: actions/download-artifact@v4 + with: + pattern: release-assets-* + path: staged-release-assets + + - name: Assemble release assets + run: node scripts/prepare-electron-release-assets.mjs staged-release-assets release-assets + + - name: Upload complete staging release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.draft.outputs.staging_tag }} + draft: true + prerelease: ${{ inputs.channel == 'nightly' }} + fail_on_unmatched_files: true + files: release-assets/* + + - name: Promote complete staging release + id: promote uses: actions/github-script@v7 + env: + AUTO_PUBLISH: ${{ inputs.auto_publish }} + FINAL_TAG: ${{ needs.draft.outputs.tag_name }} + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_SHA: ${{ inputs.sha }} + RELEASE_VERSION: ${{ inputs.version }} + STAGING_RELEASE_ID: ${{ needs.draft.outputs.staging_release_id }} + STAGING_TAG: ${{ needs.draft.outputs.staging_tag }} with: script: | - async function main() { - const token = await core.getIDToken("cap-discord-bot"); - const cnReleaseId = JSON.parse(`${{ needs.draft.outputs.cn_release_stdout }}`).id; - - const resp = await fetch("https://cap-discord-bot.brendonovich.workers.dev/github-workflow", { - method: "POST", - body: JSON.stringify({ - type: "release-done", - interactionId: "${{ inputs.interactionId }}", - version: "${{ inputs.version }}", - releaseUrl: "${{ needs.draft.outputs.gh_release_url }}", - cnReleaseId - }), - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - } + const path = require("node:path"); + const releaseId = Number(process.env.STAGING_RELEASE_ID); + const requireUnpublishedStagingRelease = async () => { + const release = (await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + })).data; + if (!release.draft || release.tag_name !== process.env.STAGING_TAG) { + throw new Error( + `Staging release '${process.env.STAGING_TAG}' is no longer an unpublished draft`, + ); + } + return release; + }; + await requireUnpublishedStagingRelease(); + + const globber = await glob.create("release-assets/*"); + const expectedAssets = []; + for await (const file of globber.globGenerator()) { + expectedAssets.push(path.basename(file)); + } + expectedAssets.sort(); + const uploadedAssetRecords = await github.paginate( + github.rest.repos.listReleaseAssets, + { + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + per_page: 100, + }, + ); + const incompleteAsset = uploadedAssetRecords.find( + (asset) => asset.state !== "uploaded" || asset.size <= 0, + ); + if (incompleteAsset) { + throw new Error( + `Staging asset '${incompleteAsset.name}' is not completely uploaded`, + ); + } + const uploadedAssets = uploadedAssetRecords + .map((asset) => asset.name) + .sort(); + if (JSON.stringify(uploadedAssets) !== JSON.stringify(expectedAssets)) { + throw new Error( + `Staging release assets do not match the assembled set: expected ${expectedAssets.join(", ")}; got ${uploadedAssets.join(", ")}`, + ); + } + + const existingRelease = (await github.paginate( + github.rest.repos.listReleases, + { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }, + )).find((release) => release.tag_name === process.env.FINAL_TAG); + if (existingRelease) { + throw new Error( + `Final release '${process.env.FINAL_TAG}' already exists: ${existingRelease.html_url}`, + ); + } + + try { + const existingTag = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.FINAL_TAG}`, }); + if (existingTag.data.object.sha !== process.env.RELEASE_SHA) { + throw new Error( + `Final tag '${process.env.FINAL_TAG}' points to ${existingTag.data.object.sha}; expected ${process.env.RELEASE_SHA}`, + ); + } + } catch (error) { + if (error.status !== 404) throw error; + } - if(resp.status !== 200) throw new Error(await resp.text()); + // Keep this guard adjacent to the mutation. The validations above may + // take long enough for an operator to publish the staging release. + await requireUnpublishedStagingRelease(); + const release = (await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + tag_name: process.env.FINAL_TAG, + target_commitish: process.env.RELEASE_SHA, + name: process.env.RELEASE_VERSION, + draft: process.env.AUTO_PUBLISH !== "true", + prerelease: process.env.RELEASE_CHANNEL === "nightly", + })).data; + const finalTag = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.FINAL_TAG}`, + }); + if (finalTag.data.object.sha !== process.env.RELEASE_SHA) { + throw new Error( + `Promoted tag '${process.env.FINAL_TAG}' points to ${finalTag.data.object.sha}; expected ${process.env.RELEASE_SHA}`, + ); } + core.setOutput("release_url", release.html_url); - main(); + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.STAGING_TAG}`, + }); + } catch (error) { + core.warning( + `Final release is complete, but staging tag cleanup failed: ${error.message}`, + ); + } + + - name: Notify Discord that the stable draft is ready + if: ${{ inputs.channel == 'stable' && inputs.interactionId != '' }} + uses: actions/github-script@v7 + env: + INTERACTION_ID: ${{ inputs.interactionId }} + RELEASE_URL: ${{ steps.promote.outputs.release_url }} + RELEASE_VERSION: ${{ inputs.version }} + with: + script: | + const token = await core.getIDToken("cap-discord-bot"); + const response = await fetch("https://cap-discord-bot.brendonovich.workers.dev/github-workflow", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + type: "release-done", + interactionId: process.env.INTERACTION_ID, + version: process.env.RELEASE_VERSION, + releaseUrl: process.env.RELEASE_URL, + }), + }); + if (!response.ok) throw new Error(await response.text()); diff --git a/.github/workflows/promote-nightly.yml b/.github/workflows/promote-nightly.yml index 99e1674eecb..118138180b7 100644 --- a/.github/workflows/promote-nightly.yml +++ b/.github/workflows/promote-nightly.yml @@ -7,7 +7,7 @@ name: "promote-nightly" # GitHub release, draft CrabNebula release on the default channel, published # manually as usual. # -# After promoting, bump apps/desktop/src-tauri/Cargo.toml on main past the +# After promoting, bump apps/desktop/package.json on main past the # promoted version so future nightlies and stable releases version correctly. on: diff --git a/.github/workflows/publish-nightly.yml b/.github/workflows/publish-nightly.yml index e877af3d027..1e627e59e16 100644 --- a/.github/workflows/publish-nightly.yml +++ b/.github/workflows/publish-nightly.yml @@ -55,11 +55,9 @@ jobs: - name: Read base version if: ${{ steps.guard.outputs.skip != 'true' }} - uses: SebRollen/toml-action@v1.0.2 id: read_version - with: - file: apps/desktop/src-tauri/Cargo.toml - field: "package.version" + shell: bash + run: echo "value=$(node -p "require('./apps/desktop/package.json').version")" >> "$GITHUB_OUTPUT" - name: Compute nightly version if: ${{ steps.guard.outputs.skip != 'true' }} @@ -67,7 +65,7 @@ jobs: shell: bash run: | BASE="${{ steps.read_version.outputs.value }}" - # If Cargo.toml still holds an already-tagged version, nightlies + # If package.json still holds an already-tagged version, nightlies # belong to the next patch release. if git rev-parse -q --verify "refs/tags/cap-v$BASE" >/dev/null; then BASE=$(echo "$BASE" | awk -F. '{printf "%d.%d.%d", $1, $2, $3 + 1}') @@ -90,20 +88,3 @@ jobs: sha: ${{ github.sha }} auto_publish: true secrets: inherit - - tag: - needs: [prepare, release] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Tag published nightly - uses: actions/github-script@v7 - with: - script: | - await github.rest.git.createRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: "refs/tags/cap-nightly-v${{ needs.prepare.outputs.version }}", - sha: context.sha, - }); diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 77054493473..7cfb182d164 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,6 @@ name: "publish" -# Stable release entrypoint. Reads the version from Cargo.toml (bump it before +# Stable release entrypoint. Reads the version from package.json (bump it before # dispatching) and hands off to the reusable desktop-release workflow. The # CrabNebula release stays a draft; publishing remains the manual step it has # always been (Discord bot / CN dashboard). @@ -22,11 +22,9 @@ jobs: - uses: actions/checkout@v4 - name: Read version number - uses: SebRollen/toml-action@v1.0.2 id: read_version - with: - file: apps/desktop/src-tauri/Cargo.toml - field: "package.version" + shell: bash + run: echo "value=$(node -p "require('./apps/desktop/package.json').version")" >> "$GITHUB_OUTPUT" release: needs: version diff --git a/Cargo.lock b/Cargo.lock index 88ca9aca6df..61b90f95dff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,7 +55,7 @@ dependencies = [ "bitflags 2.13.1", "bytes", "bytestring", - "derive_more 2.0.1", + "derive_more", "encoding_rs", "foldhash", "futures-core", @@ -153,7 +153,7 @@ dependencies = [ "bytes", "bytestring", "cfg-if", - "derive_more 2.0.1", + "derive_more", "encoding_rs", "foldhash", "futures-core", @@ -234,21 +234,6 @@ dependencies = [ "equator", ] -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -403,30 +388,6 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "image 0.25.8", - "log", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.1", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "wl-clipboard-rs", - "x11rb", -] [[package]] name = "arg_enum_proc_macro" @@ -476,14 +437,10 @@ dependencies = [ "futures-channel", "futures-util", "rand 0.9.2", - "raw-window-handle", "serde", "serde_repr", "tokio", "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", "zbus", ] @@ -640,29 +597,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -1033,27 +967,6 @@ dependencies = [ "piper", ] -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "build-time" version = "0.1.3" @@ -1116,9 +1029,6 @@ name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -dependencies = [ - "serde", -] [[package]] name = "bytestring" @@ -1129,31 +1039,6 @@ dependencies = [ "bytes", ] -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.13.1", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps 6.2.2", -] - [[package]] name = "calloop" version = "0.13.0" @@ -1180,15 +1065,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "camino" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" -dependencies = [ - "serde_core", -] - [[package]] name = "cap" version = "0.1.0" @@ -1450,6 +1326,7 @@ dependencies = [ "cap-camera-effects", "cap-camera-ffmpeg", "cap-cli-install", + "cap-desktop-runtime", "cap-editor", "cap-enc-ffmpeg", "cap-export", @@ -1475,16 +1352,14 @@ dependencies = [ "flume", "futures", "futures-intrusive", - "global-hotkey", "image 0.25.8", "kameo", "keyed_priority_queue", "lazy_static", - "libappindicator", "log", "lz4_flex", "md5", - "nix 0.29.0", + "nix", "objc", "objc2 0.6.2", "objc2-app-kit 0.3.1", @@ -1509,31 +1384,8 @@ dependencies = [ "serde", "serde_json", "specta", - "specta-typescript", "swift-rs", "sysinfo 0.35.2", - "tauri", - "tauri-build", - "tauri-nspanel", - "tauri-plugin-clipboard-manager", - "tauri-plugin-deep-link", - "tauri-plugin-dialog", - "tauri-plugin-fs", - "tauri-plugin-global-shortcut", - "tauri-plugin-http", - "tauri-plugin-notification", - "tauri-plugin-oauth", - "tauri-plugin-opener", - "tauri-plugin-os", - "tauri-plugin-positioner", - "tauri-plugin-process", - "tauri-plugin-sentry", - "tauri-plugin-shell", - "tauri-plugin-single-instance", - "tauri-plugin-store", - "tauri-plugin-updater", - "tauri-plugin-window-state", - "tauri-specta", "tempfile", "thiserror 1.0.69", "tokio", @@ -1544,17 +1396,40 @@ dependencies = [ "tracing-futures", "tracing-opentelemetry", "tracing-subscriber", + "url", "uuid", - "webview2-com", "wgpu", "whisper-rs", "windows 0.60.0", - "windows-core 0.61.2", "windows-sys 0.59.0", "winreg", "workspace-hack", ] +[[package]] +name = "cap-desktop-runtime" +version = "0.1.0" +dependencies = [ + "cap-desktop-runtime-macros", + "dirs 6.0.0", + "futures", + "inventory", + "serde", + "serde_json", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "cap-desktop-runtime-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "cap-editor" version = "0.1.0" @@ -2008,7 +1883,7 @@ dependencies = [ "flume", "futures", "libc", - "nix 0.29.0", + "nix", "serde", "serde_json", "tempfile", @@ -2036,39 +1911,6 @@ dependencies = [ "workspace-hack", ] -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.16", -] - -[[package]] -name = "cargo_toml" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml 0.9.7", -] - [[package]] name = "castaway" version = "0.2.4" @@ -2114,17 +1956,6 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - [[package]] name = "cfg-expr" version = "0.15.8" @@ -2151,12 +1982,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.1" @@ -2259,7 +2084,7 @@ version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.106", @@ -2419,32 +2244,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.16", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" version = "0.18.1" @@ -2718,30 +2517,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crash-context" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" -dependencies = [ - "cfg-if", - "libc", - "mach2", -] - -[[package]] -name = "crash-handler" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3" -dependencies = [ - "cfg-if", - "crash-context", - "libc", - "mach2", - "parking_lot", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -2826,43 +2601,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.106", -] - -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn 2.0.106", -] - [[package]] name = "ctor" version = "0.10.1" @@ -3004,17 +2742,6 @@ dependencies = [ "serde", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "derive_builder" version = "0.20.2" @@ -3046,19 +2773,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.106", -] - [[package]] name = "derive_more" version = "2.0.1" @@ -3201,7 +2915,6 @@ checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.13.1", "block2 0.6.1", - "libc", "objc2 0.6.2", ] @@ -3225,38 +2938,6 @@ dependencies = [ "libloading 0.8.8", ] -[[package]] -name = "dlopen2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - [[package]] name = "document-features" version = "0.2.11" @@ -3301,9 +2982,6 @@ name = "dpi" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -dependencies = [ - "serde", -] [[package]] name = "drm" @@ -3345,29 +3023,14 @@ dependencies = [ ] [[package]] -name = "dtoa" -version = "1.0.10" +name = "dtor" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" [[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dtor" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" - -[[package]] -name = "dunce" -version = "1.0.5" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" @@ -3383,26 +3046,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "embed-resource" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.9.7", - "vswhom", - "winreg", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - [[package]] name = "encode_unicode" version = "1.0.0" @@ -3471,17 +3114,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -3637,16 +3269,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - [[package]] name = "filetime" version = "0.2.26" @@ -3677,12 +3299,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.1.2" @@ -3833,16 +3449,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.31" @@ -3974,105 +3580,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps 6.2.2", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps 6.2.2", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps 6.2.2", - "x11", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -4093,17 +3600,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.16" @@ -4186,38 +3682,6 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", - "winapi", -] - [[package]] name = "gl_generator" version = "0.14.0" @@ -4229,77 +3693,12 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.13.1", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps 6.2.2", -] - [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "global-hotkey" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "once_cell", - "serde", - "thiserror 2.0.16", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - [[package]] name = "glow" version = "0.16.0" @@ -4334,28 +3733,6 @@ dependencies = [ "wgpu", ] -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps 6.2.2", -] - -[[package]] -name = "goblin" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" -dependencies = [ - "log", - "plain", - "scroll", -] - [[package]] name = "gpu-alloc" version = "0.6.0" @@ -4407,58 +3784,6 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps 6.2.2", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "guardian" version = "1.3.0" @@ -4501,12 +3826,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -4518,12 +3837,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -4598,18 +3911,6 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - [[package]] name = "http" version = "0.2.12" @@ -4655,12 +3956,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - [[package]] name = "httparse" version = "1.10.1" @@ -4779,16 +4074,6 @@ dependencies = [ "cc", ] -[[package]] -name = "ico" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" -dependencies = [ - "byteorder", - "png 0.17.16", -] - [[package]] name = "icu_collections" version = "2.0.0" @@ -5029,8 +4314,6 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.15.5", - "serde", - "serde_core", ] [[package]] @@ -5047,19 +4330,10 @@ dependencies = [ ] [[package]] -name = "infer" -version = "0.19.0" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding", "generic-array", @@ -5199,29 +4473,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", -] - [[package]] name = "jni" version = "0.21.1" @@ -5322,28 +4573,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json-patch" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "jsonptr" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "kameo" version = "0.17.2" @@ -5366,24 +4595,13 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3f384b32bf6426ae93a8b37da62c85073b676a31a82a86d608ad86453878de0" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.106", "uuid", ] -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.13.1", - "serde", - "unicode-segmentation", -] - [[package]] name = "keyed_priority_queue" version = "0.4.2" @@ -5431,18 +4649,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever", - "indexmap 2.11.4", - "selectors", -] - [[package]] name = "kurbo" version = "0.11.3" @@ -5504,30 +4710,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading 0.7.4", - "once_cell", -] - [[package]] name = "libc" version = "0.2.175" @@ -5544,16 +4726,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.8" @@ -5752,24 +4924,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "mac-notification-sys" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" -dependencies = [ - "cc", - "objc2 0.6.2", - "objc2-foundation 0.3.1", - "time", -] - [[package]] name = "mach2" version = "0.4.3" @@ -5814,31 +4968,6 @@ dependencies = [ "libc", ] -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "matchers" version = "0.2.0" @@ -5848,12 +4977,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "matchit" version = "0.7.3" @@ -5956,87 +5079,12 @@ dependencies = [ "unicase", ] -[[package]] -name = "minidump-common" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" -dependencies = [ - "bitflags 2.13.1", - "debugid", - "num-derive", - "num-traits", - "range-map", - "scroll", - "smart-default", -] - -[[package]] -name = "minidump-writer" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" -dependencies = [ - "bitflags 2.13.1", - "byteorder", - "cfg-if", - "crash-context", - "goblin", - "libc", - "log", - "mach2", - "memmap2", - "memoffset", - "minidump-common", - "nix 0.28.0", - "procfs-core", - "scroll", - "tempfile", - "thiserror 1.0.69", -] - -[[package]] -name = "minidumper" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4ebc9d1f8847ec1d078f78b35ed598e0ebefa1f242d5f83cd8d7f03960a7d1" -dependencies = [ - "cfg-if", - "crash-context", - "libc", - "log", - "minidump-writer", - "parking_lot", - "polling", - "scroll", - "thiserror 1.0.69", - "uds", -] - -[[package]] -name = "minidumper-child" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4f23f835dbe67e44ddf884d3802ff549ca5948bf60e9fd70e9a13c96324d1" -dependencies = [ - "crash-handler", - "minidumper", - "thiserror 1.0.69", - "uuid", -] - [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "minisign-verify" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -6123,27 +5171,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "muda" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-core-foundation", - "objc2-foundation 0.3.1", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.16", - "windows-sys 0.60.2", -] - [[package]] name = "naga" version = "25.0.1" @@ -6153,7 +5180,7 @@ dependencies = [ "arrayvec", "bit-set", "bitflags 2.13.1", - "cfg_aliases 0.2.1", + "cfg_aliases", "codespan-reporting", "half", "hashbrown 0.15.5", @@ -6299,18 +5326,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - [[package]] name = "nix" version = "0.29.0" @@ -6319,16 +5334,10 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -6354,20 +5363,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" -[[package]] -name = "notify-rust" -version = "4.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus", -] - [[package]] name = "ntapi" version = "0.4.2" @@ -6503,7 +5498,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.106", @@ -6525,17 +5520,6 @@ dependencies = [ "objc_exception", ] -[[package]] -name = "objc-foundation" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" -dependencies = [ - "block", - "objc", - "objc_id", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -6559,7 +5543,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" dependencies = [ "objc2-encode", - "objc2-exception-helper", ] [[package]] @@ -6799,15 +5782,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - [[package]] name = "objc2-foundation" version = "0.2.2" @@ -6855,16 +5829,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" -dependencies = [ - "objc2 0.6.2", - "objc2-core-foundation", -] - [[package]] name = "objc2-link-presentation" version = "0.2.2" @@ -6889,18 +5853,6 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-osa-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -6925,17 +5877,6 @@ dependencies = [ "objc2-foundation 0.3.1", ] -[[package]] -name = "objc2-security" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.2", - "objc2-core-foundation", -] - [[package]] name = "objc2-symbols" version = "0.2.2" @@ -6967,18 +5908,6 @@ dependencies = [ "objc2-user-notifications", ] -[[package]] -name = "objc2-ui-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.2", - "objc2-core-foundation", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" @@ -7004,39 +5933,14 @@ dependencies = [ ] [[package]] -name = "objc2-web-kit" -version = "0.3.1" +name = "objc_exception" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" -dependencies = [ - "bitflags 2.13.1", - "block2 0.6.1", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-core-foundation", - "objc2-foundation 0.3.1", - "objc2-javascript-core", - "objc2-security", -] - -[[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" dependencies = [ "cc", ] -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - [[package]] name = "object" version = "0.36.7" @@ -7118,7 +6022,6 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ - "dunce", "is-wsl", "libc", ] @@ -7281,16 +6184,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -7338,30 +6231,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "os_pipe" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "osakit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" -dependencies = [ - "objc2 0.6.2", - "objc2-foundation 0.3.1", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.16", -] - [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -7371,31 +6240,6 @@ dependencies = [ "ttf-parser 0.25.1", ] -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", -] - [[package]] name = "parakeet-rs" version = "0.3.4" @@ -7485,132 +6329,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap 2.11.4", -] - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - [[package]] name = "phf_shared" version = "0.10.0" @@ -7709,12 +6427,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" version = "1.8.0" @@ -7813,12 +6525,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "presser" version = "0.3.1" @@ -7854,26 +6560,6 @@ dependencies = [ "num-integer", ] -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -7883,36 +6569,6 @@ dependencies = [ "toml_edit 0.23.10+spec-1.0.0", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.101" @@ -7922,16 +6578,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "procfs-core" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" -dependencies = [ - "bitflags 2.13.1", - "hex", -] - [[package]] name = "profiling" version = "1.0.17" @@ -8039,7 +6685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -8079,7 +6725,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "once_cell", "socket2 0.6.0", @@ -8102,20 +6748,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - [[package]] name = "rand" version = "0.8.5" @@ -8137,16 +6769,6 @@ dependencies = [ "rand_core 0.9.3", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -8167,15 +6789,6 @@ dependencies = [ "rand_core 0.9.3", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -8194,39 +6807,12 @@ dependencies = [ "getrandom 0.3.3", ] -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "range-alloc" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" -[[package]] -name = "range-map" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a5a2d6c7039059af621472a4389be1215a816df61aa4d531cfe85264aee95f" -dependencies = [ - "num-traits", -] - [[package]] name = "rangemap" version = "1.6.0" @@ -8579,35 +7165,10 @@ dependencies = [ ] [[package]] -name = "rfd" -version = "0.15.4" +name = "rgb" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" -dependencies = [ - "ashpd", - "block2 0.6.1", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-core-foundation", - "objc2-foundation 0.3.1", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" dependencies = [ "bytemuck", ] @@ -8713,16 +7274,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - [[package]] name = "rustc-demangle" version = "0.1.26" @@ -9000,18 +7551,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "1.0.4" @@ -9078,26 +7617,6 @@ dependencies = [ "objc", ] -[[package]] -name = "scroll" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" -dependencies = [ - "scroll_derive", -] - -[[package]] -name = "scroll_derive" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "sctk-adwaita" version = "0.10.1" @@ -9166,24 +7685,6 @@ dependencies = [ "libc", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", -] - [[package]] name = "self_cell" version = "1.2.0" @@ -9312,17 +7813,6 @@ dependencies = [ "sentry-core", ] -[[package]] -name = "sentry-rust-minidump" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63964525bf74b16233dbcfb307e11485ebd8ff8f87f6ae212b07ca7937cd2db1" -dependencies = [ - "minidumper-child", - "sentry", - "thiserror 2.0.16", -] - [[package]] name = "sentry-tracing" version = "0.42.0" @@ -9363,18 +7853,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -9471,70 +7949,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.11.4", - "schemars 0.9.0", - "schemars 1.0.4", - "serde", - "serde_derive", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "serialize-to-javascript" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] - -[[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "sha1" version = "0.10.6" @@ -9566,34 +7980,12 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shared_child" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" -dependencies = [ - "libc", - "sigchld", - "windows-sys 0.60.2", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - [[package]] name = "signal-hook" version = "0.3.18" @@ -9685,7 +8077,7 @@ dependencies = [ "bindgen 0.71.1", "cc", "flate2", - "heck 0.5.0", + "heck", "lazy_static", "regex", "serde_json", @@ -9735,17 +8127,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smart-default" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "smithay-client-toolkit" version = "0.19.2" @@ -9819,7 +8200,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ "as-raw-xcb-connection", "bytemuck", - "cfg_aliases 0.2.1", + "cfg_aliases", "core-graphics 0.24.0", "drm", "fastrand", @@ -9843,32 +8224,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", -] - [[package]] name = "specta" version = "2.0.0-rc.20" @@ -9895,27 +8250,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "specta-serde" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12260cbb21abb2e83a0375b1521867910e3aed8a7afa782206150ce552cd2e5a" -dependencies = [ - "specta", - "thiserror 1.0.69", -] - -[[package]] -name = "specta-typescript" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4472229365ceb6395487e3a60d921ad8e21f9ad06eaecc396f098902c9adc" -dependencies = [ - "specta", - "specta-serde", - "thiserror 1.0.69", -] - [[package]] name = "spin" version = "0.9.8" @@ -9973,31 +8307,6 @@ dependencies = [ "float-cmp", ] -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "strsim" version = "0.11.1" @@ -10028,7 +8337,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", @@ -10041,7 +8350,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.106", @@ -10247,7 +8556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ "cfg-expr 0.15.8", - "heck 0.5.0", + "heck", "pkg-config", "toml 0.8.2", "version-compare", @@ -10260,61 +8569,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ "cfg-expr 0.20.8", - "heck 0.5.0", + "heck", "pkg-config", "toml 1.1.0+spec-1.1.0", "version-compare", ] -[[package]] -name = "tao" -version = "0.34.3" -dependencies = [ - "bitflags 2.13.1", - "block2 0.6.1", - "core-foundation 0.10.1", - "core-graphics 0.24.0", - "crossbeam-channel", - "dispatch", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni 0.21.1", - "lazy_static", - "libc", - "log", - "ndk 0.9.0", - "ndk-context", - "ndk-sys 0.6.0+11769913", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-foundation 0.3.1", - "once_cell", - "parking_lot", - "raw-window-handle", - "scopeguard", - "tao-macros", - "unicode-segmentation", - "url", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "tar" version = "0.4.44" @@ -10329,634 +8589,14 @@ dependencies = [ [[package]] name = "target-lexicon" version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - -[[package]] -name = "tauri" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "dirs 6.0.0", - "dunce", - "embed_plist", - "getrandom 0.3.3", - "glob", - "gtk", - "heck 0.5.0", - "http 1.3.1", - "http-range", - "image 0.25.8", - "jni 0.21.1", - "libc", - "log", - "mime", - "muda", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-foundation 0.3.1", - "objc2-ui-kit 0.3.1", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "specta", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.16", - "tokio", - "tray-icon", - "url", - "urlpattern", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows 0.61.3", -] - -[[package]] -name = "tauri-build" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" -dependencies = [ - "anyhow", - "cargo_toml", - "dirs 6.0.0", - "glob", - "heck 0.5.0", - "json-patch", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "tauri-utils", - "tauri-winres", - "toml 0.9.7", - "walkdir", -] - -[[package]] -name = "tauri-codegen" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" -dependencies = [ - "base64 0.22.1", - "brotli", - "ico", - "json-patch", - "plist", - "png 0.17.16", - "proc-macro2", - "quote", - "semver", - "serde", - "serde_json", - "sha2", - "syn 2.0.106", - "tauri-utils", - "thiserror 2.0.16", - "time", - "url", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-macros" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.106", - "tauri-codegen", - "tauri-utils", -] - -[[package]] -name = "tauri-nspanel" -version = "2.0.1" -source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a" -dependencies = [ - "bitflags 2.13.1", - "block", - "cocoa", - "core-foundation 0.10.1", - "core-graphics 0.25.0", - "objc", - "objc-foundation", - "objc_id", - "tauri", -] - -[[package]] -name = "tauri-plugin" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "toml 0.9.7", - "walkdir", -] - -[[package]] -name = "tauri-plugin-clipboard-manager" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adddd9e9275b20e77af3061d100a25a884cced3c4c9ef680bd94dd0f7e26c1ca" -dependencies = [ - "arboard", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", -] - -[[package]] -name = "tauri-plugin-deep-link" -version = "2.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd67112fb1131834c2a7398ffcba520dbbf62c17de3b10329acd1a3554b1a9bb" -dependencies = [ - "dunce", - "plist", - "rust-ini", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.16", - "tracing", - "url", - "windows-registry", - "windows-result 0.3.4", -] - -[[package]] -name = "tauri-plugin-dialog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e" -dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.16", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7" -dependencies = [ - "anyhow", - "dunce", - "glob", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.16", - "toml 0.9.7", - "url", -] - -[[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df9f0f7bf2fe768b85fee4951c2505a35b72c44df1f6403e74e110bc13c5f58" -dependencies = [ - "global-hotkey", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", -] - -[[package]] -name = "tauri-plugin-http" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938a3d7051c9a82b431e3a0f3468f85715b3442b3c3a3913095e9fa509e2652c" -dependencies = [ - "bytes", - "cookie_store", - "data-url", - "http 1.3.1", - "regex", - "reqwest", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.16", - "tokio", - "url", - "urlpattern", -] - -[[package]] -name = "tauri-plugin-notification" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fbc86b929b5376ab84b25c060f966d146b2fbd59b6af8264027b343c82c219" -dependencies = [ - "log", - "notify-rust", - "rand 0.9.2", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", - "time", - "url", -] - -[[package]] -name = "tauri-plugin-oauth" -version = "2.0.0" -source = "git+https://github.com/FabianLars/tauri-plugin-oauth?branch=v2#f941c99c3ed528a55db7fe05c931480a8255041d" -dependencies = [ - "httparse", - "log", - "serde", - "tauri", - "tauri-plugin", - "thiserror 1.0.69", - "url", -] - -[[package]] -name = "tauri-plugin-opener" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786156aa8e89e03d271fbd3fe642207da8e65f3c961baa9e2930f332bf80a1f5" -dependencies = [ - "dunce", - "glob", - "objc2-app-kit 0.3.1", - "objc2-foundation 0.3.1", - "open", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", - "url", - "windows 0.61.3", - "zbus", -] - -[[package]] -name = "tauri-plugin-os" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba" -dependencies = [ - "gethostname", - "log", - "os_info", - "serde", - "serde_json", - "serialize-to-javascript", - "sys-locale", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", -] - -[[package]] -name = "tauri-plugin-positioner" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a01e373ea3f3f5f46d40f434ba13bd12fa4833aabab50dfc09f6362bad27c95" -dependencies = [ - "log", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", -] - -[[package]] -name = "tauri-plugin-process" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" -dependencies = [ - "tauri", - "tauri-plugin", -] - -[[package]] -name = "tauri-plugin-sentry" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7432b519b6d2d027082a940783c61ba9b684b38d8df7558cd5f924d87009b295" -dependencies = [ - "base64 0.22.1", - "schemars 0.8.22", - "sentry", - "sentry-rust-minidump", - "serde", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", -] - -[[package]] -name = "tauri-plugin-shell" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54777d0c0d8add34eea3ced84378619ef5b97996bd967d3038c668feefd21071" -dependencies = [ - "encoding_rs", - "log", - "open", - "os_pipe", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "shared_child", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", - "tokio", -] - -[[package]] -name = "tauri-plugin-single-instance" -version = "2.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb9cac815bf11c4a80fb498666bcdad66d65b89e3ae24669e47806febb76389c" -dependencies = [ - "serde", - "serde_json", - "tauri", - "tauri-plugin-deep-link", - "thiserror 2.0.16", - "tracing", - "windows-sys 0.60.2", - "zbus", -] - -[[package]] -name = "tauri-plugin-store" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85dd80d60a76ee2c2fdce09e9ef30877b239c2a6bb76e6d7d03708aa5f13a19" -dependencies = [ - "dunce", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", - "tokio", - "tracing", -] - -[[package]] -name = "tauri-plugin-updater" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" -dependencies = [ - "base64 0.22.1", - "dirs 6.0.0", - "flate2", - "futures-util", - "http 1.3.1", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.16", - "time", - "tokio", - "url", - "windows-sys 0.60.2", - "zip", -] - -[[package]] -name = "tauri-plugin-window-state" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e" -dependencies = [ - "bitflags 2.13.1", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.16", -] - -[[package]] -name = "tauri-runtime" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" -dependencies = [ - "cookie", - "dpi", - "gtk", - "http 1.3.1", - "jni 0.21.1", - "objc2 0.6.2", - "objc2-ui-kit 0.3.1", - "objc2-web-kit", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.16", - "url", - "webkit2gtk", - "webview2-com", - "windows 0.61.3", -] - -[[package]] -name = "tauri-runtime-wry" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" -dependencies = [ - "gtk", - "http 1.3.1", - "jni 0.21.1", - "log", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-foundation 0.3.1", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows 0.61.3", - "wry", -] - -[[package]] -name = "tauri-specta" -version = "2.0.0-rc.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d06336a2fa3ead0c8d08719e78cbc1fb73650845789605bd2ab908cbde72815" -dependencies = [ - "heck 0.5.0", - "serde", - "serde_json", - "specta", - "specta-typescript", - "tauri", - "tauri-specta-macros", - "thiserror 1.0.69", -] - -[[package]] -name = "tauri-specta-macros" -version = "2.0.0-rc.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "tauri-utils" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor 0.2.9", - "dunce", - "glob", - "html5ever", - "http 1.3.1", - "infer", - "json-patch", - "kuchikiki", - "log", - "memchr", - "phf 0.11.3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.16", - "toml 0.9.7", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" -dependencies = [ - "embed-resource", - "toml 0.9.7", -] +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] -name = "tauri-winrt-notification" -version = "0.7.2" +name = "target-lexicon" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" -dependencies = [ - "quick-xml 0.37.5", - "thiserror 2.0.16", - "windows 0.61.3", - "windows-version", -] +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -10971,17 +8611,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -11101,15 +8730,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tiny-skia" version = "0.11.4" @@ -11143,7 +8763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" dependencies = [ "as-raw-xcb-connection", - "ctor 0.10.1", + "ctor", "libloading 0.8.8", "pkg-config", "tracing", @@ -11308,21 +8928,6 @@ dependencies = [ "toml_edit 0.20.2", ] -[[package]] -name = "toml" -version = "0.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" -dependencies = [ - "indexmap 2.11.4", - "serde_core", - "serde_spanned 1.1.0", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.13", -] - [[package]] name = "toml" version = "1.1.0+spec-1.1.0" @@ -11365,17 +8970,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.11.4", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - [[package]] name = "toml_edit" version = "0.20.2" @@ -11610,40 +9204,6 @@ dependencies = [ "strength_reduce", ] -[[package]] -name = "tray-icon" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" -dependencies = [ - "crossbeam-channel", - "dirs 6.0.0", - "libappindicator", - "muda", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.1", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.16", - "windows-sys 0.59.0", -] - -[[package]] -name = "tree_magic_mini" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" -dependencies = [ - "memchr", - "nom 7.1.3", - "once_cell", - "petgraph", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -11695,27 +9255,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "uds" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c31f06fce836457fe3ef09a59f83fe8db95d270b11cd78f40a4666c4d1661" -dependencies = [ - "libc", -] - [[package]] name = "uds_windows" version = "1.1.0" @@ -11736,47 +9281,6 @@ dependencies = [ "libc", ] -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - [[package]] name = "unicase" version = "2.8.1" @@ -11937,18 +9441,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlpattern" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" -dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", -] - [[package]] name = "usvg" version = "0.45.1" @@ -12067,26 +9559,6 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -12106,12 +9578,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -12350,50 +9816,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webkit2gtk" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps 6.2.2", -] - [[package]] name = "webpki-root-certs" version = "1.0.2" @@ -12412,42 +9834,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webview2-com" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" -dependencies = [ - "thiserror 2.0.16", - "windows 0.61.3", - "windows-core 0.61.2", -] - [[package]] name = "weezl" version = "0.1.10" @@ -12462,7 +9848,7 @@ checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" dependencies = [ "arrayvec", "bitflags 2.13.1", - "cfg_aliases 0.2.1", + "cfg_aliases", "document-features", "hashbrown 0.15.5", "js-sys", @@ -12492,7 +9878,7 @@ dependencies = [ "bit-set", "bit-vec", "bitflags 2.13.1", - "cfg_aliases 0.2.1", + "cfg_aliases", "document-features", "hashbrown 0.15.5", "indexmap 2.11.4", @@ -12552,7 +9938,7 @@ dependencies = [ "block", "bytemuck", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "core-graphics-types 0.1.3", "glow", "glutin_wgl_sys", @@ -12672,21 +10058,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "window-vibrancy" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" -dependencies = [ - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-core-foundation", - "objc2-foundation 0.3.1", - "raw-window-handle", - "windows-sys 0.59.0", - "windows-version", -] - [[package]] name = "windows" version = "0.48.0" @@ -13145,15 +10516,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-version" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e061eb0a22b4a1d778ad70f7575ec7845490abb35b08fa320df7895882cacb" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-win" version = "3.0.0" @@ -13356,7 +10718,7 @@ dependencies = [ "block2 0.5.1", "bytemuck", "calloop", - "cfg_aliases 0.2.1", + "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", "core-graphics 0.23.2", @@ -13369,7 +10731,7 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", + "objc2-ui-kit", "orbclient", "percent-encoding", "pin-project", @@ -13438,25 +10800,6 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -[[package]] -name = "wl-clipboard-rs" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 0.38.44", - "tempfile", - "thiserror 2.0.16", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "workspace-hack" version = "0.1.0" @@ -13516,7 +10859,6 @@ dependencies = [ "specta-macros", "stable_deref_trait", "syn 2.0.106", - "tauri-utils", "thiserror 2.0.16", "time", "tokio", @@ -13532,51 +10874,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" -[[package]] -name = "wry" -version = "0.53.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90" -dependencies = [ - "base64 0.22.1", - "block2 0.6.1", - "cookie", - "crossbeam-channel", - "dirs 6.0.0", - "dpi", - "dunce", - "gdkx11", - "gtk", - "html5ever", - "http 1.3.1", - "javascriptcore-rs", - "jni 0.21.1", - "kuchikiki", - "libc", - "ndk 0.9.0", - "objc2 0.6.2", - "objc2-app-kit 0.3.1", - "objc2-core-foundation", - "objc2-foundation 0.3.1", - "objc2-ui-kit 0.3.1", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.16", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - [[package]] name = "x11" version = "2.21.0" @@ -13771,7 +11068,7 @@ version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.106", @@ -13877,18 +11174,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.11.4", - "memchr", -] - [[package]] name = "zlib-rs" version = "0.5.2" @@ -13940,7 +11225,7 @@ version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.106", diff --git a/Cargo.toml b/Cargo.toml index 91e45dbbdc9..3bf97911967 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ resolver = "2" members = [ "apps/cli", - "apps/desktop/src-tauri", + "apps/desktop/src-backend", "crates/*", "crates/workspace-hack", ] @@ -24,7 +24,6 @@ tokio = { version = "1.39.3", features = [ "rt-multi-thread", "time", ] } -tauri = { version = "2.5.0", features = ["specta"] } specta = { version = "=2.0.0-rc.20", features = [ "derive", "serde_json", @@ -117,7 +116,6 @@ cidre = { git = "https://github.com/CapSoftware/cidre", rev = "bf84b67079a8" } # https://github.com/gfx-rs/wgpu/pull/7550 # wgpu = { git = "https://github.com/gfx-rs/wgpu", rev = "cd41a6e32a6239b65d1cecbeccde6a43a100914a" } wgpu-hal = { path = "vendor/wgpu-hal" } -tao = { path = "vendor/tao" } # https://github.com/CapSoftware/reqwest/commit/9b5ecbd5210a9510fde766015cabb724c1e70d2e reqwest = { git = "https://github.com/CapSoftware/reqwest", rev = "9b5ecbd5210a9510fde766015cabb724c1e70d2e" } diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index 76add878f8d..f5d6d05d16b 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,2 +1,4 @@ node_modules -dist \ No newline at end of file +dist +.electron-dist +dist-electron diff --git a/apps/desktop/app.config.ts b/apps/desktop/app.config.ts index f7caac6cabc..cbddaa58318 100644 --- a/apps/desktop/app.config.ts +++ b/apps/desktop/app.config.ts @@ -34,22 +34,18 @@ export default defineConfig({ server: { preset: "static" }, // https://vitejs.dev/config vite: () => ({ - // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` - // 1. tauri expects a fixed port, fail if that port is not available server: { port: 3001, strictPort: true, watch: { - ignored: ["**/src-tauri/**"], + ignored: ["**/electron/**", "**/src-backend/**"], }, headers: { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", }, }, - // 3. to make use of `TAURI_DEBUG` and other env variables - // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand - envPrefix: ["VITE_", "TAURI_"], + envPrefix: ["VITE_", "CAP_"], assetsInclude: ["**/*.riv"], plugins: [ ...(enableSolidDevtools ? [devtools({ autoname: true })] : []), @@ -65,20 +61,7 @@ export default defineConfig({ "import.meta.vitest": "undefined", }, optimizeDeps: { - include: [ - "@tauri-apps/plugin-os", - "@tanstack/solid-query", - "@tauri-apps/api/webviewWindow", - "@tauri-apps/plugin-dialog", - "@tauri-apps/plugin-store", - "@openpanel/web", - "uuid", - "@tauri-apps/plugin-clipboard-manager", - "@tauri-apps/api/window", - "@tauri-apps/api/core", - "@tauri-apps/api/event", - "cva", - ], + include: ["@tanstack/solid-query", "@openpanel/web", "uuid", "cva"], }, }), }); diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 00000000000..babb1ef7718 --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,122 @@ +appId: so.cap.desktop +productName: Cap +asar: true +generateUpdatesFilesForAllChannels: true +electronLanguages: + - en-US + - en-GB +extraMetadata: + main: .electron-dist/main.cjs +directories: + output: dist-electron +files: + - .electron-dist/** + - .output/public/** + - '!.output/public/_server/**' + - '!.output/public/**/*.br' + - '!.output/public/**/*.gz' + - package.json + - '!node_modules/**/*' +extraResources: + - from: src-backend/assets + to: assets + filter: + - '**/*' + - '!dmg-background.png' + - '!nsis-header.bmp' + - '!nsis-sidebar.bmp' + - '!wix-banner.bmp' + - '!wix-dialog.bmp' + - from: src-backend/icons + to: tray-icons + filter: + - tray-*.png + - from: ../../target/native-deps/onnxruntime + to: onnxruntime +mac: + icon: src-backend/icons/macos/icon.icns + category: public.app-category.productivity + hardenedRuntime: true + notarize: true + entitlements: src-backend/Entitlements.plist + entitlementsInherit: src-backend/Entitlements.plist + target: + - dmg + - zip + extraFiles: + - from: src-backend/binaries + to: MacOS + filter: + - '!**/.gitignore' + - from: ../../target/native-deps/Spacedrive.framework + to: Frameworks/Spacedrive.framework + extendInfo: + NSCameraUsageDescription: Cap uses your camera to record you in your Caps + NSMicrophoneUsageDescription: Cap uses your microphone to record you in your Caps + NSScreenCaptureUsageDescription: Cap captures your screen as part of recording your Caps + NSCameraUseContinuityCameraDeviceType: true + CFBundleURLTypes: + - CFBundleURLName: Cap Desktop + CFBundleURLSchemes: + - cap-desktop + CFBundleDocumentTypes: + - CFBundleTypeName: Cap Project + CFBundleTypeExtensions: + - cap + CFBundleTypeRole: Editor + LSHandlerRank: Default + LSTypeIsPackage: true +dmg: + background: src-backend/assets/dmg-background.png + contents: + - x: 180 + y: 140 + - x: 480 + y: 140 + type: link + path: /Applications +win: + icon: src-backend/icons/icon.ico + extraResources: + - from: src-backend/binaries + to: bin + filter: + - '!**/.gitignore' + target: + - nsis +nsis: + installerIcon: src-backend/icons/icon.ico + installerHeader: src-backend/assets/nsis-header.bmp + installerSidebar: src-backend/assets/nsis-sidebar.bmp +linux: + icon: src-backend/icons + category: Utility + extraResources: + - from: src-backend/binaries + to: bin + filter: + - '!**/.gitignore' + - from: ../../target/native-deps/cap-deb-libs + to: lib/cap + target: + - deb +deb: + depends: + - libgtk-3-0 + - libayatana-appindicator3-1 + - libva2 + - libva-drm2 + - libpipewire-0.3-0 + - libasound2t64 | libasound2 +protocols: + - name: Cap Desktop + schemes: + - cap-desktop +fileAssociations: + - ext: cap + name: Cap Project + role: Editor +publish: + provider: github + owner: CapSoftware + repo: Cap diff --git a/apps/desktop/electron/backend.cjs b/apps/desktop/electron/backend.cjs new file mode 100644 index 00000000000..6091ba6a6b9 --- /dev/null +++ b/apps/desktop/electron/backend.cjs @@ -0,0 +1,188 @@ +const crypto = require("node:crypto"); +const net = require("node:net"); + +const PROTOCOL_VERSION = 1; +const MAX_FRAME_SIZE = 128 * 1024 * 1024; + +class RustBackend { + constructor({ binaryPath, resourceDir, spawn }) { + this.binaryPath = binaryPath; + this.resourceDir = resourceDir; + this.spawn = spawn; + this.buffer = Buffer.alloc(0); + this.nextInvokeId = 1; + this.pending = new Map(); + this.listeners = new Set(); + this.startupMessages = []; + this.ready = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + } + + async start() { + const token = crypto.randomBytes(32).toString("hex"); + this.server = net.createServer((socket) => this.accept(socket, token)); + await new Promise((resolve, reject) => { + this.server.once("error", reject); + this.server.listen(0, "127.0.0.1", resolve); + }); + const address = this.server.address(); + if (!address || typeof address === "string") + throw new Error("Invalid backend address"); + this.child = this.spawn(this.binaryPath, [], { + cwd: this.resourceDir, + env: { + ...process.env, + CAP_ELECTRON_IPC_ADDR: `127.0.0.1:${address.port}`, + CAP_ELECTRON_IPC_TOKEN: token, + CAP_ELECTRON_RESOURCE_DIR: this.resourceDir, + CAP_ELECTRON_APP_DATA_DIR: require("electron").app.getPath("userData"), + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + this.child.stdout.on("data", (data) => process.stdout.write(data)); + this.child.stderr.on("data", (data) => process.stderr.write(data)); + this.child.once("error", (error) => this.fail(error)); + this.child.once("exit", (code, signal) => { + if (!this.stopping) + this.fail(new Error(`Rust backend exited (${code ?? signal})`)); + }); + return this.ready; + } + + accept(socket, token) { + if (this.socket) { + socket.destroy(); + return; + } + this.socket = socket; + this.server.close(); + socket.setNoDelay(true); + socket.on("data", (data) => this.read(data)); + socket.once("error", (error) => this.fail(error)); + socket.once("close", () => { + if (!this.stopping) + this.fail(new Error("Rust backend connection closed")); + }); + this.send({ type: "hello", token, protocolVersion: PROTOCOL_VERSION }); + } + + read(data) { + this.buffer = Buffer.concat([this.buffer, data]); + while (this.buffer.length >= 4) { + const length = this.buffer.readUInt32BE(0); + if (length > MAX_FRAME_SIZE) { + this.fail( + new Error(`Rust backend frame exceeded ${MAX_FRAME_SIZE} bytes`), + ); + return; + } + if (this.buffer.length < length + 4) return; + const payload = this.buffer.subarray(4, length + 4); + this.buffer = this.buffer.subarray(length + 4); + try { + this.receive(JSON.parse(payload.toString("utf8"))); + } catch (error) { + this.fail(error); + return; + } + } + } + + receive(message) { + if (!this.commands && message.type !== "ready") + this.startupMessages.push(message); + if (message.type === "ready") { + if (message.protocolVersion !== PROTOCOL_VERSION) { + this.fail(new Error("Rust backend protocol version mismatch")); + return; + } + this.commands = new Set(message.commands); + this.resolveReady(); + } + if (message.type === "invokeResult") { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.response.status === "ok") + pending.resolve(message.response.value); + else pending.reject(new Error(message.response.error)); + } + for (const listener of this.listeners) listener(message); + } + + invoke(windowLabel, command, arguments_) { + return this.ready.then( + () => + new Promise((resolve, reject) => { + const id = this.nextInvokeId++; + this.pending.set(id, { resolve, reject }); + const sent = this.send({ + type: "invoke", + id, + windowLabel, + command, + arguments: arguments_ ?? {}, + }); + if (!sent) { + this.pending.delete(id); + reject(this.failure ?? new Error("Rust backend is disconnected")); + } + }), + ); + } + + send(message) { + if (!this.socket || this.socket.destroyed || this.failure) return false; + const payload = Buffer.from(JSON.stringify(message)); + if (payload.length > MAX_FRAME_SIZE) { + throw new Error(`Rust backend frame exceeded ${MAX_FRAME_SIZE} bytes`); + } + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(payload.length); + this.socket.cork(); + this.socket.write(header); + this.socket.write(payload); + this.socket.uncork(); + return true; + } + + onMessage(listener) { + this.listeners.add(listener); + for (const message of this.startupMessages) listener(message); + return () => this.listeners.delete(listener); + } + + async stop() { + this.stopping = true; + if (this.socket && !this.socket.destroyed) this.send({ type: "shutdown" }); + if (this.child && this.child.exitCode === null) { + await Promise.race([ + new Promise((resolve) => this.child.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); + } + if (this.child && this.child.exitCode === null) this.child.kill(); + this.socket?.destroy(); + this.server?.close(); + } + + fail(error) { + if (this.failure || this.stopping) return; + this.failure = error instanceof Error ? error : new Error(String(error)); + this.rejectReady(error); + for (const pending of this.pending.values()) pending.reject(this.failure); + this.pending.clear(); + for (const listener of this.listeners) { + try { + listener({ type: "backendError", error: String(this.failure) }); + } catch (listenerError) { + console.error(listenerError); + } + } + } +} + +module.exports = { RustBackend }; diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs new file mode 100644 index 00000000000..86d41cbd47f --- /dev/null +++ b/apps/desktop/electron/main.cjs @@ -0,0 +1,304 @@ +const { + app, + ipcMain, + net: electronNet, + protocol, + session, +} = require("electron"); +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const { RustBackend } = require("./backend.cjs"); +const { NativeBridge } = require("./native.cjs"); +const { WindowManager } = require("./windows.cjs"); + +protocol.registerSchemesAsPrivileged([ + { + scheme: "cap", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: false, + }, + }, + { + scheme: "cap-asset", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + }, + }, +]); + +const development = !app.isPackaged; +let backend; +let quitting = false; +const pendingOpenUrls = []; + +app.setName(development ? "Cap - Development" : "Cap"); +app.setAppUserModelId(development ? "so.cap.desktop.dev" : "so.cap.desktop"); +app.setPath( + "userData", + process.env.CAP_ELECTRON_USER_DATA_DIR + ? path.resolve(process.env.CAP_ELECTRON_USER_DATA_DIR) + : path.join( + app.getPath("appData"), + development ? "Cap Development" : "so.cap.desktop", + ), +); +if (!development) app.setAsDefaultProtocolClient("cap-desktop"); + +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +if (!hasSingleInstanceLock) app.quit(); + +if (hasSingleInstanceLock) { + app + .whenReady() + .then(start) + .catch((error) => { + console.error(error); + app.exit(1); + }); +} + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); + +app.on("activate", () => { + const mainWindow = globalThis.capWindowManager?.get("main"); + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + backend?.send({ type: "event", event: "electron://reopen", payload: null }); +}); + +app.on("second-instance", (_event, commandLine, workingDirectory) => { + queueOpenUrls(commandLine, workingDirectory); + const mainWindow = globalThis.capWindowManager?.get("main"); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } +}); + +app.on("open-url", (event, url) => { + event.preventDefault(); + queueOpenUrls([url]); +}); + +app.on("open-file", (event, filePath) => { + event.preventDefault(); + queueOpenUrls([filePath]); +}); + +app.on("before-quit", (event) => { + if (quitting || !backend) return; + event.preventDefault(); + quitting = true; + globalThis.capWindowManager?.beginQuit(); + backend.stop().finally(() => app.quit()); +}); + +async function start() { + const publicDir = path.join(__dirname, "..", ".output", "public"); + registerProtocols(publicDir); + configureSecurity(); + + const backendFilename = `cap-desktop-${rustTargetTriple()}${process.platform === "win32" ? ".exe" : ""}`; + const binaryPath = development + ? path.resolve(__dirname, "../../../target/debug/cap-desktop") + : process.platform === "darwin" + ? path.join(path.dirname(process.execPath), backendFilename) + : path.join(process.resourcesPath, "bin", backendFilename); + if (!fs.existsSync(binaryPath)) { + throw new Error( + `Rust backend is missing at ${binaryPath}. Run pnpm build:backend first.`, + ); + } + backend = new RustBackend({ + binaryPath, + resourceDir: process.resourcesPath, + spawn, + }); + await backend.start(); + + const windowManager = new WindowManager({ + backend, + devUrl: development + ? (process.env.CAP_DESKTOP_DEV_URL ?? "http://localhost:3002") + : null, + publicDir, + preloadPath: path.join(__dirname, "preload.cjs"), + }); + globalThis.capWindowManager = windowManager; + const nativeBridge = new NativeBridge(windowManager); + backend.onMessage((message) => { + if (message.type === "backendError") { + console.error(message.error); + if (!quitting) app.quit(); + return; + } + if (message.type === "nativeOperation") { + void nativeBridge + .invoke("main", message.operation, message.payload) + .catch(console.error); + } + if (message.type === "nativeRequest") { + void nativeBridge.invoke("main", message.operation, message.payload).then( + (value) => + backend.send({ + type: "nativeResult", + id: message.id, + result: { Ok: value ?? null }, + }), + (error) => + backend.send({ + type: "nativeResult", + id: message.id, + result: { Err: String(error) }, + }), + ); + } + }); + + ipcMain.handle("cap:invoke", (_event, request) => + backend.invoke(request.windowLabel, request.command, request.arguments), + ); + ipcMain.handle("cap:native", (_event, request) => + nativeBridge.invoke( + request.windowLabel, + request.operation, + request.payload, + ), + ); + ipcMain.on("cap:emit", (_event, request) => { + backend.send({ + type: "event", + event: request.event, + payload: request.payload ?? null, + }); + }); + ipcMain.on("cap:event-subscribe", (_event, request) => { + windowManager.subscribeToEvent(request.windowLabel, request.event); + }); + ipcMain.on("cap:event-unsubscribe", (_event, request) => { + windowManager.unsubscribeFromEvent(request.windowLabel, request.event); + }); + ipcMain.on("cap:window-drop", (_event, request) => { + windowManager.sendWindowEvent(request.windowLabel, { + type: "dragDrop", + paths: request.paths, + }); + }); + globalThis.__capMenuSelection = (id) => { + for (const label of windowManager.windows.keys()) { + windowManager.sendEvent(label, { + type: "event", + event: `menu:${id}`, + payload: null, + }); + } + }; + queueOpenUrls(process.argv, process.cwd()); +} + +function queueOpenUrls(values, workingDirectory = process.cwd()) { + for (const value of values) { + if (typeof value !== "string" || value.startsWith("--")) continue; + if (value.startsWith("cap-desktop://") || value.startsWith("file://")) { + pendingOpenUrls.push(value); + continue; + } + const candidate = path.isAbsolute(value) + ? value + : path.resolve(workingDirectory, value); + if ( + path.extname(candidate).toLowerCase() === ".cap" && + fs.existsSync(candidate) + ) { + pendingOpenUrls.push(pathToFileURL(candidate).toString()); + } + } + if (backend?.socket && pendingOpenUrls.length > 0) { + backend.send({ + type: "event", + event: "electron://open-urls", + payload: pendingOpenUrls.splice(0), + }); + } +} + +function rustTargetTriple() { + const triple = { + "darwin-arm64": "aarch64-apple-darwin", + "darwin-x64": "x86_64-apple-darwin", + "win32-arm64": "aarch64-pc-windows-msvc", + "win32-x64": "x86_64-pc-windows-msvc", + "linux-arm64": "aarch64-unknown-linux-gnu", + "linux-x64": "x86_64-unknown-linux-gnu", + }[`${process.platform}-${process.arch}`]; + if (!triple) + throw new Error( + `Unsupported Electron target ${process.platform}-${process.arch}`, + ); + return triple; +} + +function registerProtocols(publicDir) { + protocol.handle("cap", (request) => { + const url = new URL(request.url); + let relativePath = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + if (!relativePath || !path.extname(relativePath)) + relativePath = "index.html"; + const filePath = path.resolve(publicDir, relativePath); + if ( + !filePath.startsWith(`${path.resolve(publicDir)}${path.sep}`) && + filePath !== path.join(publicDir, "index.html") + ) { + return new Response("Not found", { status: 404 }); + } + return electronNet.fetch(pathToFileURL(filePath).toString()); + }); + protocol.handle("cap-asset", (request) => { + const url = new URL(request.url); + try { + const encoded = url.pathname.replace(/^\/+/, ""); + const filePath = Buffer.from(encoded, "base64url").toString("utf8"); + if (!path.isAbsolute(filePath)) + return new Response("Invalid asset path", { status: 400 }); + return electronNet.fetch(pathToFileURL(filePath).toString()); + } catch { + return new Response("Invalid asset URL", { status: 400 }); + } + }); +} + +function configureSecurity() { + session.defaultSession.setPermissionRequestHandler( + (_webContents, permission, callback) => { + callback( + ["media", "notifications", "clipboard-sanitized-write"].includes( + permission, + ), + ); + }, + ); + app.on("web-contents-created", (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + require("electron").shell.openExternal(url); + return { action: "deny" }; + }); + contents.on("will-navigate", (event, url) => { + if (!url.startsWith("cap://") && !url.startsWith("http://localhost:3002")) + event.preventDefault(); + }); + }); +} diff --git a/apps/desktop/electron/native.cjs b/apps/desktop/electron/native.cjs new file mode 100644 index 00000000000..d47371a897d --- /dev/null +++ b/apps/desktop/electron/native.cjs @@ -0,0 +1,632 @@ +const { + app, + clipboard, + dialog, + globalShortcut, + Menu, + nativeImage, + nativeTheme, + Notification, + screen, + shell, + Tray, +} = require("electron"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +class NativeBridge { + constructor(windowManager) { + this.windowManager = windowManager; + this.stores = new Map(); + this.hotkeyAccelerators = new Set(); + this.escapeRegistered = false; + this.tray = null; + this.trayMode = "studio"; + this.trayRecording = false; + this.updateChannel = "stable"; + this.autoUpdater = null; + } + + getAutoUpdater() { + if (this.autoUpdater) return this.autoUpdater; + + const { autoUpdater } = require("electron-updater"); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.on("download-progress", (progress) => { + this.windowManager.backend.send({ + type: "event", + event: "update-download-progress", + payload: { + downloaded: Math.min(progress.transferred, 0xffffffff), + total: Math.min(progress.total, 0xffffffff), + }, + }); + }); + autoUpdater.on("update-downloaded", (info) => { + this.windowManager.backend.send({ + type: "event", + event: "update-ready", + payload: { version: info.version, installed: true }, + }); + }); + this.autoUpdater = autoUpdater; + return autoUpdater; + } + + async invoke(windowLabel, operation, payload = {}) { + const window = this.windowManager.get(windowLabel); + switch (operation) { + case "app.exit": + this.windowManager.beginQuit(); + app.exit(payload.exitCode ?? 0); + return; + case "app.dockVisibility": + if (app.dock) payload.visible ? app.dock.show() : app.dock.hide(); + return; + case "app.activationPolicy": + if (process.platform === "darwin") + app.setActivationPolicy(payload.policy); + return; + case "app.version": + return app.getVersion(); + case "app.name": + return app.getName(); + case "app.relaunch": + app.relaunch(); + app.exit(0); + return; + case "clipboard.readText": + return clipboard.readText(); + case "clipboard.writeText": + clipboard.writeText(payload.text); + return; + case "dialog.open": + return this.openDialog(window, payload); + case "dialog.save": + return this.saveDialog(window, payload); + case "dialog.message": + return this.messageDialog(window, payload); + case "dialog.ask": + return this.questionDialog(window, payload, false); + case "dialog.confirm": + return this.questionDialog(window, payload, true); + case "fs.exists": + return this.exists(resolveBaseDirectory(payload.path, payload.baseDir)); + case "fs.readFile": + return Array.from( + await fs.readFile( + resolveBaseDirectory(payload.path, payload.baseDir), + ), + ); + case "fs.readTextFile": + return fs.readFile( + resolveBaseDirectory(payload.path, payload.baseDir), + "utf8", + ); + case "fs.writeFile": + await writeFile(payload); + return; + case "fs.writeTextFile": + await writeTextFile(payload); + return; + case "fs.remove": + await fs.rm(resolveBaseDirectory(payload.path, payload.baseDir), { + recursive: payload.recursive ?? false, + force: true, + }); + return; + case "fs.mkdir": + await fs.mkdir(resolveBaseDirectory(payload.path, payload.baseDir), { + recursive: payload.recursive ?? false, + }); + return; + case "fs.readDir": + return readDir(resolveBaseDirectory(payload.path, payload.baseDir)); + case "fs.stat": + return stat(resolveBaseDirectory(payload.path, payload.baseDir)); + case "hotkeys.configure": + return this.configureHotkeys(payload.hotkeys ?? []); + case "hotkeys.escapeEnabled": + return this.setEscapeEnabled(payload.enabled); + case "menu.popup": + return this.popupMenu(window, payload.items, payload.position); + case "notification.permission": + return Notification.isSupported() ? "granted" : "denied"; + case "notification.requestPermission": + return Notification.isSupported() ? "granted" : "denied"; + case "notification.show": + new Notification(payload).show(); + return; + case "opener.openPath": + return shell.openPath(payload.path); + case "opener.revealItemInDir": + shell.showItemInFolder(payload.path); + return; + case "os.arch": + return normalizeArch(process.arch); + case "os.hostname": + return os.hostname(); + case "os.locale": + return app.getLocale(); + case "os.platform": + return process.platform; + case "os.type": + return normalizeType(process.platform); + case "os.version": + return os.release(); + case "path.appDataDir": + return withSeparator(app.getPath("userData")); + case "path.appLocalDataDir": + return withSeparator(app.getPath("userData")); + case "path.audioDir": + return withSeparator(app.getPath("music")); + case "path.cacheDir": + return withSeparator(app.getPath("sessionData")); + case "path.configDir": + return withSeparator(app.getPath("userData")); + case "path.dataDir": + return withSeparator(app.getPath("appData")); + case "path.desktopDir": + return withSeparator(app.getPath("desktop")); + case "path.documentDir": + return withSeparator(app.getPath("documents")); + case "path.downloadDir": + return withSeparator(app.getPath("downloads")); + case "path.homeDir": + return withSeparator(app.getPath("home")); + case "path.pictureDir": + return withSeparator(app.getPath("pictures")); + case "path.resourceDir": + return withSeparator(process.resourcesPath); + case "path.tempDir": + return withSeparator(app.getPath("temp")); + case "path.videoDir": + return withSeparator(app.getPath("videos")); + case "path.join": + return path.join(...payload.paths); + case "path.resolve": + return path.resolve(...payload.paths); + case "shell.open": + await shell.openExternal(payload.path); + return; + case "shell.openPath": + return shell.openPath(payload.path); + case "store.load": + return this.loadStore(payload.path); + case "store.save": + return this.saveStore(payload.path, payload.value); + case "theme.get": + return nativeTheme.shouldUseDarkColors ? "dark" : "light"; + case "tray.configure": + return this.configureTray(payload); + case "tray.setMode": + this.trayMode = payload.mode ?? "studio"; + return this.updateTrayIcon(); + case "tray.setRecording": + this.trayRecording = payload.recording === true; + return this.updateTrayIcon(); + case "updater.check": + return this.checkForUpdates(payload.channel); + case "updater.configure": + return this.configureUpdater(payload.channel); + case "updater.downloadAndInstall": + return this.downloadAndInstall(payload.channel); + case "updater.setChannel": + this.updateChannel = payload.channel ?? "stable"; + return this.configureUpdater(this.updateChannel); + case "window.action": + return this.windowAction(windowLabel, payload); + case "window.all": + return [...this.windowManager.windows.keys()]; + case "window.create": + this.windowManager.create(payload); + return payload.label; + case "window.currentMonitor": + return this.windowManager.normalizeDisplay( + window + ? screen.getDisplayMatching(window.getBounds()) + : screen.getPrimaryDisplay(), + ); + case "window.primaryMonitor": + return this.windowManager.normalizeDisplay(screen.getPrimaryDisplay()); + case "window.availableMonitors": + return screen + .getAllDisplays() + .map((display) => this.windowManager.normalizeDisplay(display)); + case "window.monitorFromPoint": + return this.windowManager.normalizeDisplay( + this.windowManager.displayFromPhysicalPoint(payload), + ); + case "window.teardownVisualEffects": + return; + default: + throw new Error(`Unknown Electron native operation '${operation}'`); + } + } + + async openDialog(window, options) { + const result = await dialog.showOpenDialog(window, { + title: options.title, + defaultPath: options.defaultPath, + buttonLabel: options.buttonLabel, + filters: normalizeFilters(options.filters), + properties: [ + options.directory ? "openDirectory" : "openFile", + ...(options.multiple ? ["multiSelections"] : []), + ...(options.recursive ? ["showHiddenFiles"] : []), + ], + }); + if (result.canceled) return null; + return options.multiple ? result.filePaths : result.filePaths[0]; + } + + async saveDialog(window, options) { + const result = await dialog.showSaveDialog(window, { + title: options.title, + defaultPath: options.defaultPath, + buttonLabel: options.buttonLabel, + filters: normalizeFilters(options.filters), + }); + return result.canceled ? null : result.filePath; + } + + async messageDialog(window, options) { + const normalized = + typeof options === "string" ? { message: options } : options; + const buttons = Array.isArray(normalized.buttons) + ? normalized.buttons + : normalized.buttons + ? Object.values(normalized.buttons).filter(Boolean) + : [normalized.okLabel ?? "OK"]; + const result = await dialog.showMessageBox(window, { + type: normalized.kind ?? "info", + title: normalized.title, + message: normalized.message, + detail: normalized.detail, + buttons, + }); + return buttons[result.response]; + } + + async questionDialog(window, options, isConfirm) { + const normalized = + typeof options === "string" ? { message: options } : options; + const result = await dialog.showMessageBox(window, { + type: normalized.kind ?? "question", + title: normalized.title, + message: normalized.message, + detail: normalized.detail, + buttons: [ + normalized.okLabel ?? (isConfirm ? "OK" : "Yes"), + normalized.cancelLabel ?? (isConfirm ? "Cancel" : "No"), + ], + defaultId: 0, + cancelId: 1, + }); + return result.response === 0; + } + + async exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + popupMenu(window, items, position) { + const menu = Menu.buildFromTemplate(items.map(normalizeMenuItem)); + menu.popup({ + window, + x: position ? Math.round(position.x) : undefined, + y: position ? Math.round(position.y) : undefined, + }); + } + + async loadStore(name) { + const storePath = this.storePath(name); + if (this.stores.has(storePath)) return this.stores.get(storePath); + let value = {}; + try { + value = JSON.parse(await fs.readFile(storePath, "utf8")); + } catch {} + this.stores.set(storePath, value); + return value; + } + + async saveStore(name, value) { + const storePath = this.storePath(name); + this.stores.set(storePath, value); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(value, null, 2)); + } + + storePath(name) { + return path.join(app.getPath("userData"), "stores", path.basename(name)); + } + + windowAction(label, payload) { + const window = this.windowManager.get(payload.label ?? label); + if (!window || window.isDestroyed()) + throw new Error(`Window '${payload.label ?? label}' does not exist`); + if (payload.action === "state") + return this.windowManager.windowState(window); + if (payload.action === "cursorPosition") + return this.windowManager.cursorPosition?.() ?? { x: 0, y: 0 }; + this.windowManager.operation(window, { + type: payload.action, + ...payload.value, + }); + } + + configureHotkeys(hotkeys) { + for (const accelerator of this.hotkeyAccelerators) + globalShortcut.unregister(accelerator); + this.hotkeyAccelerators.clear(); + for (const hotkey of hotkeys) { + if ( + !hotkey.accelerator || + this.hotkeyAccelerators.has(hotkey.accelerator) + ) + continue; + if ( + globalShortcut.register(hotkey.accelerator, () => { + this.windowManager.backend.send({ + type: "event", + event: "hotkey://trigger", + payload: hotkey.action, + }); + }) + ) + this.hotkeyAccelerators.add(hotkey.accelerator); + } + const settingsAccelerator = "CommandOrControl+,"; + if ( + !this.hotkeyAccelerators.has(settingsAccelerator) && + globalShortcut.register(settingsAccelerator, () => { + this.windowManager.backend.send({ + type: "event", + event: "hotkey://settings", + payload: null, + }); + }) + ) + this.hotkeyAccelerators.add(settingsAccelerator); + } + + setEscapeEnabled(enabled) { + if (enabled && !this.escapeRegistered) { + this.escapeRegistered = globalShortcut.register("Escape", () => { + this.windowManager.backend.send({ + type: "event", + event: "hotkey://escape", + payload: null, + }); + }); + } else if (!enabled && this.escapeRegistered) { + globalShortcut.unregister("Escape"); + this.escapeRegistered = false; + } + } + + configureTray(payload) { + this.trayMode = payload.mode ?? this.trayMode; + if (!this.tray) { + this.tray = new Tray(this.trayIconPath()); + this.tray.setToolTip("Cap"); + this.tray.on("click", () => { + if (this.trayRecording) this.emitTrayClick("stop_recording"); + else this.tray.popUpContextMenu(); + }); + } + this.tray.setContextMenu( + Menu.buildFromTemplate( + (payload.items ?? []).map((item) => this.trayMenuItem(item)), + ), + ); + this.updateTrayIcon(); + } + + trayMenuItem(item) { + if (item.type === "separator") return { type: "separator" }; + return { + id: item.id, + label: item.text, + type: item.checked === undefined ? "normal" : "checkbox", + checked: item.checked, + enabled: item.enabled ?? true, + submenu: item.items?.map((child) => this.trayMenuItem(child)), + click: () => item.id && this.emitTrayClick(item.id), + }; + } + + emitTrayClick(id) { + this.windowManager.backend.send({ + type: "event", + event: "tray://click", + payload: id, + }); + } + + updateTrayIcon() { + if (!this.tray) return; + const image = nativeImage.createFromPath(this.trayIconPath()); + if (process.platform === "darwin") image.setTemplateImage(true); + this.tray.setImage(image); + } + + trayIconPath() { + const filename = this.trayRecording + ? "tray-stop-icon.png" + : this.trayMode === "instant" + ? "tray-default-icon-instant.png" + : this.trayMode === "screenshot" + ? "tray-default-icon-screenshot.png" + : "tray-default-icon.png"; + const iconsDir = app.isPackaged + ? path.join(process.resourcesPath, "tray-icons") + : path.join(__dirname, "..", "src-backend", "icons"); + return path.join(iconsDir, filename); + } + + configureUpdater(channel = "stable") { + this.updateChannel = channel; + if (this.updateTimer) { + clearInterval(this.updateTimer); + this.updateTimer = null; + } + if (!app.isPackaged || channel !== "nightly") return; + this.updateTimer = setInterval( + () => void this.checkForUpdates(channel).catch(console.error), + 2 * 60 * 60 * 1000, + ); + this.updateTimer.unref?.(); + } + + setUpdaterFeed(channel) { + if (!app.isPackaged) return null; + const autoUpdater = this.getAutoUpdater(); + autoUpdater.channel = channel === "nightly" ? "nightly" : "latest"; + const baseUrl = process.env.CAP_ELECTRON_UPDATE_URL; + if (baseUrl) + autoUpdater.setFeedURL({ + provider: "generic", + url: `${baseUrl}/${channel}`, + }); + else + autoUpdater.setFeedURL({ + provider: "github", + owner: "CapSoftware", + repo: "Cap", + }); + return autoUpdater; + } + + async checkForUpdates(channel = this.updateChannel) { + if (!app.isPackaged) return null; + const autoUpdater = this.setUpdaterFeed(channel); + const result = await autoUpdater.checkForUpdates(); + if (!result?.updateInfo || result.updateInfo.version === app.getVersion()) + return null; + return { + version: result.updateInfo.version, + notes: + typeof result.updateInfo.releaseNotes === "string" + ? result.updateInfo.releaseNotes + : null, + channel, + }; + } + + async downloadAndInstall(channel = this.updateChannel) { + const update = await this.checkForUpdates(channel); + if (!update) throw new Error("No update available"); + await this.getAutoUpdater().downloadUpdate(); + return { version: update.version, installed: true }; + } +} + +function normalizeMenuItem(item) { + if (item.type === "separator") return { type: "separator" }; + return { + id: item.id, + label: item.text, + type: item.checked === undefined ? "normal" : "checkbox", + checked: item.checked, + enabled: item.enabled ?? true, + accelerator: item.accelerator, + role: item.role, + submenu: item.items?.map(normalizeMenuItem), + click: () => item.id && globalThis.__capMenuSelection?.(item.id), + }; +} + +function normalizeFilters(filters) { + return filters?.map((filter) => ({ + name: filter.name, + extensions: filter.extensions, + })); +} + +function normalizeArch(arch) { + return { x64: "x86_64", arm64: "aarch64", ia32: "x86" }[arch] ?? arch; +} + +function normalizeType(platform) { + return ( + { darwin: "macos", win32: "windows", linux: "linux" }[platform] ?? platform + ); +} + +function resolveBaseDirectory(filePath, baseDir) { + if (!baseDir || path.isAbsolute(filePath)) return filePath; + const names = { + 1: "audioDir", + 2: "cacheDir", + 3: "configDir", + 4: "dataDir", + 6: "localDataDir", + 7: "desktop", + 8: "documents", + 9: "downloads", + 11: "home", + 15: "pictures", + 18: "public", + 20: "resource", + 21: "temp", + 22: "templates", + 24: "videos", + }; + const name = names[baseDir]; + if (name === "resource") return path.join(process.resourcesPath, filePath); + if (name === "temp") return path.join(app.getPath("temp"), filePath); + if (name === "home") return path.join(app.getPath("home"), filePath); + if (name === "localDataDir" || name === "configDir") + return path.join(app.getPath("userData"), filePath); + if (!name) throw new Error(`Unsupported base directory '${baseDir}'`); + return path.join(app.getPath(name), filePath); +} + +async function writeFile(payload) { + const filePath = resolveBaseDirectory(payload.path, payload.baseDir); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from(payload.data)); +} + +async function writeTextFile(payload) { + const filePath = resolveBaseDirectory(payload.path, payload.baseDir); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, payload.contents, "utf8"); +} + +async function readDir(filePath) { + const entries = await fs.readdir(filePath, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isFile: entry.isFile(), + isSymlink: entry.isSymbolicLink(), + })); +} + +async function stat(filePath) { + const value = await fs.stat(filePath); + return { + size: value.size, + isFile: value.isFile(), + isDirectory: value.isDirectory(), + isSymlink: value.isSymbolicLink(), + mtime: value.mtime, + atime: value.atime, + birthtime: value.birthtime, + }; +} + +function withSeparator(value) { + return value.endsWith(path.sep) ? value : `${value}${path.sep}`; +} + +module.exports = { NativeBridge }; diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs new file mode 100644 index 00000000000..0803c66f76e --- /dev/null +++ b/apps/desktop/electron/preload.cjs @@ -0,0 +1,87 @@ +const { contextBridge, ipcRenderer, webUtils } = require("electron"); + +const labelArgument = process.argv.find((argument) => + argument.startsWith("--cap-window-label="), +); +const initArgument = process.argv.find((argument) => + argument.startsWith("--cap-window-init="), +); +const windowLabel = + labelArgument?.slice("--cap-window-label=".length) ?? "main"; +const encodedInitialization = initArgument?.slice("--cap-window-init=".length); +const normalizedInitialization = encodedInitialization + ?.replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(Math.ceil(encodedInitialization.length / 4) * 4, "="); +const initialization = initArgument + ? JSON.parse(Buffer.from(normalizedInitialization, "base64").toString("utf8")) + : null; + +const eventListeners = new Map(); +const channelListeners = new Set(); + +ipcRenderer.on("cap:event", (_event, message) => { + const listeners = eventListeners.get(message.event); + if (!listeners) return; + for (const listener of listeners) listener(message); +}); +ipcRenderer.on("cap:channel", (_event, message) => { + for (const listener of channelListeners) listener(message); +}); + +window.addEventListener("dragover", (event) => event.preventDefault()); +window.addEventListener("drop", (event) => { + event.preventDefault(); + const paths = [...(event.dataTransfer?.files ?? [])] + .map((file) => webUtils.getPathForFile(file)) + .filter(Boolean); + if (paths.length > 0) + ipcRenderer.send("cap:window-drop", { windowLabel, paths }); +}); + +contextBridge.exposeInMainWorld("capElectron", { + windowLabel, + initialization, + os: { + arch: + { x64: "x86_64", arm64: "aarch64", ia32: "x86" }[process.arch] ?? + process.arch, + platform: process.platform, + type: + { darwin: "macos", win32: "windows", linux: "linux" }[process.platform] ?? + process.platform, + version: process.getSystemVersion?.() ?? "", + }, + invoke: (command, arguments_) => + ipcRenderer.invoke("cap:invoke", { + windowLabel, + command, + arguments: arguments_, + }), + native: (operation, payload) => + ipcRenderer.invoke("cap:native", { windowLabel, operation, payload }), + emit: (event, payload) => + ipcRenderer.send("cap:emit", { windowLabel, event, payload }), + onEvent: (event, listener) => { + let listeners = eventListeners.get(event); + if (!listeners) { + listeners = new Set(); + eventListeners.set(event, listeners); + ipcRenderer.send("cap:event-subscribe", { windowLabel, event }); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size > 0) return; + eventListeners.delete(event); + ipcRenderer.send("cap:event-unsubscribe", { windowLabel, event }); + }; + }, + onChannel: (listener) => { + channelListeners.add(listener); + return () => channelListeners.delete(listener); + }, +}); +contextBridge.exposeInMainWorld("__CAP__", initialization ?? {}); +contextBridge.exposeInMainWorld("COUNTDOWN", initialization?.countdown ?? 0); +contextBridge.exposeInMainWorld("FLAGS", { captions: false }); diff --git a/apps/desktop/electron/windows.cjs b/apps/desktop/electron/windows.cjs new file mode 100644 index 00000000000..2fe34947126 --- /dev/null +++ b/apps/desktop/electron/windows.cjs @@ -0,0 +1,673 @@ +const { BrowserWindow, nativeTheme, screen } = require("electron"); + +const CURSOR_POLL_INTERVAL_MS = 50; +const CURSOR_POLL_IDLE_MS = 250; + +class WindowManager { + constructor({ backend, devUrl, publicDir, preloadPath }) { + this.backend = backend; + this.devUrl = devUrl; + this.publicDir = publicDir; + this.preloadPath = preloadPath; + this.windows = new Map(); + this.eventSubscriptions = new Map(); + this.windowMetadata = new WeakMap(); + this.allowedToClose = new WeakSet(); + this.allowAllClose = false; + backend.onMessage((message) => this.receive(message)); + this.nativeThemeListener = () => { + const theme = nativeTheme.shouldUseDarkColors ? "dark" : "light"; + for (const label of this.windows.keys()) { + this.sendEvent(label, { + type: "event", + event: "tauri://theme-changed", + payload: theme, + target: label, + }); + } + }; + nativeTheme.on("updated", this.nativeThemeListener); + this.lastCursorPosition = null; + this.cursorTimer = null; + this.cursorPollingUntil = 0; + } + + create(options) { + const existing = this.windows.get(options.label); + if (existing && !existing.isDestroyed()) { + if (options.visible) existing.show(); + if (options.focus) existing.focus(); + return existing; + } + const bounds = this.initialBounds(options); + const chrome = nativeWindowChrome(options.label); + const transparent = shouldUseTransparentSurface(options); + const window = new BrowserWindow({ + title: options.title ?? "Cap", + x: finite(bounds.x), + y: finite(bounds.y), + width: Math.round(bounds.width ?? 800), + height: Math.round(bounds.height ?? 600), + minWidth: finite(options.minWidth), + minHeight: finite(options.minHeight), + show: false, + transparent, + backgroundColor: transparent + ? "#00000000" + : nativeTheme.shouldUseDarkColors + ? "#141414" + : "#ffffff", + frame: chrome.frame, + titleBarStyle: chrome.titleBarStyle, + trafficLightPosition: chrome.trafficLightPosition, + hasShadow: !isDisplaySurface(options.label), + resizable: options.resizable ?? true, + alwaysOnTop: options.alwaysOnTop ?? false, + skipTaskbar: options.skipTaskbar ?? false, + webPreferences: { + preload: this.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + additionalArguments: [ + `--cap-window-label=${options.label}`, + `--cap-window-init=${Buffer.from(JSON.stringify(options.initialization ?? null)).toString("base64url")}`, + ], + }, + }); + this.windowMetadata.set(window, { + decorated: chrome.frame, + ignoreCursorEvents: null, + loaded: false, + pendingShow: options.visible === true, + pendingFocus: options.focus === true, + }); + window.setContentProtection(options.contentProtected ?? false); + if (options.visibleOnAllWorkspaces) { + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + this.windows.set(options.label, window); + this.bind(options.label, window); + this.load(window, options.route ?? "/"); + window.webContents.once("did-finish-load", () => { + if (window.isDestroyed()) return; + const metadata = this.windowMetadata.get(window); + metadata.loaded = true; + if (!metadata.pendingShow) return; + window.show(); + if (metadata.pendingFocus) window.focus(); + }); + return window; + } + + initialBounds(options) { + const requestedDisplay = displayIdForOptions(options); + if (requestedDisplay !== null && isDisplaySurface(options.label)) { + const display = screen + .getAllDisplays() + .find(({ id }) => String(id) === requestedDisplay); + // macOS refuses to position a normal BrowserWindow above the menu-bar + // inset. Using full display bounds would therefore shift the window down + // while retaining its full height, leaving the selection surface offset + // and extending below the display. + if (display) + return { + ...(process.platform === "darwin" + ? display.workArea + : display.bounds), + }; + } + return { + x: options.x, + y: options.y, + width: options.width, + height: options.height, + }; + } + + load(window, route) { + const normalized = route.startsWith("/") ? route : `/${route}`; + if (this.devUrl) window.loadURL(`${this.devUrl}${normalized}`); + else window.loadURL(`cap://app${normalized}`); + } + + bind(label, window) { + let lastScaleFactor = null; + const update = () => { + const state = this.sendState(label, window); + if ( + state && + lastScaleFactor !== null && + state.scaleFactor !== lastScaleFactor + ) { + this.sendWindowEvent(label, { + type: "scaleFactorChanged", + scaleFactor: state.scaleFactor, + }); + } + if (state) lastScaleFactor = state.scaleFactor; + }; + window.on("ready-to-show", update); + window.on("show", update); + window.on("hide", update); + window.on("focus", () => { + update(); + this.sendWindowEvent(label, { type: "focused", focused: true }); + }); + window.on("blur", () => { + update(); + this.sendWindowEvent(label, { type: "focused", focused: false }); + }); + window.on("move", () => { + update(); + const { x, y } = this.physicalBounds(window); + this.sendWindowEvent(label, { type: "moved", x, y }); + }); + window.on("resize", () => { + update(); + const { width, height } = this.physicalBounds(window); + this.sendWindowEvent(label, { type: "resized", width, height }); + }); + window.on("close", (event) => { + if (this.allowAllClose || this.allowedToClose.has(window)) { + this.allowedToClose.delete(window); + return; + } + event.preventDefault(); + this.sendWindowEvent(label, { type: "closeRequested" }); + }); + window.on("closed", () => { + this.windows.delete(label); + this.eventSubscriptions.delete(label); + this.sendWindowEvent(label, { type: "destroyed" }); + }); + window.webContents.on("did-finish-load", update); + } + + sendState(label, window) { + if (window.isDestroyed()) return null; + const state = this.windowState(window); + this.backend.send({ + type: "windowState", + label, + state, + }); + return state; + } + + sendWindowEvent(label, event) { + this.backend.send({ type: "windowEvent", label, event }); + const window = this.windows.get(label); + if (!window || window.isDestroyed()) return; + const rendererEvent = rendererWindowEvent(event, window); + if (rendererEvent) { + this.sendEvent(label, { + type: "event", + event: rendererEvent.name, + payload: rendererEvent.payload, + target: label, + }); + } + } + + subscribeToEvent(label, event) { + if (typeof label !== "string" || typeof event !== "string") return; + let subscriptions = this.eventSubscriptions.get(label); + if (!subscriptions) { + subscriptions = new Set(); + this.eventSubscriptions.set(label, subscriptions); + } + subscriptions.add(event); + } + + unsubscribeFromEvent(label, event) { + const subscriptions = this.eventSubscriptions.get(label); + if (!subscriptions) return; + subscriptions.delete(event); + if (subscriptions.size === 0) this.eventSubscriptions.delete(label); + } + + sendEvent(label, message) { + if (!this.eventSubscriptions.get(label)?.has(message.event)) return; + const window = this.windows.get(label); + if (!window || window.isDestroyed()) return; + window.webContents.send("cap:event", message); + } + + receive(message) { + if (message.type === "createWindow") this.create(message.options); + if (message.type === "desktopOperation") { + const window = this.windows.get(message.label); + if (window && !window.isDestroyed()) + this.operation(window, message.operation); + } + if (message.type === "event") { + for (const label of this.windows.keys()) { + if (!message.target || message.target === label) { + this.sendEvent(label, message); + } + } + } + if (message.type === "channel") { + const window = this.windows.get(message.target); + if (window && !window.isDestroyed()) + window.webContents.send("cap:channel", message); + } + } + + operation(window, operation) { + const metadata = this.windowMetadata.get(window); + switch (operation.type) { + case "center": + window.center(); + break; + case "show": + metadata.pendingShow = true; + if (metadata.loaded) window.show(); + break; + case "hide": + metadata.pendingShow = false; + metadata.pendingFocus = false; + window.hide(); + break; + case "close": + this.allowedToClose.add(window); + window.close(); + break; + case "destroy": + window.destroy(); + break; + case "focus": + metadata.pendingFocus = true; + if (metadata.loaded) window.focus(); + break; + case "minimize": + window.minimize(); + break; + case "unminimize": + window.restore(); + break; + case "maximize": + window.maximize(); + break; + case "unmaximize": + window.unmaximize(); + break; + case "setFullscreen": + window.setFullScreen(operation.fullscreen); + break; + case "setAlwaysOnTop": + window.setAlwaysOnTop(operation.alwaysOnTop); + break; + case "setContentProtected": + window.setContentProtection(operation.enabled); + break; + case "setPosition": { + const point = operation.physical + ? this.physicalPointToDip({ x: operation.x, y: operation.y }, window) + : operation; + window.setPosition(Math.round(point.x), Math.round(point.y)); + break; + } + case "setSize": { + const size = operation.physical + ? this.physicalSizeToDip(operation, window) + : operation; + window.setSize(Math.round(size.width), Math.round(size.height)); + break; + } + case "setMinSize": + case "setMinimumSize": { + const size = operation.physical + ? this.physicalSizeToDip(operation, window) + : operation; + window.setMinimumSize( + Math.round(size.width ?? 0), + Math.round(size.height ?? 0), + ); + break; + } + case "setTitle": + window.setTitle(operation.title); + break; + case "setResizable": + window.setResizable(operation.resizable); + break; + case "setSkipTaskbar": + window.setSkipTaskbar(operation.skip); + break; + case "setIgnoreCursorEvents": + this.ensureCursorPolling(); + if (metadata.ignoreCursorEvents === operation.ignore) break; + window.setIgnoreMouseEvents(operation.ignore, { forward: true }); + metadata.ignoreCursorEvents = operation.ignore; + break; + case "setOpacity": + window.setOpacity(operation.opacity); + break; + case "setTheme": { + const source = ["system", "light", "dark"].includes(operation.theme) + ? operation.theme + : "system"; + if (nativeTheme.themeSource !== source) + nativeTheme.themeSource = source; + break; + } + case "setTrafficLightPosition": + if (process.platform === "darwin") + window.setWindowButtonPosition( + operation.x === null || operation.x === undefined + ? null + : { x: Math.round(operation.x), y: Math.round(operation.y) }, + ); + break; + case "requestUserAttention": + window.flashFrame(true); + break; + case "setProgress": + window.setProgressBar(operation.progress ?? -1); + break; + } + } + + get(label) { + return this.windows.get(label); + } + + cursorPosition() { + return this.dipPointToPhysical(screen.getCursorScreenPoint()); + } + + ensureCursorPolling() { + this.cursorPollingUntil = Date.now() + CURSOR_POLL_IDLE_MS; + this.sendCursorPosition(); + if (this.cursorTimer) return; + + this.cursorTimer = setInterval(() => { + if (Date.now() >= this.cursorPollingUntil) { + this.stopCursorPolling(); + return; + } + this.sendCursorPosition(); + }, CURSOR_POLL_INTERVAL_MS); + this.cursorTimer.unref?.(); + } + + sendCursorPosition() { + const position = this.cursorPosition(); + if ( + this.lastCursorPosition?.x === position.x && + this.lastCursorPosition?.y === position.y + ) + return; + this.lastCursorPosition = position; + this.backend.send({ + type: "cursorPosition", + x: position.x, + y: position.y, + }); + } + + stopCursorPolling() { + if (this.cursorTimer) clearInterval(this.cursorTimer); + this.cursorTimer = null; + } + + windowState(window) { + const dipBounds = window.getBounds(); + const display = screen.getDisplayMatching(dipBounds); + const bounds = this.physicalBounds(window); + const monitor = this.normalizeDisplay(display); + return { + nativeWindowId: nativeWindowId(window), + ...bounds, + visible: window.isVisible(), + focused: window.isFocused(), + minimized: window.isMinimized(), + maximized: window.isMaximized(), + fullscreen: window.isFullScreen(), + alwaysOnTop: window.isAlwaysOnTop(), + resizable: window.isResizable(), + decorated: this.windowMetadata.get(window)?.decorated ?? false, + monitor: { + name: monitor.name, + x: monitor.position.x, + y: monitor.position.y, + width: monitor.size.width, + height: monitor.size.height, + workX: monitor.workArea.position.x, + workY: monitor.workArea.position.y, + workWidth: monitor.workArea.size.width, + workHeight: monitor.workArea.size.height, + scaleFactor: monitor.scaleFactor, + }, + }; + } + + physicalBounds(window) { + const bounds = window.getBounds(); + const display = screen.getDisplayMatching(bounds); + const origin = physicalDisplayOrigin(display); + return { + x: Math.round( + origin.x + (bounds.x - display.bounds.x) * display.scaleFactor, + ), + y: Math.round( + origin.y + (bounds.y - display.bounds.y) * display.scaleFactor, + ), + width: Math.round(bounds.width * display.scaleFactor), + height: Math.round(bounds.height * display.scaleFactor), + scaleFactor: display.scaleFactor, + }; + } + + physicalPointToDip(point, window) { + const display = + displayForPhysicalPoint(point) ?? + screen.getDisplayMatching( + window?.getBounds() ?? screen.getPrimaryDisplay().bounds, + ); + const origin = physicalDisplayOrigin(display); + return { + x: display.bounds.x + (point.x - origin.x) / display.scaleFactor, + y: display.bounds.y + (point.y - origin.y) / display.scaleFactor, + }; + } + + physicalSizeToDip(size, window) { + const display = screen.getDisplayMatching(window.getBounds()); + return { + width: + size.width === null || size.width === undefined + ? size.width + : size.width / display.scaleFactor, + height: + size.height === null || size.height === undefined + ? size.height + : size.height / display.scaleFactor, + }; + } + + dipPointToPhysical(point) { + const display = screen.getDisplayNearestPoint(point); + const origin = physicalDisplayOrigin(display); + return { + x: Math.round( + origin.x + (point.x - display.bounds.x) * display.scaleFactor, + ), + y: Math.round( + origin.y + (point.y - display.bounds.y) * display.scaleFactor, + ), + }; + } + + normalizeDisplay(display) { + const bounds = displayRectToPhysical(display.bounds, display); + const workArea = displayRectToPhysical(display.workArea, display); + return { + name: display.label || null, + scaleFactor: display.scaleFactor, + position: { x: bounds.x, y: bounds.y }, + size: { width: bounds.width, height: bounds.height }, + workArea: { + position: { x: workArea.x, y: workArea.y }, + size: { width: workArea.width, height: workArea.height }, + }, + }; + } + + displayFromPhysicalPoint(point) { + return displayForPhysicalPoint(point) ?? screen.getPrimaryDisplay(); + } + + beginQuit() { + this.allowAllClose = true; + this.stopCursorPolling(); + nativeTheme.removeListener("updated", this.nativeThemeListener); + } +} + +function finite(value) { + return Number.isFinite(value) ? Math.round(value) : undefined; +} + +function nativeWindowId(window) { + const sourceId = window.getMediaSourceId(); + const match = /^window:(\d+):/.exec(sourceId); + return match ? Number(match[1]) : null; +} + +function isDisplaySurface(label) { + return ( + label.startsWith("target-select-overlay-") || + label.startsWith("window-capture-occluder-") || + label === "capture-area" + ); +} + +function displayIdForOptions(options) { + const prefixes = ["target-select-overlay-", "window-capture-occluder-"]; + for (const prefix of prefixes) { + if (options.label.startsWith(prefix)) + return options.label.slice(prefix.length); + } + if (options.label !== "capture-area") return null; + try { + return new URL(options.route, "cap://app").searchParams.get("screenId"); + } catch { + return null; + } +} + +function shouldUseTransparentSurface(options) { + return ( + options.transparent === true && + !["main", "onboarding"].includes(options.label) + ); +} + +function nativeWindowChrome(label) { + if (process.platform !== "darwin") return { frame: false }; + if (label.startsWith("editor-") || label.startsWith("screenshot-editor-")) { + return { + frame: true, + titleBarStyle: "hidden", + trafficLightPosition: { x: 20, y: 24 }, + }; + } + if (label === "settings") { + return { + frame: true, + titleBarStyle: "hidden", + trafficLightPosition: { x: 22, y: 14 }, + }; + } + if (label === "teleprompter") { + return { + frame: true, + titleBarStyle: "hidden", + trafficLightPosition: { x: 14, y: 14 }, + }; + } + if (["upgrade", "mode-select", "debug"].includes(label)) { + return { frame: true, titleBarStyle: "hidden" }; + } + return { frame: false }; +} + +function physicalDisplayOrigin(display) { + if ( + Number.isFinite(display.nativeOrigin?.x) && + Number.isFinite(display.nativeOrigin?.y) + ) { + return display.nativeOrigin; + } + return { + x: Math.round(display.bounds.x * display.scaleFactor), + y: Math.round(display.bounds.y * display.scaleFactor), + }; +} + +function displayRectToPhysical(rect, display) { + const origin = physicalDisplayOrigin(display); + return { + x: Math.round(origin.x + (rect.x - display.bounds.x) * display.scaleFactor), + y: Math.round(origin.y + (rect.y - display.bounds.y) * display.scaleFactor), + width: Math.round(rect.width * display.scaleFactor), + height: Math.round(rect.height * display.scaleFactor), + }; +} + +function displayForPhysicalPoint(point) { + return screen.getAllDisplays().find((display) => { + const bounds = displayRectToPhysical(display.bounds, display); + return ( + point.x >= bounds.x && + point.x < bounds.x + bounds.width && + point.y >= bounds.y && + point.y < bounds.y + bounds.height + ); + }); +} + +function rendererWindowEvent(event, window) { + switch (event.type) { + case "closeRequested": + return { name: "tauri://close-requested", payload: null }; + case "destroyed": + return { name: "tauri://destroyed", payload: null }; + case "focused": + return { name: "tauri://focus", payload: event.focused }; + case "moved": + return { name: "tauri://move", payload: { x: event.x, y: event.y } }; + case "resized": + return { + name: "tauri://resize", + payload: { width: event.width, height: event.height }, + }; + case "scaleFactorChanged": { + const [width, height] = window.getSize(); + return { + name: "tauri://scale-change", + payload: { + scaleFactor: event.scaleFactor, + size: { + width: Math.round(width * event.scaleFactor), + height: Math.round(height * event.scaleFactor), + }, + }, + }; + } + case "themeChanged": + return { name: "tauri://theme-changed", payload: event.theme }; + case "dragDrop": + return { + name: "tauri://drag-drop", + payload: { type: "drop", paths: event.paths }, + }; + default: + return null; + } +} + +module.exports = { WindowManager }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 17e7b4f846f..8cc12e758e3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,19 +1,28 @@ { "name": "@cap/desktop", + "version": "0.5.9", + "description": "Cap screen recorder and editor", + "author": "Cap Software", "type": "module", + "main": "electron/main.cjs", "scripts": { - "dev": "pnpm -w cap-setup && pnpm build:sidecar && dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- pnpm tauri dev", - "build:tauri": "pnpm build:sidecar && dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- pnpm tauri build", + "dev": "pnpm -w cap-setup && pnpm build:sidecar && pnpm build:backend && pnpm build:electron-main && dotenv -e ../../.env -- pnpm run preparescript && concurrently -k -s first \"pnpm localdev\" \"wait-on http://localhost:3002 && electron .\"", + "build:backend": "cargo build --manifest-path ../../Cargo.toml --package cap-desktop", + "build:backend:release": "cargo build --manifest-path ../../Cargo.toml --package cap-desktop --release", + "build:electron": "pnpm build:sidecar && dotenv -e ../../.env -- pnpm run preparescript && pnpm build && pnpm build:electron-main && electron-builder", + "build:electron-main": "node scripts/build-electron.mjs", "build:sidecar": "node ../../scripts/build-desktop-binaries.mjs", "preparescript": "node scripts/prepare.js", "localdev": "dotenv -e ../../.env -- vinxi dev --port 3002", "build": "vinxi build", - "tauri": "tauri", "test:display-transport": "node scripts/desktop-display-transport-benchmark.js", "test:memory": "node scripts/desktop-memory-soak.js", "test:memory:unit": "vitest run scripts/desktop-memory-soak.test.js" }, "dependencies": { + "electron-updater": "6.8.9" + }, + "devDependencies": { "@aerofoil/rive-solid-canvas": "^2.1.4", "@cap/database": "workspace:*", "@cap/ui-solid": "workspace:*", @@ -43,19 +52,6 @@ "@solidjs/router": "^0.14.2", "@solidjs/start": "^1.1.3", "@tanstack/solid-query": "^5.51.21", - "@tauri-apps/api": "2.8.0", - "@tauri-apps/plugin-clipboard-manager": "^2.3.0", - "@tauri-apps/plugin-deep-link": "^2.4.1", - "@tauri-apps/plugin-dialog": "2.4.2", - "@tauri-apps/plugin-fs": "2.4.2", - "@tauri-apps/plugin-http": "2.5.2", - "@tauri-apps/plugin-notification": "^2.3.0", - "@tauri-apps/plugin-opener": "^2.5.0", - "@tauri-apps/plugin-os": "^2.3.0", - "@tauri-apps/plugin-process": "2.3.0", - "@tauri-apps/plugin-shell": "^2.3.0", - "@tauri-apps/plugin-store": "^2.4.0", - "@tauri-apps/plugin-updater": "2.9.0", "@ts-rest/core": "^3.52.1", "@types/react-tooltip": "^4.2.4", "cva": "npm:class-variance-authority@^0.7.0", @@ -72,20 +68,21 @@ "uuid": "^9.0.1", "vinxi": "^0.5.6", "webcodecs": "^0.1.0", - "zod": "^3.25.76" - }, - "devDependencies": { + "zod": "^3.25.76", "@fontsource/geist-sans": "^5.0.3", "@iconify/json": "^2.2.239", "@solid-devtools/overlay": "^0.33.5", "@tailwindcss/postcss": "^4.2.2", "@tailwindcss/typography": "^0.5.9", - "@tauri-apps/cli": ">=2.1.0", "@total-typescript/ts-reset": "^0.6.1", "@types/dom-webcodecs": "^0.1.11", "@types/uuid": "^9.0.8", "@webgpu/types": "^0.1.44", + "concurrently": "9.2.1", "cross-env": "^7.0.3", + "electron": "43.4.0", + "electron-builder": "26.15.3", + "esbuild": "^0.25.12", "solid-devtools": "^0.34.5", "tailwind-scrollbar": "^3.1.0", "typescript": "^5.8.3", @@ -93,6 +90,7 @@ "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.4.1", "vite-tsconfig-paths": "^5.0.1", - "vitest": "~2.1.9" + "vitest": "~2.1.9", + "wait-on": "9.0.1" } } diff --git a/apps/desktop/scripts/build-electron.mjs b/apps/desktop/scripts/build-electron.mjs new file mode 100644 index 00000000000..6452e4bbb95 --- /dev/null +++ b/apps/desktop/scripts/build-electron.mjs @@ -0,0 +1,37 @@ +import { rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const desktopDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const outputDir = path.join(desktopDir, ".electron-dist"); + +await rm(outputDir, { recursive: true, force: true }); + +const common = { + bundle: true, + platform: "node", + format: "cjs", + target: "node22", + external: ["electron"], + logLevel: "info", + legalComments: "none", + sourcemap: false, +}; + +await Promise.all([ + build({ + ...common, + entryPoints: [path.join(desktopDir, "electron", "main.cjs")], + outfile: path.join(outputDir, "main.cjs"), + }), + build({ + ...common, + entryPoints: [path.join(desktopDir, "electron", "preload.cjs")], + outfile: path.join(outputDir, "preload.cjs"), + }), +]); diff --git a/apps/desktop/scripts/prepare.js b/apps/desktop/scripts/prepare.js index dc3e28d9acf..21b2c0784b2 100644 --- a/apps/desktop/scripts/prepare.js +++ b/apps/desktop/scripts/prepare.js @@ -70,7 +70,7 @@ export async function createTauriPlatformConfigs( platform, configOptions = undefined, ) { - const srcTauri = path.join(__dirname, "../src-tauri/"); + const srcTauri = path.join(__dirname, "../src-backend/"); let baseConfig = {}; let configFileName = null; diff --git a/apps/desktop/src-backend/.gitignore b/apps/desktop/src-backend/.gitignore new file mode 100644 index 00000000000..aba21e242c9 --- /dev/null +++ b/apps/desktop/src-backend/.gitignore @@ -0,0 +1,3 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-backend/Cargo.toml similarity index 77% rename from apps/desktop/src-tauri/Cargo.toml rename to apps/desktop/src-backend/Cargo.toml index 73f82aef047..3d8a1daeb9c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-backend/Cargo.toml @@ -12,42 +12,15 @@ workspace = true name = "cap_desktop_lib" crate-type = ["lib", "cdylib", "staticlib"] -[build-dependencies] -tauri-build = { version = "2.1.0", features = [] } - [target.'cfg(target_os = "macos")'.build-dependencies] swift-rs = { version = "1.0.6", features = ["build"] } [dependencies] -tauri = { workspace = true, features = [ - "macos-private-api", - "protocol-asset", - "tray-icon", - "image-png", -] } -tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] } -tauri-plugin-dialog = "2.2.0" -tauri-plugin-fs = "2.2.0" -tauri-plugin-global-shortcut = "2.2.0" -tauri-plugin-http = "=2.5.2" -tauri-plugin-notification = "2.2.0" -tauri-plugin-os = "2.2.0" -tauri-plugin-process = "2.2.0" -tauri-plugin-shell = "2.2.0" -tauri-plugin-single-instance = { version = "2.2.0", features = ["deep-link"] } -tauri-plugin-store = "2.2.0" -tauri-plugin-updater = "2.9.0" -tauri-plugin-oauth = { git = "https://github.com/FabianLars/tauri-plugin-oauth", branch = "v2" } -tauri-plugin-window-state = "2.2.0" -tauri-plugin-positioner = "2.2.0" -tauri-plugin-deep-link = "2.2.0" -tauri-plugin-clipboard-manager = "2.2.1" -tauri-plugin-opener = "2.2.6" +cap-desktop-runtime = { path = "../../../crates/desktop-runtime" } serde = { workspace = true } serde_json = "1.0.111" specta.workspace = true -specta-typescript = "0.0.7" tokio = { workspace = true, features = ["signal"] } uuid = { version = "1.10.0", features = ["v4"] } image = "0.25.2" @@ -66,7 +39,6 @@ device_query = "4.0.1" base64 = "0.22.1" reqwest = { version = "0.12.24", features = ["json", "stream", "multipart"] } dotenvy_macro = "0.15.7" -global-hotkey = "0.7.0" rand = "0.8.5" cpal.workspace = true keyed_priority_queue = "0.4.2" @@ -76,6 +48,7 @@ whisper-rs = "0.11.0" lazy_static = "1.4.0" log = "0.4.20" semver = "1" +url = "2" cap-audio = { path = "../../../crates/audio" } cap-camera = { path = "../../../crates/camera", features = ["serde", "specta"] } @@ -109,7 +82,6 @@ wgpu.workspace = true pollster = "0.4" bytemuck = "1.23.1" kameo = "0.17.2" -tauri-plugin-sentry = "0.5.0" thiserror.workspace = true bytes = "1.10.1" async-stream = "0.3.6" @@ -144,16 +116,12 @@ objc2-foundation = { version = "0.3.0", features = [ cocoa = "0.26.0" objc = "0.2.7" swift-rs = "1.0.6" -tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } cidre = { workspace = true } cap-camera-ffmpeg = { path = "../../../crates/camera-ffmpeg" } [target.'cfg(not(all(target_os = "macos", target_arch = "x86_64")))'.dependencies] parakeet-rs = "0.3.4" -[target.'cfg(target_os = "linux")'.dependencies] -libappindicator = "0.9.0" - [target.'cfg(target_os= "windows")'.dependencies] windows = { workspace = true, features = [ "Foundation", @@ -172,11 +140,6 @@ windows = { workspace = true, features = [ ] } windows-sys = { workspace = true } winreg = "0.55" -webview2-com = "0.38.0" -windows-core = "0.61.2" [target.'cfg(unix)'.dependencies] nix = { version = "0.29.0", features = ["fs"] } - -[dev-dependencies] -tauri = { workspace = true, features = ["test"] } diff --git a/apps/desktop/src-tauri/Entitlements.plist b/apps/desktop/src-backend/Entitlements.plist similarity index 100% rename from apps/desktop/src-tauri/Entitlements.plist rename to apps/desktop/src-backend/Entitlements.plist diff --git a/apps/desktop/src-tauri/assets/backgrounds/blue/1.jpg b/apps/desktop/src-backend/assets/backgrounds/blue/1.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/blue/1.jpg rename to apps/desktop/src-backend/assets/backgrounds/blue/1.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/blue/2.jpg b/apps/desktop/src-backend/assets/backgrounds/blue/2.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/blue/2.jpg rename to apps/desktop/src-backend/assets/backgrounds/blue/2.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/blue/3.jpg b/apps/desktop/src-backend/assets/backgrounds/blue/3.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/blue/3.jpg rename to apps/desktop/src-backend/assets/backgrounds/blue/3.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/blue/4.jpg b/apps/desktop/src-backend/assets/backgrounds/blue/4.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/blue/4.jpg rename to apps/desktop/src-backend/assets/backgrounds/blue/4.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/blue/5.jpg b/apps/desktop/src-backend/assets/backgrounds/blue/5.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/blue/5.jpg rename to apps/desktop/src-backend/assets/backgrounds/blue/5.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/blue/6.jpg b/apps/desktop/src-backend/assets/backgrounds/blue/6.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/blue/6.jpg rename to apps/desktop/src-backend/assets/backgrounds/blue/6.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/liverpool.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/liverpool.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/liverpool.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/liverpool.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/london.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/london.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/london.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/london.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/miami.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/miami.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/miami.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/miami.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/monaco.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/monaco.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/monaco.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/monaco.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/nyc.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/nyc.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/nyc.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/nyc.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/rome.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/rome.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/rome.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/rome.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/santorini.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/santorini.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/santorini.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/santorini.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/cities/sf.jpg b/apps/desktop/src-backend/assets/backgrounds/cities/sf.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/cities/sf.jpg rename to apps/desktop/src-backend/assets/backgrounds/cities/sf.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/dark/1.jpg b/apps/desktop/src-backend/assets/backgrounds/dark/1.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/dark/1.jpg rename to apps/desktop/src-backend/assets/backgrounds/dark/1.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/dark/2.jpg b/apps/desktop/src-backend/assets/backgrounds/dark/2.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/dark/2.jpg rename to apps/desktop/src-backend/assets/backgrounds/dark/2.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/dark/3.jpg b/apps/desktop/src-backend/assets/backgrounds/dark/3.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/dark/3.jpg rename to apps/desktop/src-backend/assets/backgrounds/dark/3.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/dark/4.jpg b/apps/desktop/src-backend/assets/backgrounds/dark/4.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/dark/4.jpg rename to apps/desktop/src-backend/assets/backgrounds/dark/4.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/dark/5.jpg b/apps/desktop/src-backend/assets/backgrounds/dark/5.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/dark/5.jpg rename to apps/desktop/src-backend/assets/backgrounds/dark/5.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/dark/6.jpg b/apps/desktop/src-backend/assets/backgrounds/dark/6.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/dark/6.jpg rename to apps/desktop/src-backend/assets/backgrounds/dark/6.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sequoia-dark.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sequoia-dark.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sequoia-dark.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sequoia-dark.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sequoia-light.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sequoia-light.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sequoia-light.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sequoia-light.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-clouds.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-clouds.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-clouds.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-clouds.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-dark.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-dark.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-dark.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-dark.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-evening.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-evening.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-evening.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-evening.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-fromabove.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-fromabove.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-fromabove.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-fromabove.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-horizon.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-horizon.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-horizon.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-horizon.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-light.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-light.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-light.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-light.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-river.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-river.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/sonoma-river.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/sonoma-river.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-dark.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-dark.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-dark.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-dark.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-dawn-min.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-dawn-min.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-dawn-min.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-dawn-min.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-day-min.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-day-min.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-day-min.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-day-min.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-dusk-min.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-dusk-min.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-dusk-min.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-dusk-min.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-light.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-light.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-light.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-light.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-night-min.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-night-min.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/tahoe-night-min.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/tahoe-night-min.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/ventura-dark.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/ventura-dark.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/ventura-dark.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/ventura-dark.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/ventura-semi-dark.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/ventura-semi-dark.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/ventura-semi-dark.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/ventura-semi-dark.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/macOS/ventura.jpg b/apps/desktop/src-backend/assets/backgrounds/macOS/ventura.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/macOS/ventura.jpg rename to apps/desktop/src-backend/assets/backgrounds/macOS/ventura.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/1.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/1.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/1.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/1.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/2.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/2.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/2.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/2.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/3.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/3.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/3.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/3.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/4.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/4.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/4.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/4.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/5.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/5.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/5.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/5.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/6.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/6.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/6.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/6.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/7.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/7.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/7.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/7.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/8.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/8.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/8.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/8.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/orange/9.jpg b/apps/desktop/src-backend/assets/backgrounds/orange/9.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/orange/9.jpg rename to apps/desktop/src-backend/assets/backgrounds/orange/9.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/purple/1.jpg b/apps/desktop/src-backend/assets/backgrounds/purple/1.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/purple/1.jpg rename to apps/desktop/src-backend/assets/backgrounds/purple/1.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/purple/2.jpg b/apps/desktop/src-backend/assets/backgrounds/purple/2.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/purple/2.jpg rename to apps/desktop/src-backend/assets/backgrounds/purple/2.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/purple/3.jpg b/apps/desktop/src-backend/assets/backgrounds/purple/3.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/purple/3.jpg rename to apps/desktop/src-backend/assets/backgrounds/purple/3.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/purple/4.jpg b/apps/desktop/src-backend/assets/backgrounds/purple/4.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/purple/4.jpg rename to apps/desktop/src-backend/assets/backgrounds/purple/4.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/purple/5.jpg b/apps/desktop/src-backend/assets/backgrounds/purple/5.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/purple/5.jpg rename to apps/desktop/src-backend/assets/backgrounds/purple/5.jpg diff --git a/apps/desktop/src-tauri/assets/backgrounds/purple/6.jpg b/apps/desktop/src-backend/assets/backgrounds/purple/6.jpg similarity index 100% rename from apps/desktop/src-tauri/assets/backgrounds/purple/6.jpg rename to apps/desktop/src-backend/assets/backgrounds/purple/6.jpg diff --git a/apps/desktop/src-tauri/assets/dmg-background.png b/apps/desktop/src-backend/assets/dmg-background.png similarity index 100% rename from apps/desktop/src-tauri/assets/dmg-background.png rename to apps/desktop/src-backend/assets/dmg-background.png diff --git a/apps/desktop/src-tauri/assets/nsis-header.bmp b/apps/desktop/src-backend/assets/nsis-header.bmp similarity index 100% rename from apps/desktop/src-tauri/assets/nsis-header.bmp rename to apps/desktop/src-backend/assets/nsis-header.bmp diff --git a/apps/desktop/src-tauri/assets/nsis-sidebar.bmp b/apps/desktop/src-backend/assets/nsis-sidebar.bmp similarity index 100% rename from apps/desktop/src-tauri/assets/nsis-sidebar.bmp rename to apps/desktop/src-backend/assets/nsis-sidebar.bmp diff --git a/apps/desktop/src-tauri/assets/wix-banner.bmp b/apps/desktop/src-backend/assets/wix-banner.bmp similarity index 100% rename from apps/desktop/src-tauri/assets/wix-banner.bmp rename to apps/desktop/src-backend/assets/wix-banner.bmp diff --git a/apps/desktop/src-tauri/assets/wix-dialog.bmp b/apps/desktop/src-backend/assets/wix-dialog.bmp similarity index 100% rename from apps/desktop/src-tauri/assets/wix-dialog.bmp rename to apps/desktop/src-backend/assets/wix-dialog.bmp diff --git a/apps/desktop/src-tauri/binaries/.gitignore b/apps/desktop/src-backend/binaries/.gitignore similarity index 88% rename from apps/desktop/src-tauri/binaries/.gitignore rename to apps/desktop/src-backend/binaries/.gitignore index a28175f65f0..bb54721cb8a 100644 --- a/apps/desktop/src-tauri/binaries/.gitignore +++ b/apps/desktop/src-backend/binaries/.gitignore @@ -1,4 +1,5 @@ cap-muxer-* cap-exporter-* cap-* +*.dll !.gitignore diff --git a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs b/apps/desktop/src-backend/examples/desktop-display-transport-benchmark.rs similarity index 100% rename from apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs rename to apps/desktop/src-backend/examples/desktop-display-transport-benchmark.rs diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-backend/icons/128x128.png similarity index 100% rename from apps/desktop/src-tauri/icons/128x128.png rename to apps/desktop/src-backend/icons/128x128.png diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-backend/icons/128x128@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/128x128@2x.png rename to apps/desktop/src-backend/icons/128x128@2x.png diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-backend/icons/32x32.png similarity index 100% rename from apps/desktop/src-tauri/icons/32x32.png rename to apps/desktop/src-backend/icons/32x32.png diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-backend/icons/Square107x107Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square107x107Logo.png rename to apps/desktop/src-backend/icons/Square107x107Logo.png diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-backend/icons/Square142x142Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square142x142Logo.png rename to apps/desktop/src-backend/icons/Square142x142Logo.png diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-backend/icons/Square150x150Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square150x150Logo.png rename to apps/desktop/src-backend/icons/Square150x150Logo.png diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-backend/icons/Square284x284Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square284x284Logo.png rename to apps/desktop/src-backend/icons/Square284x284Logo.png diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-backend/icons/Square30x30Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square30x30Logo.png rename to apps/desktop/src-backend/icons/Square30x30Logo.png diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-backend/icons/Square310x310Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square310x310Logo.png rename to apps/desktop/src-backend/icons/Square310x310Logo.png diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-backend/icons/Square44x44Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square44x44Logo.png rename to apps/desktop/src-backend/icons/Square44x44Logo.png diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-backend/icons/Square71x71Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square71x71Logo.png rename to apps/desktop/src-backend/icons/Square71x71Logo.png diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-backend/icons/Square89x89Logo.png similarity index 100% rename from apps/desktop/src-tauri/icons/Square89x89Logo.png rename to apps/desktop/src-backend/icons/Square89x89Logo.png diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-backend/icons/StoreLogo.png similarity index 100% rename from apps/desktop/src-tauri/icons/StoreLogo.png rename to apps/desktop/src-backend/icons/StoreLogo.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/desktop/src-backend/icons/android/mipmap-hdpi/ic_launcher.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png rename to apps/desktop/src-backend/icons/android/mipmap-hdpi/ic_launcher.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/desktop/src-backend/icons/android/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png rename to apps/desktop/src-backend/icons/android/mipmap-hdpi/ic_launcher_foreground.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/desktop/src-backend/icons/android/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png rename to apps/desktop/src-backend/icons/android/mipmap-hdpi/ic_launcher_round.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/desktop/src-backend/icons/android/mipmap-mdpi/ic_launcher.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png rename to apps/desktop/src-backend/icons/android/mipmap-mdpi/ic_launcher.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/desktop/src-backend/icons/android/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png rename to apps/desktop/src-backend/icons/android/mipmap-mdpi/ic_launcher_foreground.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/desktop/src-backend/icons/android/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png rename to apps/desktop/src-backend/icons/android/mipmap-mdpi/ic_launcher_round.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/desktop/src-backend/icons/android/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png rename to apps/desktop/src-backend/icons/android/mipmap-xhdpi/ic_launcher.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/desktop/src-backend/icons/android/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png rename to apps/desktop/src-backend/icons/android/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/desktop/src-backend/icons/android/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png rename to apps/desktop/src-backend/icons/android/mipmap-xhdpi/ic_launcher_round.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/desktop/src-backend/icons/android/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png rename to apps/desktop/src-backend/icons/android/mipmap-xxhdpi/ic_launcher.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/desktop/src-backend/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png rename to apps/desktop/src-backend/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/desktop/src-backend/icons/android/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png rename to apps/desktop/src-backend/icons/android/mipmap-xxhdpi/ic_launcher_round.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/desktop/src-backend/icons/android/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png rename to apps/desktop/src-backend/icons/android/mipmap-xxxhdpi/ic_launcher.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/desktop/src-backend/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png rename to apps/desktop/src-backend/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/desktop/src-backend/icons/android/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png rename to apps/desktop/src-backend/icons/android/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-backend/icons/icon.icns similarity index 100% rename from apps/desktop/src-tauri/icons/icon.icns rename to apps/desktop/src-backend/icons/icon.icns diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-backend/icons/icon.ico similarity index 100% rename from apps/desktop/src-tauri/icons/icon.ico rename to apps/desktop/src-backend/icons/icon.ico diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-backend/icons/icon.png similarity index 100% rename from apps/desktop/src-tauri/icons/icon.png rename to apps/desktop/src-backend/icons/icon.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-backend/icons/ios/AppIcon-20x20@1x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-20x20@1x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/desktop/src-backend/icons/ios/AppIcon-20x20@2x-1.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png rename to apps/desktop/src-backend/icons/ios/AppIcon-20x20@2x-1.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/desktop/src-backend/icons/ios/AppIcon-20x20@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-20x20@2x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/desktop/src-backend/icons/ios/AppIcon-20x20@3x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-20x20@3x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/desktop/src-backend/icons/ios/AppIcon-29x29@1x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-29x29@1x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/desktop/src-backend/icons/ios/AppIcon-29x29@2x-1.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png rename to apps/desktop/src-backend/icons/ios/AppIcon-29x29@2x-1.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/desktop/src-backend/icons/ios/AppIcon-29x29@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-29x29@2x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/desktop/src-backend/icons/ios/AppIcon-29x29@3x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-29x29@3x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/desktop/src-backend/icons/ios/AppIcon-40x40@1x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-40x40@1x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/desktop/src-backend/icons/ios/AppIcon-40x40@2x-1.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png rename to apps/desktop/src-backend/icons/ios/AppIcon-40x40@2x-1.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/desktop/src-backend/icons/ios/AppIcon-40x40@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-40x40@2x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/desktop/src-backend/icons/ios/AppIcon-40x40@3x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-40x40@3x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/desktop/src-backend/icons/ios/AppIcon-512@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-512@2x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/desktop/src-backend/icons/ios/AppIcon-60x60@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-60x60@2x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/desktop/src-backend/icons/ios/AppIcon-60x60@3x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-60x60@3x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/desktop/src-backend/icons/ios/AppIcon-76x76@1x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-76x76@1x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/desktop/src-backend/icons/ios/AppIcon-76x76@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-76x76@2x.png diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/desktop/src-backend/icons/ios/AppIcon-83.5x83.5@2x.png similarity index 100% rename from apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png rename to apps/desktop/src-backend/icons/ios/AppIcon-83.5x83.5@2x.png diff --git a/apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-default-symbolic.svg b/apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-default-symbolic.svg similarity index 100% rename from apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-default-symbolic.svg rename to apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-default-symbolic.svg diff --git a/apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-instant-symbolic.svg b/apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-instant-symbolic.svg similarity index 100% rename from apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-instant-symbolic.svg rename to apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-instant-symbolic.svg diff --git a/apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-screenshot-symbolic.svg b/apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-screenshot-symbolic.svg similarity index 100% rename from apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-screenshot-symbolic.svg rename to apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-screenshot-symbolic.svg diff --git a/apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-stop-symbolic.svg b/apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-stop-symbolic.svg similarity index 100% rename from apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-stop-symbolic.svg rename to apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-stop-symbolic.svg diff --git a/apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-studio-symbolic.svg b/apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-studio-symbolic.svg similarity index 100% rename from apps/desktop/src-tauri/icons/linux/so.cap.desktop-tray-studio-symbolic.svg rename to apps/desktop/src-backend/icons/linux/so.cap.desktop-tray-studio-symbolic.svg diff --git a/apps/desktop/src-tauri/icons/macos/icon.icns b/apps/desktop/src-backend/icons/macos/icon.icns similarity index 100% rename from apps/desktop/src-tauri/icons/macos/icon.icns rename to apps/desktop/src-backend/icons/macos/icon.icns diff --git a/apps/desktop/src-tauri/icons/macos/icon.png b/apps/desktop/src-backend/icons/macos/icon.png similarity index 100% rename from apps/desktop/src-tauri/icons/macos/icon.png rename to apps/desktop/src-backend/icons/macos/icon.png diff --git a/apps/desktop/src-tauri/icons/tray-default-icon-instant.png b/apps/desktop/src-backend/icons/tray-default-icon-instant.png similarity index 100% rename from apps/desktop/src-tauri/icons/tray-default-icon-instant.png rename to apps/desktop/src-backend/icons/tray-default-icon-instant.png diff --git a/apps/desktop/src-tauri/icons/tray-default-icon-screenshot.png b/apps/desktop/src-backend/icons/tray-default-icon-screenshot.png similarity index 100% rename from apps/desktop/src-tauri/icons/tray-default-icon-screenshot.png rename to apps/desktop/src-backend/icons/tray-default-icon-screenshot.png diff --git a/apps/desktop/src-tauri/icons/tray-default-icon-studio.png b/apps/desktop/src-backend/icons/tray-default-icon-studio.png similarity index 100% rename from apps/desktop/src-tauri/icons/tray-default-icon-studio.png rename to apps/desktop/src-backend/icons/tray-default-icon-studio.png diff --git a/apps/desktop/src-tauri/icons/tray-default-icon.png b/apps/desktop/src-backend/icons/tray-default-icon.png similarity index 100% rename from apps/desktop/src-tauri/icons/tray-default-icon.png rename to apps/desktop/src-backend/icons/tray-default-icon.png diff --git a/apps/desktop/src-tauri/icons/tray-stop-icon.png b/apps/desktop/src-backend/icons/tray-stop-icon.png similarity index 100% rename from apps/desktop/src-tauri/icons/tray-stop-icon.png rename to apps/desktop/src-backend/icons/tray-stop-icon.png diff --git a/apps/desktop/src-tauri/sounds/action.ogg b/apps/desktop/src-backend/sounds/action.ogg similarity index 100% rename from apps/desktop/src-tauri/sounds/action.ogg rename to apps/desktop/src-backend/sounds/action.ogg diff --git a/apps/desktop/src-tauri/sounds/screenshot.ogg b/apps/desktop/src-backend/sounds/screenshot.ogg similarity index 100% rename from apps/desktop/src-tauri/sounds/screenshot.ogg rename to apps/desktop/src-backend/sounds/screenshot.ogg diff --git a/apps/desktop/src-tauri/sounds/start-recording.ogg b/apps/desktop/src-backend/sounds/start-recording.ogg similarity index 100% rename from apps/desktop/src-tauri/sounds/start-recording.ogg rename to apps/desktop/src-backend/sounds/start-recording.ogg diff --git a/apps/desktop/src-tauri/sounds/stop-recording.ogg b/apps/desktop/src-backend/sounds/stop-recording.ogg similarity index 100% rename from apps/desktop/src-tauri/sounds/stop-recording.ogg rename to apps/desktop/src-backend/sounds/stop-recording.ogg diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-backend/src/api.rs similarity index 99% rename from apps/desktop/src-tauri/src/api.rs rename to apps/desktop/src-backend/src/api.rs index ef0988298df..bbd997e110d 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-backend/src/api.rs @@ -1,11 +1,11 @@ //! TODO: We should investigate generating this with OpenAPI. //! This will come part of the EffectTS rewrite work. +use cap_desktop_runtime::AppHandle; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; use std::collections::HashMap; -use tauri::AppHandle; use tracing::{instrument, trace}; use crate::web_api::{AuthedApiError, ManagerExt}; diff --git a/apps/desktop/src-tauri/src/audio.rs b/apps/desktop/src-backend/src/audio.rs similarity index 100% rename from apps/desktop/src-tauri/src/audio.rs rename to apps/desktop/src-backend/src/audio.rs diff --git a/apps/desktop/src-tauri/src/audio_library.rs b/apps/desktop/src-backend/src/audio_library.rs similarity index 96% rename from apps/desktop/src-tauri/src/audio_library.rs rename to apps/desktop/src-backend/src/audio_library.rs index 561b9673696..145d884799b 100644 --- a/apps/desktop/src-tauri/src/audio_library.rs +++ b/apps/desktop/src-backend/src/audio_library.rs @@ -1,9 +1,9 @@ use std::path::PathBuf; +use cap_desktop_runtime::{AppHandle, Manager, path::BaseDirectory}; use cap_enc_ffmpeg::remux::get_media_duration; use serde::{Deserialize, Serialize}; use specta::Type; -use tauri::{AppHandle, Manager, path::BaseDirectory}; use crate::editor_window::WindowEditorInstance; @@ -46,7 +46,7 @@ pub struct ImportedAudioTrack { pub duration: f64, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub fn list_audio_library() -> Vec { AUDIO_LIBRARY @@ -65,7 +65,7 @@ fn probe_duration(path: &std::path::Path) -> f64 { .unwrap_or(0.0) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[tracing::instrument(skip(app, editor_instance))] pub async fn add_audio_library_track( @@ -114,7 +114,7 @@ pub async fn add_audio_library_track( .map_err(|e| format!("Audio import task failed: {e}"))? } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[tracing::instrument(skip(editor_instance))] pub async fn import_audio_track_file( diff --git a/apps/desktop/src-tauri/src/audio_meter.rs b/apps/desktop/src-backend/src/audio_meter.rs similarity index 97% rename from apps/desktop/src-tauri/src/audio_meter.rs rename to apps/desktop/src-backend/src/audio_meter.rs index 8070eb9e9b2..b6eaaf33ff8 100644 --- a/apps/desktop/src-tauri/src/audio_meter.rs +++ b/apps/desktop/src-backend/src/audio_meter.rs @@ -1,3 +1,5 @@ +use cap_desktop_runtime::AppHandle; +use cap_desktop_runtime::Event; use cap_recording::feeds::microphone::MicrophoneSamples; use cpal::{SampleFormat, StreamInstant}; use keyed_priority_queue::KeyedPriorityQueue; @@ -5,14 +7,12 @@ use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::collections::VecDeque; use std::time::Duration; -use tauri::AppHandle; -use tauri_specta::Event; const MAX_AMPLITUDE_F32: f64 = (u16::MAX / 2) as f64; // i16 max value const ZERO_AMPLITUDE: u16 = 0; const MIN_DB: f64 = -96.0; -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct AudioInputLevelChange(f64); pub fn spawn_event_emitter( diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-backend/src/auth.rs similarity index 98% rename from apps/desktop/src-tauri/src/auth.rs rename to apps/desktop/src-backend/src/auth.rs index 5ae16506dba..5d5e38d60ad 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-backend/src/auth.rs @@ -1,8 +1,7 @@ +use cap_desktop_runtime::{AppHandle, Runtime}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; -use tauri::{AppHandle, Runtime}; -use tauri_plugin_store::StoreExt; use web_api::ManagerExt; diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-backend/src/automation.rs similarity index 97% rename from apps/desktop/src-tauri/src/automation.rs rename to apps/desktop/src-backend/src/automation.rs index a05f0a030f8..f9ccb67f817 100644 --- a/apps/desktop/src-tauri/src/automation.rs +++ b/apps/desktop/src-backend/src/automation.rs @@ -3,6 +3,7 @@ use cap_automation::{ Capability, CaptureTargetKind, ClipboardSource, ExportDestination, ExportFormat, ExportProfile, Trigger, TriggerContext, sanitize_filename_component, }; +use cap_desktop_runtime::{AppHandle, Manager, Wry}; use cap_recording::sources::screen_capture::ScreenCaptureTarget; use clipboard_rs::Clipboard; use clipboard_rs::common::RustImage; @@ -10,8 +11,6 @@ use serde_json::json; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use tauri::{AppHandle, Manager, Wry}; -use tauri_plugin_store::StoreExt; use tokio::sync::RwLock; use tracing::{error, info, warn}; @@ -210,7 +209,7 @@ impl AutomationHost for DesktopAutomationHost { existing.clone() } else if let Some(project_path) = ctx.project_path.as_ref() { info!(path = %project_path.display(), "Automation: uploading recording"); - let channel = tauri::ipc::Channel::new(|_| Ok(())); + let channel = cap_desktop_runtime::ipc::Channel::new(|_| Ok(())); let result = crate::upload_exported_video( self.app.clone(), project_path.clone(), @@ -265,8 +264,6 @@ impl AutomationHost for DesktopAutomationHost { } async fn open_file(&self, ctx: &TriggerContext) -> Result<(), String> { - use tauri_plugin_opener::OpenerExt; - let path = ctx .image_path .as_ref() @@ -275,9 +272,7 @@ impl AutomationHost for DesktopAutomationHost { let path_str = path.to_str().ok_or("Invalid path")?; self.app - .opener() - .open_path(path_str, None::) - .map_err(|e| format!("Failed to open file: {e}"))?; + .native_operation("opener.openPath", serde_json::json!({ "path": path_str }))?; Ok(()) } @@ -426,8 +421,6 @@ impl AutomationHost for DesktopAutomationHost { title_template: &str, body_template: &str, ) -> Result<(), String> { - use tauri_plugin_notification::NotificationExt; - let enabled = crate::general_settings::GeneralSettingsStore::get(&self.app) .map(|s| s.is_some_and(|s| s.enable_notifications)) .unwrap_or(false); @@ -439,13 +432,10 @@ impl AutomationHost for DesktopAutomationHost { let title = apply_body_template(&apply_filename_template(title_template, ctx), ctx); let body = apply_body_template(&apply_filename_template(body_template, ctx), ctx); - self.app - .notification() - .builder() - .title(title) - .body(body) - .show() - .map_err(|e| format!("Failed to send notification: {e}"))?; + self.app.native_operation( + "notification.show", + serde_json::json!({ "title": title, "body": body }), + )?; Ok(()) } @@ -823,13 +813,13 @@ pub fn get_store(app: &AppHandle) -> Result, Strin } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn get_automations(app: AppHandle) -> Result { Ok(get_store(&app)?.unwrap_or_default()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn set_automations(app: AppHandle, store: AutomationsStore) -> Result<(), String> { let tauri_store = app.store("store").map_err(|e| e.to_string())?; @@ -853,7 +843,7 @@ pub struct AutomationTestReport { pub action_checks: Vec, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn test_automation( app: AppHandle, @@ -901,7 +891,7 @@ pub async fn test_automation( }) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn automation_should_open_screenshot_editor( app: AppHandle, @@ -910,7 +900,7 @@ pub async fn automation_should_open_screenshot_editor( should_open_screenshot_editor(&app, &target) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn list_automation_capabilities() -> Vec { vec![ diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-backend/src/camera.rs similarity index 82% rename from apps/desktop/src-tauri/src/camera.rs rename to apps/desktop/src-backend/src/camera.rs index 111d9effeea..ec8d36e58cf 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-backend/src/camera.rs @@ -9,6 +9,7 @@ use cap_utils::macos_qos::{MacOsQosClass, set_current_thread_qos}; #[cfg(target_os = "macos")] use crate::camera_native::{NativeFrameConverter, classify_frame}; +use cap_desktop_runtime::{LogicalPosition, LogicalSize, PhysicalSize, WebviewWindow}; use ffmpeg::{ format::{self, Pixel}, frame, @@ -24,7 +25,6 @@ use std::{ }, thread, }; -use tauri::{LogicalPosition, LogicalSize, PhysicalSize, WebviewWindow}; use tokio::{ runtime::Runtime, sync::{broadcast, oneshot}, @@ -263,7 +263,7 @@ fn camera_preview_texture_dimensions( } pub struct CameraPreviewManager { - store: Result>, String>, + store: Result, String>, store_save_generation: Arc, preview: Option, preview_session_id: Arc, @@ -271,10 +271,10 @@ pub struct CameraPreviewManager { } impl CameraPreviewManager { - pub fn new(app: &tauri::AppHandle) -> Self { + pub fn new(app: &cap_desktop_runtime::AppHandle) -> Self { Self { - store: tauri_plugin_store::StoreBuilder::new(app, "cameraPreview") - .build() + store: app + .store("cameraPreview") .map_err(|err| format!("Error initializing camera preview store: {err}")), store_save_generation: Arc::new(AtomicU64::new(0)), preview: None, @@ -311,7 +311,7 @@ impl CameraPreviewManager { let generation = self.store_save_generation.fetch_add(1, Ordering::AcqRel) + 1; let save_generation = self.store_save_generation.clone(); let store = store.clone(); - tauri::async_runtime::spawn(async move { + cap_desktop_runtime::async_runtime::spawn(async move { tokio::time::sleep(Duration::from_millis(150)).await; if save_generation.load(Ordering::Acquire) == generation && let Err(err) = store.save() @@ -532,283 +532,292 @@ impl InitializedCameraPreview { default_state: &CameraPreviewState, instance: wgpu::Instance, ) -> anyhow::Result { - let aspect = if default_state.shape == CameraPreviewShape::Full { - WIDE_CAMERA_ASPECT_RATIO - } else { - 1.0 - }; + #[cfg(any())] + { + let aspect = if default_state.shape == CameraPreviewShape::Full { + WIDE_CAMERA_ASPECT_RATIO + } else { + 1.0 + }; - let (window_width, window_height, surface_scale) = - resize_window(&window, default_state, aspect, false) - .await - .context("Error resizing Tauri window")?; + let (window_width, window_height, surface_scale) = + resize_window(&window, default_state, aspect, false) + .await + .context("Error resizing Tauri window")?; - let (tx, rx) = oneshot::channel(); - window - .run_on_main_thread({ - let window = window.clone(); - let instance = instance.clone(); - move || { - let surface = instance.create_surface(window.clone()); - tx.send(surface).ok(); - } - }) - .with_context(|| "Failed to initialize wgpu instance")?; + let (tx, rx) = oneshot::channel(); + window + .run_on_main_thread({ + let window = window.clone(); + let instance = instance.clone(); + move || { + let surface = instance.create_surface(window.clone()); + tx.send(surface).ok(); + } + }) + .with_context(|| "Failed to initialize wgpu instance")?; - let surface = rx - .await - .with_context(|| "Failed to receive initialized wgpu surface")?; - let surface = surface.with_context(|| "Failed to initialize wgpu surface")?; - - let force_software_adapter = cap_rendering::force_software_wgpu_adapter(); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::LowPower, - force_fallback_adapter: force_software_adapter, - compatible_surface: Some(&surface), - }) - .await - .with_context(|| "Failed to find an appropriate wgpu adapter")?; + let surface = rx + .await + .with_context(|| "Failed to receive initialized wgpu surface")?; + let surface = surface.with_context(|| "Failed to initialize wgpu surface")?; + + let force_software_adapter = cap_rendering::force_software_wgpu_adapter(); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: force_software_adapter, + compatible_surface: Some(&surface), + }) + .await + .with_context(|| "Failed to find an appropriate wgpu adapter")?; + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: None, + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_webgl2_defaults() + .using_resolution(adapter.limits()), + memory_hints: Default::default(), + trace: wgpu::Trace::Off, + }) + .await + .with_context(|| "Failed to create wgpu device")?; - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_webgl2_defaults() - .using_resolution(adapter.limits()), - memory_hints: Default::default(), - trace: wgpu::Trace::Off, - }) - .await - .with_context(|| "Failed to create wgpu device")?; + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "./camera.wgsl" + ))), + }); - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: None, - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "./camera.wgsl" - ))), - }); + let uniform_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Uniform Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Texture Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); - let uniform_bind_group_layout = - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Uniform Bind Group Layout"), + let state_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("State Uniform Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let window_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Window Uniform Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let camera_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Camera Uniform Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Uniform Bind Group"), + layout: &uniform_bind_group_layout, entries: &[ - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 0, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, + resource: state_uniform_buffer.as_entire_binding(), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 1, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, + resource: window_uniform_buffer.as_entire_binding(), }, - wgpu::BindGroupLayoutEntry { + wgpu::BindGroupEntry { binding: 2, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, + resource: camera_uniform_buffer.as_entire_binding(), }, ], }); - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Texture Bind Group Layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - - let state_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("State Uniform Buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - let window_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Window Uniform Buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - let camera_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Camera Uniform Buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[&bind_group_layout, &uniform_bind_group_layout], + push_constant_ranges: &[], + }); - let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Uniform Bind Group"), - layout: &uniform_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: state_uniform_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: window_uniform_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: camera_uniform_buffer.as_entire_binding(), + let swapchain_format = wgpu::TextureFormat::Bgra8Unorm; + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), }, - ], - }); - - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: None, - bind_group_layouts: &[&bind_group_layout, &uniform_bind_group_layout], - push_constant_ranges: &[], - }); + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: swapchain_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: Default::default(), + multiview: None, + cache: None, + }); - let swapchain_format = wgpu::TextureFormat::Bgra8Unorm; - let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: None, - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: swapchain_format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState::default(), - depth_stencil: None, - multisample: Default::default(), - multiview: None, - cache: None, - }); + let surface_capabilities = surface.get_capabilities(&adapter); + let alpha_mode = preferred_alpha_mode(&surface_capabilities.alpha_modes); - let surface_capabilities = surface.get_capabilities(&adapter); - let alpha_mode = preferred_alpha_mode(&surface_capabilities.alpha_modes); + let present_mode = if surface_capabilities + .present_modes + .contains(&wgpu::PresentMode::Mailbox) + { + wgpu::PresentMode::Mailbox + } else { + wgpu::PresentMode::Fifo + }; - let present_mode = if surface_capabilities - .present_modes - .contains(&wgpu::PresentMode::Mailbox) - { - wgpu::PresentMode::Mailbox - } else { - wgpu::PresentMode::Fifo - }; + let surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: swapchain_format, + width: window_width, + height: window_height, + present_mode, + alpha_mode, + view_formats: vec![], + desired_maximum_frame_latency: 1, + }; - let surface_config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: swapchain_format, - width: window_width, - height: window_height, - present_mode, - alpha_mode, - view_formats: vec![], - desired_maximum_frame_latency: 1, - }; + surface.configure(&device, &surface_config); - surface.configure(&device, &surface_config); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - address_mode_u: wgpu::AddressMode::ClampToEdge, - address_mode_v: wgpu::AddressMode::ClampToEdge, - address_mode_w: wgpu::AddressMode::ClampToEdge, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); + let mut renderer = Renderer { + surface: Some(surface), + surface_config, + surface_scale, + render_pipeline, + device, + queue, + sampler, + bind_group_layout, + state_uniform_buffer, + window_uniform_buffer, + camera_uniform_buffer, + uniform_bind_group, + texture: Cached::default(), + aspect_ratio: Cached::default(), + blur_processor: None, + blur_processor_init_attempted: false, + blur_source_texture: None, + #[cfg(target_os = "macos")] + native_converter: None, + #[cfg(target_os = "macos")] + native_converter_init_attempted: false, + }; - let mut renderer = Renderer { - surface: Some(surface), - surface_config, - surface_scale, - render_pipeline, - device, - queue, - sampler, - bind_group_layout, - state_uniform_buffer, - window_uniform_buffer, - camera_uniform_buffer, - uniform_bind_group, - texture: Cached::default(), - aspect_ratio: Cached::default(), - blur_processor: None, - blur_processor_init_attempted: false, - blur_source_texture: None, - #[cfg(target_os = "macos")] - native_converter: None, - #[cfg(target_os = "macos")] - native_converter_init_attempted: false, - }; + renderer.update_state_uniforms(default_state); + renderer + .sync_ratio_uniform_and_resize_window_to_it(&window, default_state, aspect) + .await; + renderer.reconfigure_gpu_surface(window_width, window_height); - renderer.update_state_uniforms(default_state); - renderer - .sync_ratio_uniform_and_resize_window_to_it(&window, default_state, aspect) - .await; - renderer.reconfigure_gpu_surface(window_width, window_height); + let initial_surface = renderer.acquire_surface_texture(); + if let Some(surface) = initial_surface { + let output_width = 5; + let output_height = 5; - let initial_surface = renderer.acquire_surface_texture(); - if let Some(surface) = initial_surface { - let output_width = 5; - let output_height = 5; + let (buffer, stride) = + render_solid_frame([0x11, 0x11, 0x11, 0xFF], output_width, output_height); - let (buffer, stride) = - render_solid_frame([0x11, 0x11, 0x11, 0xFF], output_width, output_height); + PreparedTexture::init( + renderer.device.clone(), + renderer.queue.clone(), + &renderer.sampler, + &renderer.bind_group_layout, + renderer.uniform_bind_group.clone(), + renderer.render_pipeline.clone(), + output_width, + output_height, + ) + .render(&surface, &buffer, stride); + surface.present(); + } - PreparedTexture::init( - renderer.device.clone(), - renderer.queue.clone(), - &renderer.sampler, - &renderer.bind_group_layout, - renderer.uniform_bind_group.clone(), - renderer.render_pipeline.clone(), - output_width, - output_height, - ) - .render(&surface, &buffer, stride); - surface.present(); + return Ok(renderer); } - Ok(renderer) + let _ = (window, default_state, instance); + Err(anyhow!( + "Direct native-window camera rendering is unavailable under Electron" + )) } } @@ -1705,65 +1714,9 @@ async fn resize_window( .run_on_main_thread({ let window = window.clone(); move || { - let result: tauri::Result<(u32, u32, f64)> = (|| { + let result: cap_desktop_runtime::Result<(u32, u32, f64)> = (|| { if should_move { - let (monitor_size, monitor_offset, monitor_scale_factor): ( - PhysicalSize, - LogicalPosition, - _, - ) = if let Some(monitor) = window.current_monitor()? { - let size = monitor.position().to_logical(monitor.scale_factor()); - (*monitor.size(), size, monitor.scale_factor()) - } else { - (PhysicalSize::new(640, 360), LogicalPosition::new(0, 0), 1.0) - }; - - let x = (monitor_size.width as f64 / monitor_scale_factor - - window_width as f64 - - 100.0) as u32 - + monitor_offset.x; - let y = (monitor_size.height as f64 / monitor_scale_factor - - window_height as f64 - - 100.0) as u32 - + monitor_offset.y; - - window.set_position(LogicalPosition::new(x, y))?; - } else if let Some(monitor) = window.current_monitor()? { - let scale_factor = monitor.scale_factor(); - let monitor_pos = monitor.position().to_logical::(scale_factor); - let monitor_size = monitor.size().to_logical::(scale_factor); - - let current_pos = window - .outer_position() - .map(|p| p.to_logical::(scale_factor)) - .unwrap_or(monitor_pos); - - let mut new_x = current_pos.x; - let mut new_y = current_pos.y; - let new_width = window_width as f64; - let new_height = window_height as f64; - - if new_x + new_width > monitor_pos.x + monitor_size.width { - new_x = monitor_pos.x + monitor_size.width - new_width; - } - - if new_y + new_height > monitor_pos.y + monitor_size.height { - new_y = monitor_pos.y + monitor_size.height - new_height; - } - - if new_x < monitor_pos.x { - new_x = monitor_pos.x; - } - - if new_y < monitor_pos.y { - new_y = monitor_pos.y; - } - - if (new_x - current_pos.x).abs() > 1.0 - || (new_y - current_pos.y).abs() > 1.0 - { - window.set_position(LogicalPosition::new(new_x, new_y))?; - } + window.set_position(LogicalPosition::new(100.0, 100.0))?; } window.set_size(LogicalSize::new(window_width, window_height))?; @@ -1780,11 +1733,12 @@ async fn resize_window( tx.send(result).ok(); } }) + .map_err(anyhow::Error::msg) .context("Failed to run window resize on main thread")?; rx.await .context("Failed to receive window resize result")? - .map_err(anyhow::Error::new) + .map_err(anyhow::Error::msg) } #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/camera.wgsl b/apps/desktop/src-backend/src/camera.wgsl similarity index 100% rename from apps/desktop/src-tauri/src/camera.wgsl rename to apps/desktop/src-backend/src/camera.wgsl diff --git a/apps/desktop/src-tauri/src/camera_legacy.rs b/apps/desktop/src-backend/src/camera_legacy.rs similarity index 100% rename from apps/desktop/src-tauri/src/camera_legacy.rs rename to apps/desktop/src-backend/src/camera_legacy.rs diff --git a/apps/desktop/src-tauri/src/camera_native.rs b/apps/desktop/src-backend/src/camera_native.rs similarity index 100% rename from apps/desktop/src-tauri/src/camera_native.rs rename to apps/desktop/src-backend/src/camera_native.rs diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-backend/src/captions.rs similarity index 99% rename from apps/desktop/src-tauri/src/captions.rs rename to apps/desktop/src-backend/src/captions.rs index 53cd4abdc44..8b5bef5e947 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-backend/src/captions.rs @@ -1,5 +1,7 @@ use anyhow::Result; use cap_audio::AudioData; +use cap_desktop_runtime::Event; +use cap_desktop_runtime::{AppHandle, Manager}; use ffmpeg::{ ChannelLayout, codec as avcodec, format::{self as avformat}, @@ -16,8 +18,6 @@ use std::io::Read; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use tauri::{AppHandle, Manager}; -use tauri_specta::Event; use tempfile::tempdir; use tokio::io::AsyncWriteExt; use tokio::sync::{Mutex, Notify}; @@ -196,14 +196,14 @@ fn validate_model_path(app: &AppHandle, path: &str) -> Result { resolve_path_with_base(&app_data_dir, path) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub async fn create_dir(path: String, _recursive: bool) -> Result<(), String> { std::fs::create_dir_all(path).map_err(|e| format!("Failed to create directory: {e}")) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub async fn save_model_file(path: String, data: Vec) -> Result<(), String> { @@ -1309,7 +1309,7 @@ fn process_with_parakeet( Err(PARAKEET_UNSUPPORTED_MESSAGE.to_string()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub async fn transcribe_audio( @@ -1438,7 +1438,7 @@ pub async fn transcribe_audio( } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn save_captions( @@ -1841,7 +1841,7 @@ pub fn parse_captions_json(json: &str) -> Result Result Ok(captions_dir) } -#[derive(Debug, Serialize, Type, tauri_specta::Event, Clone)] +#[derive(Debug, Serialize, Type, cap_desktop_runtime::Event, Clone)] pub struct DownloadProgress { pub progress: f64, pub message: String, @@ -2046,7 +2046,7 @@ async fn clear_model_download_status(path: &Path) { let _ = downloads.remove(&key); } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn get_model_download_status( @@ -2059,7 +2059,7 @@ pub async fn get_model_download_status( Ok(downloads.get(&key).map(|entry| entry.status.clone())) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn download_whisper_model( @@ -2076,7 +2076,7 @@ pub async fn download_whisper_model( let download_app = app.clone(); let download_key = key.clone(); - tauri::async_runtime::spawn(async move { + cap_desktop_runtime::async_runtime::spawn(async move { let result = download_whisper_model_to_path( &download_app, &model_name, @@ -2217,7 +2217,7 @@ async fn total_content_length(client: &reqwest::Client, urls: &[&str]) -> u64 { total } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn check_model_exists(app: AppHandle, model_path: String) -> Result { @@ -2225,7 +2225,7 @@ pub async fn check_model_exists(app: AppHandle, model_path: String) -> Result Result<(), String> { @@ -2418,7 +2418,7 @@ fn finalize_parakeet_model_download( } #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn download_parakeet_model(app: AppHandle, output_dir: String) -> Result<(), String> { @@ -2431,7 +2431,7 @@ pub async fn download_parakeet_model(app: AppHandle, output_dir: String) -> Resu let download_app = app.clone(); let download_key = key.clone(); - tauri::async_runtime::spawn(async move { + cap_desktop_runtime::async_runtime::spawn(async move { let result = download_parakeet_model_to_dir(&download_app, &validated_dir, &download_key).await; let final_status = match &result { @@ -2565,7 +2565,7 @@ async fn download_parakeet_model_to_dir( } #[cfg(all(target_os = "macos", target_arch = "x86_64"))] -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(_app))] pub async fn download_parakeet_model(_app: AppHandle, _output_dir: String) -> Result<(), String> { @@ -2573,7 +2573,7 @@ pub async fn download_parakeet_model(_app: AppHandle, _output_dir: String) -> Re } #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn check_parakeet_model_exists( @@ -2597,7 +2597,7 @@ pub async fn check_parakeet_model_exists( } #[cfg(all(target_os = "macos", target_arch = "x86_64"))] -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(_app))] pub async fn check_parakeet_model_exists( @@ -2607,7 +2607,7 @@ pub async fn check_parakeet_model_exists( Ok(false) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn delete_parakeet_model(app: AppHandle, model_dir: String) -> Result<(), String> { @@ -2652,7 +2652,7 @@ fn format_srt_time(seconds: f64) -> String { format!("{hours:02}:{minutes:02}:{secs:02},{millis:03}") } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] pub async fn export_captions_srt( diff --git a/apps/desktop/src-tauri/src/cli.rs b/apps/desktop/src-backend/src/cli.rs similarity index 80% rename from apps/desktop/src-tauri/src/cli.rs rename to apps/desktop/src-backend/src/cli.rs index d258d170dd0..dca779ff244 100644 --- a/apps/desktop/src-tauri/src/cli.rs +++ b/apps/desktop/src-backend/src/cli.rs @@ -1,18 +1,18 @@ pub use cap_cli_install::CliInstallStatus; -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub fn get_cli_install_status() -> Result { cap_cli_install::status() } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub fn install_cli() -> Result { cap_cli_install::install() } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub fn uninstall_cli() -> Result { cap_cli_install::uninstall() diff --git a/apps/desktop/src-tauri/src/clip_thumbnails.rs b/apps/desktop/src-backend/src/clip_thumbnails.rs similarity index 99% rename from apps/desktop/src-tauri/src/clip_thumbnails.rs rename to apps/desktop/src-backend/src/clip_thumbnails.rs index bc898f7fc19..82c94ea7773 100644 --- a/apps/desktop/src-tauri/src/clip_thumbnails.rs +++ b/apps/desktop/src-backend/src/clip_thumbnails.rs @@ -11,7 +11,7 @@ const SEEK_DECODE_PACKET_LIMIT: usize = 240; static THUMBNAIL_SEMAPHORE: LazyLock = LazyLock::new(|| Semaphore::new(4)); -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[tracing::instrument(skip(editor_instance))] pub async fn get_clip_thumbnail( diff --git a/apps/desktop/src-tauri/src/crash_sentinel.rs b/apps/desktop/src-backend/src/crash_sentinel.rs similarity index 98% rename from apps/desktop/src-tauri/src/crash_sentinel.rs rename to apps/desktop/src-backend/src/crash_sentinel.rs index f394c8f9b65..51258a52539 100644 --- a/apps/desktop/src-tauri/src/crash_sentinel.rs +++ b/apps/desktop/src-backend/src/crash_sentinel.rs @@ -106,10 +106,10 @@ pub fn init(logs_dir: &Path, app_version: &str) -> Option let os = format!( "{} {}", - tauri_plugin_os::platform(), - tauri_plugin_os::version() + std::env::consts::OS, + sysinfo::System::long_os_version().unwrap_or_else(|| "unknown".to_string()) ); - let arch = tauri_plugin_os::arch().to_string(); + let arch = std::env::consts::ARCH.to_string(); let record = SessionRecord { pid: std::process::id(), diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-backend/src/deeplink_actions.rs similarity index 99% rename from apps/desktop/src-tauri/src/deeplink_actions.rs rename to apps/desktop/src-backend/src/deeplink_actions.rs index 6eb7f4c8939..70f7be70a37 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-backend/src/deeplink_actions.rs @@ -1,15 +1,15 @@ +use cap_desktop_runtime::{AppHandle, Manager, Url}; use cap_recording::{ RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, }; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Manager, Url}; use tracing::trace; use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; #[cfg(debug_assertions)] -use tauri::Emitter; +use cap_desktop_runtime::Emitter; #[cfg(debug_assertions)] use crate::camera::CameraPreviewState; diff --git a/apps/desktop/src-tauri/src/editor_window.rs b/apps/desktop/src-backend/src/editor_window.rs similarity index 90% rename from apps/desktop/src-tauri/src/editor_window.rs rename to apps/desktop/src-backend/src/editor_window.rs index c1620a1b222..2f21e6864ff 100644 --- a/apps/desktop/src-tauri/src/editor_window.rs +++ b/apps/desktop/src-backend/src/editor_window.rs @@ -1,14 +1,14 @@ +use cap_desktop_runtime::{AppHandle, CommandContext, Listener, Manager, Window, ipc::CommandArg}; use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc, time::Instant}; -use tauri::{AppHandle, Listener, Manager, Runtime, Window, ipc::CommandArg}; use tokio::sync::{RwLock, watch}; use tokio_util::sync::CancellationToken; +use cap_desktop_runtime::Event; use cap_rendering::GpuOutputFormat; -use tauri_specta::Event; use crate::{ FrameLayoutEvent, create_editor_instance_impl, - frame_ws::{WSFrame, WSFrameFormat, create_watch_frame_ws}, + frame_ws::{WSFrame, WSFrameFormat, create_watch_frame_ws_with_output_capability}, }; /// Forwards rendered frames to the preview websocket and mirrors each frame's @@ -61,7 +61,7 @@ pub struct EditorInstance { pub ws_port: u16, pub ws_shutdown_token: CancellationToken, app_handle: AppHandle, - render_frame_event_id: tauri::EventId, + render_frame_event_id: cap_desktop_runtime::EventId, } type PendingResult = Result, String>; @@ -72,10 +72,21 @@ pub struct PendingEditorInstances(Arc>>) async fn do_prewarm(app: AppHandle, path: PathBuf) -> PendingResult { let (frame_tx, frame_rx) = watch::channel(None); - - let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx, Default::default()).await; - let (inner, render_frame_event_id) = - create_editor_instance_impl(&app, path, make_frame_callback(app.clone(), frame_tx)).await?; + let nv12_output_available = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let (ws_port, ws_shutdown_token) = create_watch_frame_ws_with_output_capability( + frame_rx, + Default::default(), + nv12_output_available.clone(), + ) + .await; + let (inner, render_frame_event_id) = create_editor_instance_impl( + &app, + path, + make_frame_callback(app.clone(), frame_tx), + nv12_output_available, + ) + .await?; Ok(Arc::new(EditorInstance { inner, @@ -256,11 +267,11 @@ impl AsRef for WindowEditorInstance { } } -impl<'de, R: Runtime> CommandArg<'de, R> for WindowEditorInstance { +impl CommandArg for WindowEditorInstance { fn from_command( - command: tauri::ipc::CommandItem<'de, R>, - ) -> Result { - let window = Window::from_command(command)?; + context: &CommandContext, + ) -> Result { + let window = context.window(); let Some(instances) = window.try_state::() else { return Err("editor instance registry unavailable".into()); @@ -299,13 +310,11 @@ impl Deref for OptionalWindowEditorInstance { } } -impl<'de, R: Runtime> CommandArg<'de, R> for OptionalWindowEditorInstance { +impl CommandArg for OptionalWindowEditorInstance { fn from_command( - command: tauri::ipc::CommandItem<'de, R>, - ) -> Result { - let Ok(window) = Window::from_command(command) else { - return Ok(Self(None)); - }; + context: &CommandContext, + ) -> Result { + let window = context.window(); let Some(instances) = window.try_state::() else { return Ok(Self(None)); @@ -362,14 +371,20 @@ impl EditorInstances { } let (frame_tx, frame_rx) = watch::channel(None); + let nv12_output_available = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let (ws_port, ws_shutdown_token) = - create_watch_frame_ws(frame_rx, Default::default()).await; + let (ws_port, ws_shutdown_token) = create_watch_frame_ws_with_output_capability( + frame_rx, + Default::default(), + nv12_output_available.clone(), + ) + .await; let app_handle = window.app_handle().clone(); let (inner, render_frame_event_id) = create_editor_instance_impl( window.app_handle(), path, make_frame_callback(app_handle.clone(), frame_tx), + nv12_output_available, ) .await?; diff --git a/apps/desktop/src-tauri/src/exit_shutdown.rs b/apps/desktop/src-backend/src/exit_shutdown.rs similarity index 100% rename from apps/desktop/src-tauri/src/exit_shutdown.rs rename to apps/desktop/src-backend/src/exit_shutdown.rs diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-backend/src/export.rs similarity index 97% rename from apps/desktop/src-tauri/src/export.rs rename to apps/desktop/src-backend/src/export.rs index d0fbe410cb8..594207e64e9 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-backend/src/export.rs @@ -1,5 +1,6 @@ use crate::editor_window::{OptionalWindowEditorInstance, WindowEditorInstance}; use crate::{FramesRendered, get_video_metadata}; +use cap_desktop_runtime::Manager; use cap_export::{ExporterBase, make_cursor_only_project}; use cap_project::{RecordingMeta, TimelineFrameMapping, XY}; use cap_rendering::{ @@ -22,8 +23,6 @@ use std::{ atomic::{AtomicBool, AtomicUsize, Ordering}, }, }; -use tauri::Manager; -use tauri_plugin_dialog::DialogExt; use tokio::io::AsyncBufReadExt; use tokio_util::sync::CancellationToken; use tracing::{error, info, instrument, warn}; @@ -177,10 +176,10 @@ impl ExportWorkerMode { } #[derive(Clone)] -struct ExportProgress(tauri::ipc::Channel); +struct ExportProgress(cap_desktop_runtime::ipc::Channel); struct ExportSaveDialogRequest { - app: tauri::AppHandle, + app: cap_desktop_runtime::AppHandle, file_name: String, file_type: String, } @@ -337,17 +336,17 @@ pub fn cancel_all_exports() { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub fn cancel_export(export_id: String) -> bool { cancel_export_by_id(&export_id) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(window))] -pub fn cancel_current_window_exports(window: tauri::Window) { +pub fn cancel_current_window_exports(window: cap_desktop_runtime::Window) { cancel_exports_for_window(window.label()); } @@ -441,15 +440,15 @@ fn release_all_window_export_sessions() -> usize { count } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] -pub fn begin_export_session(window: tauri::Window) { +pub fn begin_export_session(window: cap_desktop_runtime::Window) { retain_export_session_for_window(window.label()); } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] -pub fn end_export_session(window: tauri::Window) { +pub fn end_export_session(window: cap_desktop_runtime::Window) { release_export_session_for_window(window.label()); } @@ -711,12 +710,12 @@ fn resolve_exporter_binary() -> Result { } Err(format!( - "Export worker binary not found; place {} next to the app executable or build the Tauri sidecar bundle", + "Export worker binary not found; place {} next to the app executable or build the Electron sidecar bundle", exporter_bin_name() )) } -/// Dev-loop trap: `tauri dev` rebuilds the app but not the exporter sidecar, +/// Dev-loop trap: the frontend dev server rebuilds the app but not the exporter sidecar, /// so exports can silently run renderer code from days earlier and disagree /// with the preview. Debug builds log loudly when that is happening. fn warn_if_exporter_stale(app_exe: &Path, exporter: &Path) { @@ -748,7 +747,7 @@ fn exporter_binary_candidates(root: &Path) -> Vec { candidates.push( root.join("apps") .join("desktop") - .join("src-tauri") + .join("src-backend") .join("binaries") .join(exporter_bin_name()), ); @@ -757,7 +756,7 @@ fn exporter_binary_candidates(root: &Path) -> Vec { candidates.push( root.join("apps") .join("desktop") - .join("src-tauri") + .join("src-backend") .join("binaries") .join(format!( "cap-exporter-{target_triple}{}", @@ -1041,13 +1040,13 @@ fn should_use_out_of_process_export() -> bool { cfg!(any(target_os = "macos", target_os = "windows")) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(window, progress, editor))] pub async fn export_video( - window: tauri::Window, + window: cap_desktop_runtime::Window, project_path: PathBuf, - progress: tauri::ipc::Channel, + progress: cap_desktop_runtime::ipc::Channel, settings: ExportSettings, editor: OptionalWindowEditorInstance, ) -> Result { @@ -1067,13 +1066,13 @@ pub async fn export_video( .await } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(window, progress, editor))] pub async fn export_video_with_id( - window: tauri::Window, + window: cap_desktop_runtime::Window, project_path: PathBuf, - progress: tauri::ipc::Channel, + progress: cap_desktop_runtime::ipc::Channel, settings: ExportSettings, export_id: String, editor: OptionalWindowEditorInstance, @@ -1093,13 +1092,13 @@ pub async fn export_video_with_id( .await } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(window, progress, editor))] pub async fn export_video_to_file( - window: tauri::Window, + window: cap_desktop_runtime::Window, project_path: PathBuf, - progress: tauri::ipc::Channel, + progress: cap_desktop_runtime::ipc::Channel, settings: ExportSettings, file_name: String, file_type: String, @@ -1267,7 +1266,7 @@ async fn export_video_inner( } async fn show_export_save_dialog( - app: &tauri::AppHandle, + app: &cap_desktop_runtime::AppHandle, file_name: String, file_type: String, ) -> Result, String> { @@ -1285,17 +1284,17 @@ async fn show_export_save_dialog( info!(file_name, name, extension, "Showing save file dialog"); - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog() - .file() - .set_title("Save File") - .set_file_name(file_name) - .add_filter(name, &[extension]) - .save_file(move |path| { - let _ = tx.send(path.and_then(|p| p.as_path().map(PathBuf::from))); - }); - - rx.await.map_err(|e| e.to_string()).inspect(|result| { + app.native_request::, _>( + "dialog.save", + serde_json::json!({ + "title": "Save File", + "defaultPath": file_name, + "filters": [{ "name": name, "extensions": [extension] }] + }), + ) + .await + .map(|path| path.map(PathBuf::from)) + .inspect(|result| { info!(path = ?result, "Save file dialog completed"); }) } @@ -1340,7 +1339,7 @@ pub struct ExportEstimates { pub estimated_size_mb: f64, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub async fn get_export_estimates( @@ -1448,7 +1447,7 @@ fn bpp_to_jpeg_quality(bpp: f32) -> u8 { ((bpp - 0.04) / (0.3 - 0.04) * (95.0 - 40.0) + 40.0).clamp(40.0, 95.0) as u8 } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip_all)] pub async fn generate_export_preview( @@ -1809,7 +1808,7 @@ mod tests { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip_all)] pub async fn generate_export_preview_fast( diff --git a/apps/desktop/src-tauri/src/fake_window.rs b/apps/desktop/src-backend/src/fake_window.rs similarity index 92% rename from apps/desktop/src-tauri/src/fake_window.rs rename to apps/desktop/src-backend/src/fake_window.rs index ec5b011cb06..8640de64024 100644 --- a/apps/desktop/src-tauri/src/fake_window.rs +++ b/apps/desktop/src-backend/src/fake_window.rs @@ -1,3 +1,4 @@ +use cap_desktop_runtime::{AppHandle, Manager, WebviewWindow}; use cap_recording::sources::screen_capture::ScreenCaptureTarget; use scap_targets::{Display, DisplayId, Window as ScapWindow, bounds::LogicalBounds}; use std::{ @@ -8,7 +9,6 @@ use std::{ }, time::Duration, }; -use tauri::{AppHandle, Manager, WebviewWindow}; use tokio::{sync::RwLock, time::sleep}; use tokio_util::sync::CancellationToken; use tracing::{debug, instrument}; @@ -82,14 +82,14 @@ impl FakeWindowListeners { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(state))] pub async fn set_fake_window_bounds( - window: tauri::Window, + window: cap_desktop_runtime::Window, name: String, bounds: LogicalBounds, - state: tauri::State<'_, FakeWindowBounds>, + state: cap_desktop_runtime::State<'_, FakeWindowBounds>, ) -> Result<(), String> { let mut state = state.0.write().await; let map = state.entry(window.label().to_string()).or_default(); @@ -99,13 +99,13 @@ pub async fn set_fake_window_bounds( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(state, window))] pub async fn remove_fake_window( - window: tauri::Window, + window: cap_desktop_runtime::Window, name: String, - state: tauri::State<'_, FakeWindowBounds>, + state: cap_desktop_runtime::State<'_, FakeWindowBounds>, ) -> Result<(), String> { let mut state = state.0.write().await; let Some(map) = state.get_mut(window.label()) else { @@ -122,8 +122,8 @@ pub async fn remove_fake_window( } fn should_ignore_cursor_events( - window_position: tauri::PhysicalPosition, - mouse_position: tauri::PhysicalPosition, + window_position: cap_desktop_runtime::PhysicalPosition, + mouse_position: cap_desktop_runtime::PhysicalPosition, scale_factor: f64, windows: &HashMap, default_ignore: bool, @@ -171,7 +171,7 @@ fn should_ignore_cursor_events( } fn recording_controls_size_allows_default_interaction( - window_size: tauri::PhysicalSize, + window_size: cap_desktop_runtime::PhysicalSize, scale_factor: f64, ) -> bool { let scale_factor = scale_factor.max(1.0); @@ -198,7 +198,7 @@ fn prepare_recording_controls_default_interaction(window: &WebviewWindow) -> boo return false; } - let _ = window.set_size(tauri::LogicalSize::new( + let _ = window.set_size(cap_desktop_runtime::LogicalSize::new( RECORDING_CONTROLS_WIDTH, RECORDING_CONTROLS_HEIGHT, )); @@ -536,8 +536,8 @@ mod tests { #[test] fn recording_controls_without_bounds_are_interactive() { assert!(!should_ignore_cursor_events( - tauri::PhysicalPosition::new(100, 200), - tauri::PhysicalPosition::new(150.0, 240.0), + cap_desktop_runtime::PhysicalPosition::new(100, 200), + cap_desktop_runtime::PhysicalPosition::new(150.0, 240.0), 2.0, &HashMap::new(), false, @@ -548,8 +548,8 @@ mod tests { #[test] fn recording_controls_without_bounds_pass_through_when_window_is_large() { assert!(should_ignore_cursor_events( - tauri::PhysicalPosition::new(0, 0), - tauri::PhysicalPosition::new(150.0, 240.0), + cap_desktop_runtime::PhysicalPosition::new(0, 0), + cap_desktop_runtime::PhysicalPosition::new(150.0, 240.0), 2.0, &HashMap::new(), false, @@ -560,8 +560,8 @@ mod tests { #[test] fn non_recording_windows_without_bounds_pass_through() { assert!(should_ignore_cursor_events( - tauri::PhysicalPosition::new(100, 200), - tauri::PhysicalPosition::new(150.0, 240.0), + cap_desktop_runtime::PhysicalPosition::new(100, 200), + cap_desktop_runtime::PhysicalPosition::new(150.0, 240.0), 2.0, &HashMap::new(), true, @@ -575,8 +575,8 @@ mod tests { windows.insert("controls".to_string(), bounds(10.0, 20.0, 100.0, 40.0)); assert!(!should_ignore_cursor_events( - tauri::PhysicalPosition::new(100, 200), - tauri::PhysicalPosition::new(150.0, 250.0), + cap_desktop_runtime::PhysicalPosition::new(100, 200), + cap_desktop_runtime::PhysicalPosition::new(150.0, 250.0), 2.0, &windows, false, @@ -590,8 +590,8 @@ mod tests { windows.insert("controls".to_string(), bounds(10.0, 20.0, 100.0, 40.0)); assert!(should_ignore_cursor_events( - tauri::PhysicalPosition::new(100, 200), - tauri::PhysicalPosition::new(50.0, 250.0), + cap_desktop_runtime::PhysicalPosition::new(100, 200), + cap_desktop_runtime::PhysicalPosition::new(50.0, 250.0), 2.0, &windows, false, @@ -605,8 +605,8 @@ mod tests { windows.insert("controls".to_string(), bounds(10.0, 20.0, 0.0, 40.0)); assert!(!should_ignore_cursor_events( - tauri::PhysicalPosition::new(100, 200), - tauri::PhysicalPosition::new(50.0, 250.0), + cap_desktop_runtime::PhysicalPosition::new(100, 200), + cap_desktop_runtime::PhysicalPosition::new(50.0, 250.0), 2.0, &windows, false, @@ -620,8 +620,8 @@ mod tests { windows.insert("controls".to_string(), bounds(0.0, 0.0, 1440.0, 900.0)); assert!(should_ignore_cursor_events( - tauri::PhysicalPosition::new(0, 0), - tauri::PhysicalPosition::new(150.0, 240.0), + cap_desktop_runtime::PhysicalPosition::new(0, 0), + cap_desktop_runtime::PhysicalPosition::new(150.0, 240.0), 2.0, &windows, false, @@ -632,11 +632,11 @@ mod tests { #[test] fn recording_controls_default_interaction_is_size_limited() { assert!(recording_controls_size_allows_default_interaction( - tauri::PhysicalSize::new(640, 300), + cap_desktop_runtime::PhysicalSize::new(640, 300), 2.0, )); assert!(!recording_controls_size_allows_default_interaction( - tauri::PhysicalSize::new(1920, 1080), + cap_desktop_runtime::PhysicalSize::new(1920, 1080), 2.0, )); } diff --git a/apps/desktop/src-backend/src/flags.rs b/apps/desktop/src-backend/src/flags.rs new file mode 100644 index 00000000000..8b7233ea640 --- /dev/null +++ b/apps/desktop/src-backend/src/flags.rs @@ -0,0 +1,3 @@ +pub fn value() -> serde_json::Value { + serde_json::to_value(&cap_flags::FLAGS).unwrap_or_default() +} diff --git a/apps/desktop/src-tauri/src/frame_ws.rs b/apps/desktop/src-backend/src/frame_ws.rs similarity index 79% rename from apps/desktop/src-tauri/src/frame_ws.rs rename to apps/desktop/src-backend/src/frame_ws.rs index 4c78a129620..aba472cd5f3 100644 --- a/apps/desktop/src-tauri/src/frame_ws.rs +++ b/apps/desktop/src-backend/src/frame_ws.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Instant; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::{broadcast, watch}; @@ -143,6 +143,68 @@ struct WsFrameStatsWindow { max_created_to_sent_ns: u64, } +#[derive(Default)] +struct OutputCapabilityCounts { + subscribers: usize, + nv12_capable_subscribers: usize, +} + +struct SubscriberCapabilityGuard { + counts: Arc>, + nv12_output_available: Arc, + nv12_capable: bool, +} + +impl SubscriberCapabilityGuard { + fn new( + counts: Arc>, + nv12_output_available: Arc, + ) -> Self { + let guard = Self { + counts, + nv12_output_available, + nv12_capable: false, + }; + guard.update_counts(|counts| counts.subscribers += 1); + guard + } + + fn update_counts(&self, update: impl FnOnce(&mut OutputCapabilityCounts)) { + let mut counts = self.counts.lock().unwrap_or_else(|e| e.into_inner()); + update(&mut counts); + self.nv12_output_available.store( + counts.subscribers > 0 && counts.nv12_capable_subscribers == counts.subscribers, + Ordering::Release, + ); + } + + fn set_nv12_capable(&mut self, capable: bool) { + if capable == self.nv12_capable { + return; + } + self.update_counts(|counts| { + if capable { + counts.nv12_capable_subscribers += 1; + } else { + counts.nv12_capable_subscribers = counts.nv12_capable_subscribers.saturating_sub(1); + } + }); + self.nv12_capable = capable; + } +} + +impl Drop for SubscriberCapabilityGuard { + fn drop(&mut self) { + let nv12_capable = self.nv12_capable; + self.update_counts(|counts| { + counts.subscribers = counts.subscribers.saturating_sub(1); + if nv12_capable { + counts.nv12_capable_subscribers = counts.nv12_capable_subscribers.saturating_sub(1); + } + }); + } +} + struct SubscriberCountGuard { subscribers: Arc, instant_subscribers: Option>, @@ -175,7 +237,15 @@ pub async fn create_watch_frame_ws( frame_rx: watch::Receiver>>, subscribers: Arc, ) -> (u16, CancellationToken) { - create_watch_frame_ws_inner(frame_rx, subscribers, None).await + create_watch_frame_ws_inner(frame_rx, subscribers, None, None).await +} + +pub async fn create_watch_frame_ws_with_output_capability( + frame_rx: watch::Receiver>>, + subscribers: Arc, + nv12_output_available: Arc, +) -> (u16, CancellationToken) { + create_watch_frame_ws_inner(frame_rx, subscribers, None, Some(nv12_output_available)).await } pub async fn create_watch_frame_ws_with_instant_tracking( @@ -183,13 +253,14 @@ pub async fn create_watch_frame_ws_with_instant_tracking( subscribers: Arc, instant_subscribers: Arc, ) -> (u16, CancellationToken) { - create_watch_frame_ws_inner(frame_rx, subscribers, Some(instant_subscribers)).await + create_watch_frame_ws_inner(frame_rx, subscribers, Some(instant_subscribers), None).await } async fn create_watch_frame_ws_inner( frame_rx: watch::Receiver>>, subscribers: Arc, instant_subscribers: Option>, + nv12_output_available: Option>, ) -> (u16, CancellationToken) { use axum::{ extract::{ @@ -200,20 +271,41 @@ async fn create_watch_frame_ws_inner( routing::get, }; + let output_capability_counts = nv12_output_available + .as_ref() + .map(|_| Arc::new(Mutex::new(OutputCapabilityCounts::default()))); + type RouterState = ( watch::Receiver>>, Arc, Option>, + Option>, + Option>>, ); #[axum::debug_handler] async fn ws_handler( ws: WebSocketUpgrade, Query(query): Query, - State((state, subscribers, instant_subscribers)): State, + State(( + state, + subscribers, + instant_subscribers, + nv12_output_available, + output_capability_counts, + )): State, ) -> impl IntoResponse { let instant_subscribers = query.instant.then_some(instant_subscribers).flatten(); - ws.on_upgrade(move |socket| handle_socket(socket, state, subscribers, instant_subscribers)) + ws.on_upgrade(move |socket| { + handle_socket( + socket, + state, + subscribers, + instant_subscribers, + nv12_output_available, + output_capability_counts, + ) + }) } async fn handle_socket( @@ -221,6 +313,8 @@ async fn create_watch_frame_ws_inner( mut camera_rx: watch::Receiver>>, subscribers: Arc, instant_subscribers: Option>, + nv12_output_available: Option>, + output_capability_counts: Option>>, ) { tracing::info!("Socket connection established"); let now = std::time::Instant::now(); @@ -234,6 +328,9 @@ async fn create_watch_frame_ws_inner( subscribers, instant_subscribers, }; + let mut subscriber_capability = nv12_output_available + .zip(output_capability_counts) + .map(|(available, counts)| SubscriberCapabilityGuard::new(counts, available)); { let packed = { @@ -274,6 +371,19 @@ async fn create_watch_frame_ws_inner( tracing::info!("WebSocket closed"); break; } + Some(Ok(Message::Text(message))) => { + if let Some(subscriber_capability) = &mut subscriber_capability { + match message.as_str() { + "cap-preview:nv12-webgpu" => { + subscriber_capability.set_nv12_capable(true); + } + "cap-preview:rgba" => { + subscriber_capability.set_nv12_capable(false); + } + _ => {} + } + } + } Some(Ok(_)) => {} Some(Err(e)) => { if is_normal_socket_disconnect(&e) { @@ -357,6 +467,8 @@ async fn create_watch_frame_ws_inner( frame_rx, subscribers, instant_subscribers, + nv12_output_available, + output_capability_counts, )); let cancel_token = CancellationToken::new(); @@ -562,4 +674,34 @@ mod tests { assert_eq!(subscribers.load(Ordering::Acquire), 0); assert_eq!(instant_subscribers.load(Ordering::Acquire), 0); } + + #[test] + fn last_subscriber_resets_output_capability() { + let nv12_output_available = Arc::new(AtomicBool::new(true)); + let counts = Arc::new(Mutex::new(OutputCapabilityCounts::default())); + let mut subscriber = SubscriberCapabilityGuard::new(counts, nv12_output_available.clone()); + subscriber.set_nv12_capable(true); + + drop(subscriber); + + assert!(!nv12_output_available.load(Ordering::Acquire)); + } + + #[test] + fn nv12_requires_every_active_subscriber_to_be_capable() { + let counts = Arc::new(Mutex::new(OutputCapabilityCounts::default())); + let nv12_output_available = Arc::new(AtomicBool::new(false)); + let mut capable = + SubscriberCapabilityGuard::new(counts.clone(), nv12_output_available.clone()); + let mut fallback = SubscriberCapabilityGuard::new(counts, nv12_output_available.clone()); + + capable.set_nv12_capable(true); + assert!(!nv12_output_available.load(Ordering::Acquire)); + fallback.set_nv12_capable(true); + assert!(nv12_output_available.load(Ordering::Acquire)); + fallback.set_nv12_capable(false); + assert!(!nv12_output_available.load(Ordering::Acquire)); + drop(fallback); + assert!(nv12_output_available.load(Ordering::Acquire)); + } } diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-backend/src/general_settings.rs similarity index 98% rename from apps/desktop/src-tauri/src/general_settings.rs rename to apps/desktop/src-backend/src/general_settings.rs index 8ea2ce2fd9c..ab38f11df4e 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-backend/src/general_settings.rs @@ -1,14 +1,13 @@ use crate::updates::UpdateChannel; use crate::window_exclusion::WindowExclusion; +#[cfg(target_os = "macos")] +use cap_desktop_runtime::Listener; +use cap_desktop_runtime::{AppHandle, Manager, Wry}; use scap_targets::DisplayId; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; use std::collections::BTreeMap; -#[cfg(target_os = "macos")] -use tauri::Listener; -use tauri::{AppHandle, Manager, Wry}; -use tauri_plugin_store::StoreExt; use tracing::{error, instrument}; use uuid::Uuid; @@ -84,7 +83,10 @@ pub fn default_studio_recording_quality() -> StudioRecordingQuality { } impl MainWindowRecordingStartBehaviour { - pub fn perform(&self, window: &tauri::WebviewWindow) -> tauri::Result<()> { + pub fn perform( + &self, + window: &cap_desktop_runtime::WebviewWindow, + ) -> cap_desktop_runtime::Result<()> { match self { Self::Close => { // On Windows, hide() leaves the DirectComposition surface composited on screen as @@ -573,7 +575,7 @@ fn bundled_muxer_bin_name() -> &'static str { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub fn get_default_excluded_windows() -> Vec { diff --git a/apps/desktop/src-tauri/src/gpu_context.rs b/apps/desktop/src-backend/src/gpu_context.rs similarity index 100% rename from apps/desktop/src-tauri/src/gpu_context.rs rename to apps/desktop/src-backend/src/gpu_context.rs diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-backend/src/hotkeys.rs similarity index 75% rename from apps/desktop/src-tauri/src/hotkeys.rs rename to apps/desktop/src-backend/src/hotkeys.rs index 2c9a6eed18c..4a91eb75577 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-backend/src/hotkeys.rs @@ -4,48 +4,48 @@ use crate::{ tray, windows::ShowCapWindow, }; +use cap_desktop_runtime::Event; +use cap_desktop_runtime::{AppHandle, Manager}; use cap_recording::feeds::microphone::MicrophoneFeed; use cap_recording::screen_capture::ScreenCaptureTarget; -use global_hotkey::HotKeyState; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; use std::sync::Mutex; -use tauri::{AppHandle, Manager}; -use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; -use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; -use tauri_plugin_store::StoreExt; -use tauri_specta::Event; use tracing::instrument; -#[derive(Serialize, Deserialize, Type, PartialEq, Clone, Copy, Debug)] +#[derive(Serialize, Deserialize, Type, PartialEq, Clone, Debug)] pub struct Hotkey { #[specta(type = String)] - code: Code, + code: String, meta: bool, ctrl: bool, alt: bool, shift: bool, } -impl From for Shortcut { - fn from(hotkey: Hotkey) -> Self { - let mut modifiers = Modifiers::empty(); - - if hotkey.meta { - modifiers |= Modifiers::META; +impl Hotkey { + fn accelerator(&self) -> String { + let mut parts = Vec::new(); + if self.meta { + parts.push("CommandOrControl".to_string()); } - if hotkey.ctrl { - modifiers |= Modifiers::CONTROL; + if self.ctrl && !self.meta { + parts.push("Control".to_string()); } - if hotkey.alt { - modifiers |= Modifiers::ALT; + if self.alt { + parts.push("Alt".to_string()); } - if hotkey.shift { - modifiers |= Modifiers::SHIFT; + if self.shift { + parts.push("Shift".to_string()); } - - Shortcut::new(Some(modifiers), hotkey.code) + let key = self + .code + .strip_prefix("Key") + .or_else(|| self.code.strip_prefix("Digit")) + .unwrap_or(&self.code); + parts.push(key.to_string()); + parts.join("+") } } @@ -85,7 +85,7 @@ impl HotkeysStore { } } -#[derive(Serialize, Type, tauri_specta::Event, Debug, Clone)] +#[derive(Serialize, Type, cap_desktop_runtime::Event, Debug, Clone)] pub struct OnEscapePress; pub type HotkeysState = Mutex; @@ -139,20 +139,18 @@ async fn confirm_direct_recording_without_microphone(app: &AppHandle) -> bool { return true; } - let (sender, receiver) = tokio::sync::oneshot::channel(); - app.dialog() - .message("This recording will not include your voice.") - .title("No microphone detected") - .kind(MessageDialogKind::Warning) - .buttons(MessageDialogButtons::OkCancelCustom( - "Record without microphone".to_string(), - "Cancel".to_string(), - )) - .show(move |confirmed| { - let _ = sender.send(confirmed); - }); - - receiver.await.unwrap_or(false) + app.native_request::( + "dialog.confirm", + serde_json::json!({ + "message": "This recording will not include your voice.", + "title": "No microphone detected", + "kind": "warning", + "okLabel": "Record without microphone", + "cancelLabel": "Cancel" + }), + ) + .await + .unwrap_or(false) } async fn start_recording_from_hotkey( @@ -177,37 +175,6 @@ async fn start_recording_from_hotkey( } pub fn init(app: &AppHandle) { - app.plugin( - tauri_plugin_global_shortcut::Builder::new() - .with_handler(|app, shortcut, event| { - if !matches!(event.state(), HotKeyState::Pressed) { - return; - } - - if shortcut.key == Code::Escape { - OnEscapePress.emit(app).ok(); - } - - if shortcut.key == Code::Comma && shortcut.mods == Modifiers::META { - let app = app.clone(); - tokio::spawn(async move { - let _ = ShowCapWindow::Settings { page: None }.show(&app).await; - }); - } - - let state = app.state::(); - let store = state.lock().unwrap(); - - for (action, hotkey) in &store.hotkeys { - if &Shortcut::from(*hotkey) == shortcut { - tokio::spawn(handle_hotkey(app.clone(), *action)); - } - } - }) - .build(), - ) - .unwrap(); - let store = match HotkeysStore::get(app) { Ok(Some(s)) => s, Ok(None) => HotkeysStore::default(), @@ -217,12 +184,46 @@ pub fn init(app: &AppHandle) { } }; - let global_shortcut = app.global_shortcut(); - for hotkey in store.hotkeys.values() { - global_shortcut.register(Shortcut::from(*hotkey)).ok(); - } - app.manage(Mutex::new(store)); + sync_electron_hotkeys(app); + + let app_handle = app.clone(); + app.listen("hotkey://trigger", move |event| { + let Ok(action) = serde_json::from_str::(event.payload()) else { + return; + }; + tokio::spawn(handle_hotkey(app_handle.clone(), action)); + }); + let app_handle = app.clone(); + app.listen("hotkey://escape", move |_| { + OnEscapePress.emit(&app_handle).ok(); + }); + let app_handle = app.clone(); + app.listen("hotkey://settings", move |_| { + let app = app_handle.clone(); + tokio::spawn(async move { + let _ = ShowCapWindow::Settings { page: None }.show(&app).await; + }); + }); +} + +fn sync_electron_hotkeys(app: &AppHandle) { + let state = app.state::(); + let store = state.lock().unwrap(); + let hotkeys = store + .hotkeys + .iter() + .map(|(action, hotkey)| { + serde_json::json!({ + "action": action, + "accelerator": hotkey.accelerator() + }) + }) + .collect::>(); + let _ = app.native_operation( + "hotkeys.configure", + serde_json::json!({ "hotkeys": hotkeys }), + ); } async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), String> { @@ -336,32 +337,21 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin } } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] #[instrument(skip(app))] pub fn set_hotkey(app: AppHandle, action: HotkeyAction, hotkey: Option) -> Result<(), ()> { - let global_shortcut = app.global_shortcut(); let state = app.state::(); let mut store = state.lock().unwrap(); - let prev = store.hotkeys.get(&action).cloned(); - if let Some(hotkey) = hotkey { store.hotkeys.insert(action, hotkey); } else { store.hotkeys.remove(&action); } - if let Some(prev) = prev - && !store.hotkeys.values().any(|h| h == &prev) - { - global_shortcut.unregister(Shortcut::from(prev)).ok(); - } - - if let Some(hotkey) = hotkey { - global_shortcut.register(Shortcut::from(hotkey)).ok(); - } - + drop(store); + sync_electron_hotkeys(&app); Ok(()) } diff --git a/apps/desktop/src-tauri/src/http_client.rs b/apps/desktop/src-backend/src/http_client.rs similarity index 100% rename from apps/desktop/src-tauri/src/http_client.rs rename to apps/desktop/src-backend/src/http_client.rs diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-backend/src/import.rs similarity index 99% rename from apps/desktop/src-tauri/src/import.rs rename to apps/desktop/src-backend/src/import.rs index 94b4b89f722..bad30206b44 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-backend/src/import.rs @@ -1,3 +1,5 @@ +use cap_desktop_runtime::Event; +use cap_desktop_runtime::{AppHandle, Manager, Window}; use cap_enc_ffmpeg::{ AudioEncoder, h264::{H264EncoderBuilder, H264Preset}, @@ -25,8 +27,6 @@ use std::{ path::{Path, PathBuf}, str::FromStr, }; -use tauri::{AppHandle, Manager, Window}; -use tauri_specta::Event; use tracing::{debug, error, info}; use crate::{ @@ -52,7 +52,7 @@ pub enum ImportStage { Failed, } -#[derive(Serialize, Deserialize, Type, tauri_specta::Event, Clone, Debug)] +#[derive(Serialize, Deserialize, Type, cap_desktop_runtime::Event, Clone, Debug)] pub struct VideoImportProgress { pub project_path: String, pub stage: ImportStage, @@ -1356,7 +1356,7 @@ fn transcode_video( Ok((fps, sample_rate)) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result { info!("Starting video import from: {:?}", source_path); @@ -1865,7 +1865,7 @@ async fn append_cap_project_to_editor_project( Ok(copied_segments.len()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn add_existing_recording_to_editor( window: Window, @@ -1894,7 +1894,7 @@ pub async fn add_existing_recording_to_editor( Ok(imported_count) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn start_image_import(app: AppHandle, source_path: PathBuf) -> Result { info!("Starting image import from: {:?}", source_path); @@ -2016,7 +2016,7 @@ pub async fn start_image_import(app: AppHandle, source_path: PathBuf) -> Result< Ok(image_path) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn check_import_ready(project_path: PathBuf) -> Result { debug!("check_import_ready called for: {:?}", project_path); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-backend/src/lib.rs similarity index 90% rename from apps/desktop/src-tauri/src/lib.rs rename to apps/desktop/src-backend/src/lib.rs index 9c58541d522..8cdf40f56a0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-backend/src/lib.rs @@ -1,4 +1,5 @@ #![recursion_limit = "256"] +#![allow(dead_code, unused_imports)] mod api; mod audio; @@ -86,6 +87,11 @@ use screenshot_editor::{ }; mod gpu_context; +use cap_desktop_runtime::Event; +use cap_desktop_runtime::Listener; +use cap_desktop_runtime::{ + AppHandle, Emitter, Manager, PhysicalPosition, State, Window, WindowEvent, ipc::Channel, +}; pub use gpu_context::{PendingScreenshot, PendingScreenshots}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -103,15 +109,6 @@ use std::{ }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use tauri::Listener; -use tauri::{AppHandle, Emitter, Manager, State, Window, WindowEvent, ipc::Channel}; -use tauri_plugin_deep_link::DeepLinkExt; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_global_shortcut::GlobalShortcutExt; -use tauri_plugin_notification::{NotificationExt, PermissionState}; -use tauri_plugin_opener::OpenerExt; -use tauri_plugin_shell::ShellExt; -use tauri_specta::Event; use tokio::sync::{Mutex, RwLock, oneshot, watch}; use tracing::*; use upload::{create_or_get_video, upload_screenshot_bytes, upload_screenshot_file, upload_video}; @@ -129,16 +126,16 @@ use crate::{ recording_settings::{RecordingSettingsStore, RecordingTargetMode}, upload::InstantMultipartUpload, }; +#[cfg(any())] +use cap_desktop_runtime::menu::{ + AboutMetadata, HELP_SUBMENU_ID, Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID, +}; use exit_shutdown::{ AppExitAction, ExitRequestDecision, app_exit_action, collect_device_inventory, handle_exit_requested, run_while_active, }; use futures::FutureExt; use std::panic::AssertUnwindSafe; -#[cfg(target_os = "macos")] -use tauri::menu::{ - AboutMetadata, HELP_SUBMENU_ID, Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID, -}; type FinalizingRecordingsMap = std::collections::HashMap, watch::Receiver)>; @@ -476,8 +473,10 @@ fn should_show_onboarding(app: &AppHandle) -> bool { || !permissions::do_permissions_check(false).necessary_granted() } -#[cfg(target_os = "macos")] -fn build_macos_app_menu(app_handle: &AppHandle) -> tauri::Result> { +#[cfg(any())] +fn build_macos_app_menu( + app_handle: &AppHandle, +) -> cap_desktop_runtime::Result> { let pkg_info = app_handle.package_info(); let config = app_handle.config(); let about_metadata = AboutMetadata { @@ -1101,7 +1100,7 @@ impl App { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(state))] async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { @@ -1239,19 +1238,19 @@ async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> R } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] async fn upload_logs(app_handle: AppHandle) -> Result<(), String> { logging::upload_log_file(&app_handle).await } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] fn get_system_diagnostics() -> cap_recording::diagnostics::SystemDiagnostics { cap_recording::diagnostics::collect_diagnostics() } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app_handle, state))] #[allow(unused_mut)] @@ -1512,7 +1511,7 @@ async fn set_camera_input( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app_handle, state))] async fn set_native_camera_preview_enabled( @@ -1722,14 +1721,14 @@ fn spawn_device_watchers(app_handle: AppHandle) { spawn_camera_watcher(app_handle); } -#[derive(Serialize, Type, tauri_specta::Event, Debug, Clone)] +#[derive(Serialize, Type, cap_desktop_runtime::Event, Debug, Clone)] pub struct DevicesUpdated { cameras: Vec, microphones: Vec, permissions: permissions::OSPermissionsCheck, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] async fn get_devices_snapshot() -> DevicesUpdated { let permissions = permissions::do_permissions_check(false); @@ -1882,7 +1881,7 @@ fn spawn_system_resume_detector(app_handle: AppHandle) { } pub(crate) fn schedule_resume_recovery(app_handle: AppHandle) { - tauri::async_runtime::spawn(async move { + cap_desktop_runtime::async_runtime::spawn(async move { if app_is_exiting(&app_handle) { return; } @@ -2399,72 +2398,72 @@ pub(crate) fn is_camera_available(id: &DeviceOrModelID) -> bool { }) } -#[derive(specta::Type, Serialize, tauri_specta::Event, Clone)] +#[derive(specta::Type, Serialize, cap_desktop_runtime::Event, Clone)] pub struct RecordingOptionsChanged; -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct NewStudioRecordingAdded { path: PathBuf, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct EditorRecordingAdded { pub editor_path: PathBuf, pub recording_path: PathBuf, } -#[derive(Deserialize, specta::Type, tauri_specta::Event, Debug, Clone, Serialize)] +#[derive(Deserialize, specta::Type, cap_desktop_runtime::Event, Debug, Clone, Serialize)] pub struct RecordingDeleted { path: PathBuf, } -#[derive(specta::Type, tauri_specta::Event, Serialize)] +#[derive(specta::Type, cap_desktop_runtime::Event, Serialize)] pub struct SetCaptureAreaPending(bool); -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct NewScreenshotAdded { path: PathBuf, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RecordingStarted; -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RecordingStopped; -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RequestStartRecording { pub mode: RecordingMode, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RequestOpenRecordingPicker { pub target_mode: Option, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RequestSetTargetMode { pub target_mode: Option, pub display_id: Option, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RequestOpenSettings { page: String, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RequestScrollToSettingsSection { pub section: String, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct RequestScreenCapturePrewarm { #[serde(default)] pub force: bool, } -#[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, Serialize, cap_desktop_runtime::Event, Debug, Clone)] pub struct NewNotification { title: String, body: String, @@ -2532,7 +2531,7 @@ struct CurrentRecording { status: RecordingStatus, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(state))] async fn get_current_recording( @@ -2576,7 +2575,7 @@ async fn get_current_recording( }))) } -#[derive(Serialize, Type, tauri_specta::Event, Clone)] +#[derive(Serialize, Type, cap_desktop_runtime::Event, Clone)] pub struct CurrentRecordingChanged; pub(crate) async fn create_screenshot( @@ -2753,7 +2752,7 @@ fn find_first_segment(dir: &std::path::Path) -> Option { // .map_err(|e| format!("Task join error: {e}"))? // } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn copy_file_to_path(app: AppHandle, src: String, dst: String) -> Result<(), String> { @@ -2888,7 +2887,7 @@ pub fn is_valid_video(path: &std::path::Path) -> bool { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(clipboard))] async fn copy_screenshot_to_clipboard( @@ -2907,7 +2906,7 @@ async fn copy_screenshot_to_clipboard( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(clipboard, data))] async fn copy_image_to_clipboard( @@ -2926,7 +2925,7 @@ async fn copy_image_to_clipboard( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(clipboard, instance))] async fn copy_rendered_screenshot_to_clipboard( @@ -2945,7 +2944,7 @@ async fn copy_rendered_screenshot_to_clipboard( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(_app))] async fn open_file_path(_app: AppHandle, path: PathBuf) -> Result<(), String> { @@ -3006,21 +3005,21 @@ async fn open_file_path(_app: AppHandle, path: PathBuf) -> Result<(), String> { Ok(()) } -#[derive(Deserialize, specta::Type, tauri_specta::Event, Debug, Clone)] +#[derive(Deserialize, specta::Type, cap_desktop_runtime::Event, Debug, Clone)] struct RenderFrameEvent { frame_number: u32, fps: u32, resolution_base: XY, } -#[derive(Serialize, specta::Type, tauri_specta::Event, Debug, Clone)] +#[derive(Serialize, specta::Type, cap_desktop_runtime::Event, Debug, Clone)] struct EditorStateChanged { playhead_position: u32, } /// Rendered display/camera placement of the latest preview frame, in /// output-frame pixels — consumed by the editor's on-canvas layout overlay. -#[derive(Serialize, specta::Type, tauri_specta::Event, Debug, Clone, PartialEq)] +#[derive(Serialize, specta::Type, cap_desktop_runtime::Event, Debug, Clone, PartialEq)] pub(crate) struct FrameLayoutEvent { display: [f32; 4], camera: Option<[f32; 4]>, @@ -3047,7 +3046,7 @@ impl EditorStateChanged { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(editor_instance))] async fn start_playback( @@ -3060,7 +3059,7 @@ async fn start_playback( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(editor_instance))] async fn stop_playback(editor_instance: WindowEditorInstance) -> Result<(), String> { @@ -3087,7 +3086,7 @@ struct SerializedEditorInstance { notch_base: cap_project::DisplayNotch, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(window))] async fn create_editor_instance(window: Window) -> Result { @@ -3126,7 +3125,7 @@ async fn create_editor_instance(window: Window) -> Result Result { @@ -3146,7 +3145,7 @@ async fn get_editor_project_path(window: Window) -> Result { Ok(path.clone()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(editor))] async fn get_editor_meta(editor: WindowEditorInstance) -> Result { @@ -3154,13 +3153,13 @@ async fn get_editor_meta(editor: WindowEditorInstance) -> Result Result { RecordingMeta::load_for_project(&project_path).map_err(|e| e.to_string()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] async fn set_editor_recording_target( app: AppHandle, @@ -3169,7 +3168,7 @@ async fn set_editor_recording_target( EditorRecordingTarget::set(&app, project_path); Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(editor))] async fn set_pretty_name(editor: WindowEditorInstance, pretty_name: String) -> Result<(), String> { @@ -3178,7 +3177,7 @@ async fn set_pretty_name(editor: WindowEditorInstance, pretty_name: String) -> R meta.save_for_project().map_err(|e| e.to_string()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, clipboard))] async fn copy_video_to_clipboard( @@ -3196,7 +3195,7 @@ async fn copy_video_to_clipboard( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] async fn get_video_metadata(path: PathBuf) -> Result { @@ -3275,48 +3274,21 @@ async fn get_video_metadata(path: PathBuf) -> Result Result, ()> { @@ -3464,7 +3436,7 @@ pub enum UploadMode { Reupload, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, channel))] async fn upload_exported_video( @@ -3663,7 +3635,7 @@ async fn upgrade_required_result(app: &AppHandle) -> UploadResult { UploadResult::UpgradeRequired } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] fn get_screenshot_project_share_state( path: PathBuf, @@ -3679,7 +3651,7 @@ fn get_screenshot_project_share_state( Ok(ScreenshotProjectShareState { config, sharing }) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, clipboard))] async fn copy_current_screenshot_share_link( @@ -3697,7 +3669,7 @@ async fn copy_current_screenshot_share_link( Ok(Some(UploadResult::Success(link))) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, clipboard))] async fn upload_screenshot( @@ -3737,7 +3709,7 @@ async fn upload_screenshot( Ok(UploadResult::Success(uploaded.link)) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, clipboard, image_bytes))] async fn upload_rendered_screenshot( @@ -3777,7 +3749,7 @@ async fn upload_rendered_screenshot( Ok(UploadResult::Success(uploaded.link)) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn save_file_dialog( @@ -3797,8 +3769,6 @@ async fn save_file_dialog_inner( file_name: String, file_type: String, ) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - info!(file_name, file_type, "Save file dialog requested"); let file_name = file_name @@ -3819,27 +3789,17 @@ async fn save_file_dialog_inner( info!(file_name, name, extension, "Showing save file dialog"); - // Use `tokio::sync::oneshot` so the async runtime worker yields while the native dialog - // is open instead of being parked by a synchronous `std::sync::mpsc` receive. The - // previous version blocked a runtime worker for the lifetime of the dialog which, in - // release builds with fewer/active workers, could starve other tasks and let an unrelated - // exit event slip through before the export session guard incremented. - let (tx, rx) = tokio::sync::oneshot::channel(); - - app.dialog() - .file() - .set_title("Save File") - .set_file_name(file_name) - .add_filter(name, &[extension]) - .save_file(move |path| { - let _ = tx.send( - path.as_ref() - .and_then(|p| p.as_path()) - .map(|p| p.to_string_lossy().to_string()), - ); - }); - - match rx.await { + match app + .native_request::, _>( + "dialog.save", + serde_json::json!({ + "title": "Save File", + "defaultPath": file_name, + "filters": [{ "name": name, "extensions": [extension] }] + }), + ) + .await + { Ok(result) => { info!(path = ?result, "Save file dialog completed"); Ok(result) @@ -3922,7 +3882,7 @@ pub enum FileType { Screenshot, } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] #[instrument] fn get_recording_meta( @@ -3954,7 +3914,7 @@ fn media_sort_time_millis(path: &Path) -> f64 { * 1000.0 } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] fn list_recordings(app: AppHandle) -> Result, String> { @@ -3985,7 +3945,7 @@ fn list_recordings(app: AppHandle) -> Result Result<(), String> { @@ -4032,7 +3992,7 @@ async fn delete_recording_directory(app: AppHandle, path: PathBuf) -> Result<(), Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] fn list_screenshots(app: AppHandle) -> Result, String> { @@ -4074,7 +4034,7 @@ fn list_screenshots(app: AppHandle) -> Result Result { @@ -4096,10 +4056,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { return Ok(true); } - println!( - "Fetching plan for user {}", - auth.user_id.as_deref().unwrap_or("unknown") - ); + println!("Fetching plan for authenticated user"); let response = app .authed_api_request("/api/desktop/plan", |client, url| client.get(url)) .await @@ -4136,29 +4093,27 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { Ok(is_pro) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] -fn open_external_link(app: tauri::AppHandle, url: String) -> Result<(), String> { +fn open_external_link(app: cap_desktop_runtime::AppHandle, url: String) -> Result<(), String> { if let Ok(Some(settings)) = GeneralSettingsStore::get(&app) && settings.disable_auto_open_links { return Ok(()); } - app.shell() - .open(&url, None) - .map_err(|e| format!("Failed to open URL: {e}"))?; + app.native_operation("shell.open", serde_json::json!({ "path": url }))?; Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(_app))] async fn reset_camera_permissions(_app: AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] { - let bundle_id = _app.config().identifier.clone(); + let bundle_id = "so.cap.desktop"; Command::new("tccutil") .arg("reset") @@ -4171,13 +4126,13 @@ async fn reset_camera_permissions(_app: AppHandle) -> Result<(), String> { Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(_app))] async fn reset_microphone_permissions(_app: AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] { - let bundle_id = _app.config().identifier.clone(); + let bundle_id = "so.cap.desktop"; Command::new("tccutil") .arg("reset") @@ -4190,14 +4145,14 @@ async fn reset_microphone_permissions(_app: AppHandle) -> Result<(), String> { Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn clear_presets(app: AppHandle) -> Result<(), String> { presets::PresetsStore::clear(&app) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn is_camera_window_open(app: AppHandle) -> bool { @@ -4207,7 +4162,7 @@ async fn is_camera_window_open(app: AppHandle) -> bool { .unwrap_or(false) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(editor_instance))] async fn seek_to(editor_instance: WindowEditorInstance, frame_number: u32) -> Result<(), String> { @@ -4220,7 +4175,7 @@ async fn seek_to(editor_instance: WindowEditorInstance, frame_number: u32) -> Re Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(editor_instance))] async fn get_display_frame_for_cropping( @@ -4372,7 +4327,7 @@ async fn get_display_frame_for_cropping( Ok(jpeg_data.into_inner()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(editor_instance))] async fn get_mic_waveforms(editor_instance: WindowEditorInstance) -> Result>, String> { @@ -4394,7 +4349,7 @@ async fn get_mic_waveforms(editor_instance: WindowEditorInstance) -> Result Result<(), String> { let _ = window.close(); @@ -4439,7 +4394,7 @@ async fn editor_delete_project( } // keep this async otherwise opening windows may hang on windows -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn show_window(app: AppHandle, window: ShowCapWindow) -> Result<(), String> { @@ -4454,14 +4409,14 @@ async fn show_window(app: AppHandle, window: ShowCapWindow) -> Result<(), String Ok(()) } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] #[instrument] fn list_fails() -> Result, ()> { Ok(cap_fail::get_state()) } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] #[instrument] fn set_fail(name: String, value: bool) { @@ -4477,11 +4432,20 @@ async fn check_notification_permissions(app: AppHandle) { return; } - match app.notification().permission_state() { - Ok(state) if state != PermissionState::Granted => { + match app + .native_request::("notification.permission", serde_json::json!({})) + .await + { + Ok(state) if state != "granted" => { println!("Requesting notification permission"); - match app.notification().request_permission() { - Ok(PermissionState::Granted) => { + match app + .native_request::( + "notification.requestPermission", + serde_json::json!({}), + ) + .await + { + Ok(state) if state == "granted" => { println!("Notification permission granted"); } Ok(_) | Err(_) => { @@ -4526,7 +4490,7 @@ async fn check_notification_permissions(app: AppHandle) { // _guard // } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn set_server_url(app: MutableState<'_, App>, server_url: String) -> Result<(), ()> { @@ -4537,27 +4501,19 @@ async fn set_server_url(app: MutableState<'_, App>, server_url: String) -> Resul Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn pick_recordings_folder(app: AppHandle) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - - let (tx, rx) = tokio::sync::oneshot::channel(); - - app.dialog() - .file() - .set_title("Choose Recordings Folder") - .pick_folder(move |path| { - let _ = tx.send( - path.as_ref() - .and_then(|p| p.as_path()) - .and_then(|p| p.to_str()) - .map(|s| s.to_string()), - ); - }); - - let result = rx.await.map_err(|e| e.to_string())?; + let result = app + .native_request::, _>( + "dialog.open", + serde_json::json!({ + "title": "Choose Recordings Folder", + "directory": true + }), + ) + .await?; if let Some(ref path) = result { general_settings::GeneralSettingsStore::update(&app, |s| { let previous = s.recordings_path.replace(path.clone()); @@ -4567,7 +4523,7 @@ async fn pick_recordings_folder(app: AppHandle) -> Result, String Ok(result) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn reset_recordings_folder(app: AppHandle) -> Result<(), String> { @@ -4577,7 +4533,7 @@ async fn reset_recordings_folder(app: AppHandle) -> Result<(), String> { }) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn set_camera_preview_state( @@ -4597,7 +4553,7 @@ async fn set_camera_preview_state( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] fn set_camera_window_position(app: AppHandle, x: f64, y: f64) -> Result<(), String> { @@ -4615,7 +4571,7 @@ fn set_camera_window_position(app: AppHandle, x: f64, y: f64) -> Result<(), Stri Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] fn ignore_camera_window_position( @@ -4626,7 +4582,7 @@ fn ignore_camera_window_position( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(state))] async fn refresh_camera_feed(state: MutableState<'_, App>) -> Result<(), String> { @@ -4676,7 +4632,7 @@ async fn refresh_camera_feed(state: MutableState<'_, App>) -> Result<(), String> Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, state))] async fn destroy_camera_window(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { @@ -4694,7 +4650,7 @@ async fn destroy_camera_window(app: AppHandle, state: MutableState<'_, App>) -> Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] async fn await_camera_preview_ready(app: MutableState<'_, App>) -> Result { @@ -4709,7 +4665,7 @@ async fn await_camera_preview_ready(app: MutableState<'_, App>) -> Result() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::() - .typ::(); - - #[cfg(debug_assertions)] + #[cfg(any())] { - let bindings_path = std::path::Path::new("../src/utils/tauri.ts"); - if bindings_path.parent().is_some_and(|parent| parent.exists()) { - if let Err(err) = - specta_builder.export(specta_typescript::Typescript::default(), bindings_path) - { - warn!(error = %err, "Failed to export TypeScript bindings"); + let specta_builder = tauri_specta::Builder::new() + .commands(tauri_specta::collect_commands![ + set_mic_input, + set_camera_input, + set_native_camera_preview_enabled, + recording_settings::set_recording_mode, + upload_logs, + get_system_diagnostics, + cli::get_cli_install_status, + cli::install_cli, + cli::uninstall_cli, + recording::start_recording, + recording::stop_recording, + recording::pause_recording, + recording::resume_recording, + recording::toggle_pause_recording, + recording::set_mic_recording_muted, + recording::restart_recording, + recording::delete_recording, + recording::take_screenshot, + recording::import_current_desktop_background, + recording::list_cameras, + recording::get_camera_formats, + recording::get_microphone_info, + recording::list_capture_windows, + recording::list_capture_displays, + recording::list_displays_with_thumbnails, + recording::list_windows_with_thumbnails, + windows::refresh_window_content_protection, + general_settings::get_default_excluded_windows, + list_audio_devices, + close_recordings_overlay_window, + fake_window::set_fake_window_bounds, + fake_window::remove_fake_window, + focus_captures_panel, + get_current_recording, + export::begin_export_session, + export::end_export_session, + export::cancel_export, + export::cancel_current_window_exports, + export::export_video, + export::export_video_with_id, + export::export_video_to_file, + export::get_export_estimates, + export::generate_export_preview, + export::generate_export_preview_fast, + import::start_video_import, + import::add_existing_recording_to_editor, + import::start_image_import, + import::check_import_ready, + copy_file_to_path, + copy_video_to_clipboard, + copy_screenshot_to_clipboard, + copy_image_to_clipboard, + copy_rendered_screenshot_to_clipboard, + open_file_path, + get_video_metadata, + create_editor_instance, + get_editor_project_path, + get_mic_waveforms, + get_system_audio_waveforms, + audio_library::list_audio_library, + audio_library::add_audio_library_track, + audio_library::import_audio_track_file, + start_playback, + stop_playback, + set_playhead_position, + set_project_config, + update_project_config_in_memory, + generate_zoom_segments_from_clicks, + generate_keyboard_segments, + render_screenshot_for_export, + render_screenshot_project_for_export, + get_screenshot_project_share_state, + permissions::open_permission_settings, + permissions::do_permissions_check, + permissions::request_permission, + get_devices_snapshot, + upload_exported_video, + copy_current_screenshot_share_link, + upload_screenshot, + upload_rendered_screenshot, + create_screenshot_editor_instance, + update_screenshot_config, + prewarm_screenshot_background, + recognize_screenshot_text, + get_recording_meta, + save_file_dialog, + list_recordings, + list_screenshots, + check_upgraded_and_update, + open_external_link, + hotkeys::set_hotkey, + reset_camera_permissions, + reset_microphone_permissions, + clear_presets, + is_camera_window_open, + seek_to, + get_display_frame_for_cropping, + clip_thumbnails::get_clip_thumbnail, + windows::position_traffic_lights, + windows::set_theme, + windows::set_teleprompter_window_level, + windows::set_teleprompter_window_opacity, + windows::apply_macos_liquid_glass_background, + global_message_dialog, + show_window, + write_clipboard_string, + platform::perform_haptic_feedback, + platform::is_system_audio_capture_supported, + list_fails, + set_fail, + update_auth_plan, + set_window_transparent, + get_editor_meta, + get_recording_meta_by_path, + set_editor_recording_target, + delete_recording_directory, + set_pretty_name, + set_server_url, + pick_recordings_folder, + reset_recordings_folder, + recordings_locations::count_recordings_to_migrate, + recordings_locations::migrate_recordings_to_current_dir, + set_camera_preview_state, + set_camera_window_position, + ignore_camera_window_position, + await_camera_preview_ready, + destroy_camera_window, + refresh_camera_feed, + captions::create_dir, + captions::save_model_file, + captions::transcribe_audio, + captions::save_captions, + captions::load_captions, + captions::get_model_download_status, + captions::download_whisper_model, + captions::check_model_exists, + captions::delete_whisper_model, + captions::download_parakeet_model, + captions::check_parakeet_model_exists, + captions::delete_parakeet_model, + captions::export_captions_srt, + target_select_overlay::open_target_select_overlays, + target_select_overlay::close_target_select_overlays, + target_select_overlay::update_camera_overlay_bounds, + target_select_overlay::display_information, + target_select_overlay::get_window_icon, + target_select_overlay::focus_window, + editor_delete_project, + format_project_name, + recovery::find_incomplete_recordings, + recovery::recover_recording, + recovery::discard_incomplete_recording, + automation::get_automations, + automation::set_automations, + automation::test_automation, + automation::automation_should_open_screenshot_editor, + automation::list_automation_capabilities, + updates::updates_check, + updates::updates_download_and_install, + updates::updates_channel_changed, + ]) + .events(tauri_specta::collect_events![ + RecordingOptionsChanged, + NewStudioRecordingAdded, + EditorRecordingAdded, + NewScreenshotAdded, + RenderFrameEvent, + EditorStateChanged, + FrameLayoutEvent, + CurrentRecordingChanged, + RecordingStarted, + RecordingStopped, + RequestStartRecording, + RequestOpenRecordingPicker, + RequestSetTargetMode, + RequestOpenSettings, + RequestScrollToSettingsSection, + RequestScreenCapturePrewarm, + NewNotification, + audio_meter::AudioInputLevelChange, + captions::DownloadProgress, + recording::RecordingEvent, + RecordingDeleted, + recordings_locations::RecordingsMigrationProgress, + target_select_overlay::TargetUnderCursor, + hotkeys::OnEscapePress, + upload::UploadProgressEvent, + import::VideoImportProgress, + SetCaptureAreaPending, + DevicesUpdated, + updates::UpdateDownloadProgress, + updates::UpdateReady, + ]) + .error_handling(tauri_specta::ErrorHandlingMode::Throw) + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::(); + + #[cfg(debug_assertions)] + { + let bindings_path = std::path::Path::new("../src/utils/tauri.ts"); + if bindings_path.parent().is_some_and(|parent| parent.exists()) { + if let Err(err) = + specta_builder.export(specta_typescript::Typescript::default(), bindings_path) + { + warn!(error = %err, "Failed to export TypeScript bindings"); + } + } else { + debug!("Skipping TypeScript bindings export outside source checkout"); } - } else { - debug!("Skipping TypeScript bindings export outside source checkout"); } } @@ -5155,122 +5112,22 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { mic_feed }; - tauri::async_runtime::set(tokio::runtime::Handle::current()); - - #[allow(unused_mut)] - let mut builder = tauri::Builder::default(); - - // The Linux single-instance plugin establishes its D-Bus connection through a - // blocking zbus call, which panics ("Cannot start a runtime from within a - // runtime") when initialized inside the Tokio runtime that drives the app. - #[cfg(not(target_os = "linux"))] - { - builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - trace!("Single instance invoked with args {args:?}"); - - let action_urls = args - .iter() - .filter(|arg| arg.starts_with("cap-desktop://")) - .filter_map(|arg| tauri::Url::parse(arg).ok()) - .collect::>(); - if !action_urls.is_empty() { - deeplink_actions::handle(app, action_urls); - return; - } - - let Some(cap_file) = args - .iter() - .find(|arg| arg.ends_with(".cap")) - .map(PathBuf::from) - else { - let app = app.clone(); - tokio::spawn(async move { - ShowCapWindow::Main { - init_target_mode: None, - } - .show(&app) - .await - }); - return; - }; - - let _ = open_project_from_path(&cap_file, app.clone()); - })); - } + cap_desktop_runtime::async_runtime::set(tokio::runtime::Handle::current()); - #[cfg(target_os = "macos")] - { - builder = builder - .menu(build_macos_app_menu) - .on_menu_event(|app, event| { - if event.id() == APP_MENU_QUIT_ID { - let app = app.clone(); - tokio::spawn(async move { - request_app_exit(app).await; - }); - } - }) - .plugin(tauri_nspanel::init()); - } - - builder - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_store::Builder::new().build()) - .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_oauth::init()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_notification::init()) - .plugin(flags::plugin::init()) - .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_opener::init()) - .plugin( - tauri_plugin_window_state::Builder::new() - .with_state_flags({ - use tauri_plugin_window_state::StateFlags; - let mut flags = StateFlags::all(); - flags.remove(StateFlags::VISIBLE); - flags - }) - .with_denylist(&[ - CapWindowId::Onboarding.label().as_str(), - CapWindowId::Main.label().as_str(), - CapWindowId::Settings.label().as_str(), - "window-capture-occluder", - "target-select-overlay", - CapWindowId::CaptureArea.label().as_str(), - CapWindowId::Camera.label().as_str(), - CapWindowId::RecordingsOverlay.label().as_str(), - CapWindowId::RecordingControls.label().as_str(), - CapWindowId::Upgrade.label().as_str(), - "editor", - "screenshot-editor", - ]) - .map_label(|label| match label { - label if label.starts_with("camera-") => "camera", - label if label.starts_with("editor-") => "editor", - label if label.starts_with("screenshot-editor-") => "screenshot-editor", - label if label.starts_with("window-capture-occluder-") => { - "window-capture-occluder" - } - label if label.starts_with("target-select-overlay") => "target-select-overlay", - _ => label, - }) - .build(), - ) - .invoke_handler(specta_builder.invoke_handler()) - .setup(move |app| { - let app = app.handle().clone(); + let resource_dir = std::env::var_os("CAP_ELECTRON_RESOURCE_DIR") + .map(PathBuf::from) + .or_else(|| std::env::current_exe().ok()?.parent().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + let backend_options = cap_desktop_runtime::BackendOptions::new("so.cap.desktop", resource_dir); + if let Err(error) = cap_desktop_runtime::run_backend( + backend_options, + move |app| { + let app = app.clone(); if let Err(err) = update_project_names::migrate_if_needed(&app) { tracing::error!("Failed to migrate project file names: {}", err); } - specta_builder.mount_events(&app); hotkeys::init(&app); general_settings::init(&app); configure_camera_blur_recovery(&app, previous_termination); @@ -5287,7 +5144,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app.manage(http_client::RetryableHttpClient::default()); app.manage(PendingScreenshots::default()); app.manage(FinalizingRecordings::default()); - app.manage(updates::UpdatesState::default()); + app.manage(updates::UpdatesState); updates::spawn_background_loop(app.clone()); #[cfg(unix)] @@ -5560,29 +5417,39 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { }); let app_handle = app.clone(); - app.deep_link().on_open_url(move |event| { - deeplink_actions::handle(&app_handle, event.urls()); + app.listen("electron://open-urls", move |event| { + let Ok(urls) = serde_json::from_str::>(event.payload()) else { + return; + }; + let urls = urls + .into_iter() + .filter_map(|url| cap_desktop_runtime::Url::parse(&url).ok()) + .collect(); + deeplink_actions::handle(&app_handle, urls); }); - Ok(()) - }) - .on_window_event(|window, event| { + let app_handle = app.clone(); + app.listen("electron://reopen", move |_| { + handle_electron_reopen(&app_handle); + }); + + app.on_window_event(|window, event| { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let label = window.label(); let app = window.app_handle(); if matches!( event, - WindowEvent::CloseRequested { .. } - | WindowEvent::Moved(_) - | WindowEvent::Focused(_) + WindowEvent::CloseRequested + | WindowEvent::Moved { .. } + | WindowEvent::Focused { .. } ) && app_is_exiting(app) { return; } match event { - WindowEvent::CloseRequested { api, .. } => { + WindowEvent::CloseRequested => { let window_id = CapWindowId::from_str(label).ok(); if !matches!( window_id, @@ -5590,7 +5457,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { | Some(CapWindowId::ScreenshotEditor { .. }) ) && export::export_session_active() { - api.prevent_close(); warn!( window = label, "Preventing window close request during active export" @@ -5608,7 +5474,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { return; } - api.prevent_close(); let _ = window.hide(); tracing::warn!("Camera window CloseRequested event received!"); let session_id = app @@ -5621,7 +5486,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { }); } CapWindowId::Main => { - api.prevent_close(); let _ = window.hide(); #[cfg(target_os = "macos")] @@ -5676,7 +5540,9 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { }); } } - _ => {} + _ => { + let _ = window.close(); + } } } } @@ -5814,7 +5680,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { if let Some(focus_manager) = app.try_state::() { - focus_manager.destroy(&display_id, app.global_shortcut()); + focus_manager.destroy(&display_id, app); } let session_id = app .try_state::>() @@ -5856,7 +5722,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { crate::permissions::sync_macos_dock_visibility(app); } #[cfg(target_os = "macos")] - WindowEvent::Focused(focused) => { + WindowEvent::Focused { focused } => { let window_id = CapWindowId::from_str(label); if matches!(window_id, Ok(CapWindowId::Upgrade)) { @@ -5869,20 +5735,21 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } } - if *focused + if focused && let Ok(window_id) = window_id && window_id.activates_dock() { crate::permissions::sync_macos_dock_visibility(app); } } - WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) => { + WindowEvent::DragDrop { paths } => { let window_id = CapWindowId::from_str(label).ok(); for path in paths { + let path = PathBuf::from(path); let result = if matches!(window_id, Some(CapWindowId::Main)) { - open_importable_from_path(path, app.clone()) + open_importable_from_path(&path, app.clone()) } else { - open_project_from_path(path, app.clone()) + open_project_from_path(&path, app.clone()) }; if let Err(err) = result { @@ -5890,13 +5757,14 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } } } - WindowEvent::Moved(position) => { + WindowEvent::Moved { x, y } => { + let position = PhysicalPosition::new(x as i32, y as i32); let window_id = CapWindowId::from_str(label); #[cfg(target_os = "macos")] if matches!(&window_id, Ok(CapWindowId::Main)) && let Some(constrained_position) = - platform::constrain_main_window_to_visible_top(window, *position) + platform::constrain_main_window_to_visible_top(&window, position) { match window.set_position(constrained_position) { Ok(()) => { @@ -5975,7 +5843,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { if let Some(webview_window) = app.get_webview_window(label) { windows::update_window_rasterization_scale( &webview_window, - *scale_factor, + scale_factor, ); } } @@ -5985,29 +5853,43 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { if let Err(panic) = result { let message = panic_payload_message(&panic); - tracing::error!(panic = %message, "Suppressed panic in Tauri WindowEvent handler"); + tracing::error!(panic = %message, "Suppressed panic in Electron WindowEvent handler"); sentry::capture_message( - &format!("Tauri WindowEvent panic suppressed: {message}"), + &format!("Electron WindowEvent panic suppressed: {message}"), sentry::Level::Error, ); } - }) - .build(tauri_context) - .expect("error while running tauri application") - .run(move |handle, event| { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - handle_run_event(handle, event); - })); + }); - if let Err(panic) = result { - let message = panic_payload_message(&panic); - tracing::error!(panic = %message, "Suppressed panic in Tauri RunEvent handler"); - sentry::capture_message( - &format!("Tauri RunEvent panic suppressed: {message}"), - sentry::Level::Error, - ); + Ok(()) + }, + |app| async move { + let should_cleanup = app + .try_state::() + .is_none_or(|exit_state| exit_state.begin()); + if should_cleanup { + if tokio::time::timeout( + APP_EXIT_TOTAL_TIMEOUT, + cleanup_app_resources_for_exit(&app), + ) + .await + .is_err() + { + error!( + timeout_ms = APP_EXIT_TOTAL_TIMEOUT.as_millis(), + "Timed out while cleaning up the Electron backend" + ); + } else { + crash_sentinel::mark_clean_exit(); + } } - }); + Ok(()) + }, + ) + .await + { + error!(%error, "Electron backend stopped unexpectedly"); + } } pub(crate) fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { @@ -6020,7 +5902,7 @@ pub(crate) fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> Str } } -// Catches panics inside an async Tauri command body so a single bad code path can't +// Catches panics inside an async desktop command body so a single bad code path can't // unwind through the IPC dispatcher and terminate the whole desktop process. This is // particularly important for commands that participate in the export flow because the // sidecar architecture relies on the desktop app remaining alive to babysit the worker. @@ -6035,10 +5917,10 @@ where error!( command = command_name, panic = %message, - "Suppressed panic in Tauri command" + "Suppressed panic in desktop command" ); sentry::capture_message( - &format!("Tauri command '{command_name}' panicked: {message}"), + &format!("Desktop command '{command_name}' panicked: {message}"), sentry::Level::Error, ); Err(format!("{command_name} failed unexpectedly")) @@ -6061,10 +5943,46 @@ where } } -fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { +fn handle_electron_reopen(app: &AppHandle) { + let should_focus_onboarding = should_show_onboarding(app); + if should_focus_onboarding && let Some(onboarding) = CapWindowId::Onboarding.get(app) { + onboarding.show().ok(); + onboarding.set_focus().ok(); + return; + } + + let window = app + .webview_windows() + .into_iter() + .find(|(label, _)| { + label.starts_with("editor-") + || label.starts_with("screenshot-editor-") + || label.as_str() == "settings" + || label.as_str() == "signin" + || (should_focus_onboarding && label.as_str() == "onboarding") + }) + .map(|(_, window)| window); + + if let Some(window) = window { + window.show().ok(); + window.set_focus().ok(); + } else { + let app = app.clone(); + spawn_on_runtime(async move { + let _ = ShowCapWindow::Main { + init_target_mode: None, + } + .show(&app) + .await; + }); + } +} + +#[cfg(any())] +fn handle_run_event(_handle: &AppHandle, event: cap_desktop_runtime::RunEvent) { match event { #[cfg(target_os = "macos")] - tauri::RunEvent::Reopen { .. } => { + cap_desktop_runtime::RunEvent::Reopen { .. } => { let should_focus_onboarding = should_show_onboarding(_handle); if should_focus_onboarding @@ -6109,7 +6027,7 @@ fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { }); } } - tauri::RunEvent::ExitRequested { code, api, .. } => { + cap_desktop_runtime::RunEvent::ExitRequested { code, api, .. } => { info!(?code, "App exit requested"); match handle_exit_requested( @@ -6134,7 +6052,7 @@ fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { ExitRequestDecision::AllowRuntimeExit => {} } } - tauri::RunEvent::Exit => { + cap_desktop_runtime::RunEvent::Exit => { #[cfg(target_os = "macos")] { // This arm runs on the AppKit main thread, so reverse the Liquid Glass @@ -6547,7 +6465,8 @@ async fn create_editor_instance_impl( app: &AppHandle, path: PathBuf, frame_cb: cap_editor::EditorFrameCallback, -) -> Result<(Arc, tauri::EventId), String> { + nv12_output_available: Arc, +) -> Result<(Arc, cap_desktop_runtime::EventId), String> { let app = app.clone(); wait_for_recording_ready(&app, &path).await?; @@ -6567,13 +6486,14 @@ async fn create_editor_instance_impl( let instance = { let app = app.clone(); - EditorInstance::new( + EditorInstance::new_with_output_capability( path, move |state| { let _ = EditorStateChanged::new(state).emit(&app); }, frame_cb, shared_device, + nv12_output_available, ) .await? }; @@ -6706,14 +6626,14 @@ fn screenshots_path(app: &AppHandle) -> PathBuf { // screenshots_path(app).join(format!("{screenshot_id}.cap")) // } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app))] fn global_message_dialog(app: AppHandle, message: String) { - app.dialog().message(message).show(|_| {}); + let _ = app.native_operation("dialog.message", serde_json::json!({ "message": message })); } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(clipboard))] async fn write_clipboard_string( @@ -6728,7 +6648,7 @@ async fn write_clipboard_string( .map_err(|e| format!("Failed to write text to clipboard: {e}")) } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] fn format_project_name( template: Option, @@ -6746,11 +6666,11 @@ fn format_project_name( ) } -trait EventExt: tauri_specta::Event { +trait EventExt: cap_desktop_runtime::Event { fn listen_any_spawn( app: &AppHandle, - handler: impl Fn(Self, AppHandle) -> Fut + Send + 'static + Clone, - ) -> tauri::EventId + handler: impl Fn(Self, AppHandle) -> Fut + Send + Sync + 'static + Clone, + ) -> cap_desktop_runtime::EventId where Fut: Future + Send, Self: serde::de::DeserializeOwned + Send + 'static, @@ -6766,14 +6686,17 @@ trait EventExt: tauri_specta::Event { } } -impl EventExt for T {} +impl EventExt for T {} fn show_import_error_dialog(app: &AppHandle, message: String) { - app.dialog() - .message(message) - .title("Import Error") - .kind(tauri_plugin_dialog::MessageDialogKind::Error) - .show(|_| {}); + let _ = app.native_operation( + "dialog.message", + serde_json::json!({ + "message": message, + "title": "Import Error", + "kind": "error" + }), + ); } fn hide_main_window(app: &AppHandle) { @@ -6856,8 +6779,7 @@ fn open_project_from_path(path: &Path, app: AppHandle) -> Result<(), String> { if mp4_path.exists() && mp4_path.is_file() { let _ = app - .opener() - .open_path(mp4_path.to_str().unwrap_or_default(), None::); + .native_operation("opener.openPath", serde_json::json!({ "path": mp4_path })); if let Some(main_window) = CapWindowId::Main.get(&app) { main_window.hide().ok(); } diff --git a/apps/desktop/src-tauri/src/logging.rs b/apps/desktop/src-backend/src/logging.rs similarity index 99% rename from apps/desktop/src-tauri/src/logging.rs rename to apps/desktop/src-backend/src/logging.rs index f70d042c836..22fb8c35195 100644 --- a/apps/desktop/src-tauri/src/logging.rs +++ b/apps/desktop/src-backend/src/logging.rs @@ -2,13 +2,13 @@ use crate::{ ArcLock, feeds::microphone::MicrophoneFeed, general_settings::GeneralSettingsStore, permissions, web_api::ManagerExt, }; +use cap_desktop_runtime::{AppHandle, Manager}; use cap_recording::diagnostics::{ CameraDiagnostics, CameraFormatInfo, DisplayDiagnostics, HardwareInfo, MicrophoneDiagnostics, StorageInfo, }; use serde::Serialize; use std::{fs, path::PathBuf}; -use tauri::{AppHandle, Manager}; async fn get_latest_log_file(app: &AppHandle) -> Option { let logs_dir = app diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-backend/src/main.rs similarity index 92% rename from apps/desktop/src-tauri/src/main.rs rename to apps/desktop/src-backend/src/main.rs index ab1dc76e715..ff39e18d94f 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-backend/src/main.rs @@ -9,6 +9,9 @@ use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 16 * 1024 * 1024; fn main() { + #[cfg(target_os = "macos")] + configure_macos_sidecar_activation_policy(); + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] if std::env::var_os("ORT_DYLIB_PATH").is_none() && let Some(path) = cap_camera_effects::onnx_runtime_library_path() @@ -172,6 +175,21 @@ fn main() { .block_on(cap_desktop_lib::run(handle, logs_dir)); } +#[cfg(target_os = "macos")] +fn configure_macos_sidecar_activation_policy() { + use cocoa::appkit::{ + NSApp, NSApplication, + NSApplicationActivationPolicy::NSApplicationActivationPolicyProhibited, + }; + + // Electron owns Cap's windows, menu bar, and Dock presence. Some of the native + // capture crates initialize AppKit in this process, which otherwise makes macOS + // register the Rust sidecar as a second foreground copy of Cap. + unsafe { + NSApp().setActivationPolicy_(NSApplicationActivationPolicyProhibited); + } +} + fn install_panic_hook(logs_dir: std::path::PathBuf) { let prev = std::panic::take_hook(); let panics_log = logs_dir.join("panics.log"); diff --git a/apps/desktop/src-tauri/src/notifications.rs b/apps/desktop/src-backend/src/notifications.rs similarity index 91% rename from apps/desktop/src-tauri/src/notifications.rs rename to apps/desktop/src-backend/src/notifications.rs index d872fd8380f..51d449e8678 100644 --- a/apps/desktop/src-tauri/src/notifications.rs +++ b/apps/desktop/src-backend/src/notifications.rs @@ -1,5 +1,4 @@ use crate::{AppSounds, general_settings::GeneralSettingsStore}; -use tauri_plugin_notification::NotificationExt; #[allow(unused)] pub enum NotificationType { @@ -83,12 +82,15 @@ impl NotificationType { } } - pub fn send(self, app: &tauri::AppHandle) { + pub fn send(self, app: &cap_desktop_runtime::AppHandle) { send_notification(app, self); } } -pub fn send_notification(app: &tauri::AppHandle, notification_type: NotificationType) { +pub fn send_notification( + app: &cap_desktop_runtime::AppHandle, + notification_type: NotificationType, +) { let enable_notifications = GeneralSettingsStore::get(app) .map(|settings| settings.is_some_and(|s| s.enable_notifications)) .unwrap_or(false); @@ -99,12 +101,10 @@ pub fn send_notification(app: &tauri::AppHandle, notification_type: Notification let (title, body, _is_error) = notification_type.details(); - app.notification() - .builder() - .title(title) - .body(body) - .show() - .ok(); + let _ = app.native_operation( + "notification.show", + serde_json::json!({ "title": title, "body": body }), + ); let skip_sound = matches!( notification_type, diff --git a/apps/desktop/src-tauri/src/panel_manager.rs b/apps/desktop/src-backend/src/panel_manager.rs similarity index 82% rename from apps/desktop/src-tauri/src/panel_manager.rs rename to apps/desktop/src-backend/src/panel_manager.rs index 68bc7eff07d..b855985c629 100644 --- a/apps/desktop/src-tauri/src/panel_manager.rs +++ b/apps/desktop/src-backend/src/panel_manager.rs @@ -193,7 +193,7 @@ impl PanelOperationGuard { } #[cfg(target_os = "macos")] -pub fn is_window_handle_valid(window: &tauri::WebviewWindow) -> bool { +pub fn is_window_handle_valid(window: &cap_desktop_runtime::WebviewWindow) -> bool { match window.inner_size() { Ok(_) => true, Err(e) => { @@ -202,42 +202,3 @@ pub fn is_window_handle_valid(window: &tauri::WebviewWindow) -> bool { } } } - -#[cfg(target_os = "macos")] -pub type NSPanel = tauri_nspanel::objc_id::Id< - tauri_nspanel::raw_nspanel::RawNSPanel, - tauri_nspanel::objc_id::Shared, ->; - -#[cfg(target_os = "macos")] -pub fn try_to_panel(window: &tauri::WebviewWindow) -> Result { - use tauri_nspanel::WebviewWindowExt as NSPanelWebviewWindowExt; - - if !is_window_handle_valid(window) { - return Err(PanelConversionError::InvalidHandle); - } - - window - .to_panel() - .map_err(|e| PanelConversionError::ConversionFailed(format!("{e:?}"))) -} - -#[cfg(target_os = "macos")] -#[derive(Debug)] -pub enum PanelConversionError { - InvalidHandle, - ConversionFailed(String), -} - -#[cfg(target_os = "macos")] -impl std::fmt::Display for PanelConversionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidHandle => write!(f, "Window handle is invalid or unavailable"), - Self::ConversionFailed(msg) => write!(f, "Panel conversion failed: {msg}"), - } - } -} - -#[cfg(target_os = "macos")] -impl std::error::Error for PanelConversionError {} diff --git a/apps/desktop/src-tauri/src/permissions.rs b/apps/desktop/src-backend/src/permissions.rs similarity index 94% rename from apps/desktop/src-tauri/src/permissions.rs rename to apps/desktop/src-backend/src/permissions.rs index 8f66b9b998b..8e10ce22d39 100644 --- a/apps/desktop/src-tauri/src/permissions.rs +++ b/apps/desktop/src-backend/src/permissions.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; #[cfg(target_os = "macos")] use crate::{general_settings::GeneralSettingsStore, windows::CapWindowId}; #[cfg(target_os = "macos")] +use cap_desktop_runtime::Manager; +#[cfg(target_os = "macos")] use cidre::{av, sc}; #[cfg(target_os = "macos")] use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; @@ -13,8 +15,6 @@ use std::{ sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}, time::Duration, }; -#[cfg(target_os = "macos")] -use tauri::Manager; use tracing::instrument; #[cfg(target_os = "macos")] @@ -48,7 +48,7 @@ const MACOS_SCK_OUTCOME_FAILED: u8 = 2; #[cfg(target_os = "macos")] pub(crate) struct MacosPanelWindowActivationGuard { - app: tauri::AppHandle, + app: cap_desktop_runtime::AppHandle, } #[cfg(target_os = "macos")] @@ -93,7 +93,7 @@ fn macos_prompt_accessibility_access() { #[cfg(target_os = "macos")] fn macos_run_on_main_thread( - app: &tauri::AppHandle, + app: &cap_desktop_runtime::AppHandle, callback: impl FnOnce() -> R + Send + 'static, ) -> Option { use std::sync::mpsc; @@ -137,7 +137,7 @@ fn macos_permission_needs_settings_fallback(permission: &OSPermission) -> bool { } #[cfg(target_os = "macos")] -fn macos_focus_permission_window(app: &tauri::AppHandle) { +fn macos_focus_permission_window(app: &cap_desktop_runtime::AppHandle) { if let Some(window) = ["onboarding", "main", "settings"] .into_iter() .find_map(|label| app.get_webview_window(label)) @@ -148,12 +148,12 @@ fn macos_focus_permission_window(app: &tauri::AppHandle) { } #[cfg(target_os = "macos")] -fn macos_activate_permission_request(app: &tauri::AppHandle) { +fn macos_activate_permission_request(app: &cap_desktop_runtime::AppHandle) { if let Err(err) = app.set_dock_visibility(true) { tracing::warn!("Failed to show dock icon for permission request: {err}"); } - if let Err(err) = app.set_activation_policy(tauri::ActivationPolicy::Regular) { + if let Err(err) = app.set_activation_policy(cap_desktop_runtime::ActivationPolicy::Regular) { tracing::warn!("Failed to set activation policy to Regular: {err}"); } @@ -170,11 +170,11 @@ fn macos_activate_permission_request(app: &tauri::AppHandle) { } #[cfg(target_os = "macos")] -fn macos_sync_activation_policy(app: &tauri::AppHandle, should_show_dock: bool) { +fn macos_sync_activation_policy(app: &cap_desktop_runtime::AppHandle, should_show_dock: bool) { let policy = if should_show_dock { - tauri::ActivationPolicy::Regular + cap_desktop_runtime::ActivationPolicy::Regular } else { - tauri::ActivationPolicy::Accessory + cap_desktop_runtime::ActivationPolicy::Accessory }; if let Err(err) = app.set_activation_policy(policy) { @@ -187,7 +187,7 @@ fn macos_sync_activation_policy(app: &tauri::AppHandle, should_show_dock: bool) // NSException that aborts the process when it unwinds into Rust. Callers must // leave the policy alone until fullscreen exits. #[cfg(target_os = "macos")] -fn macos_any_window_fullscreen(app: &tauri::AppHandle) -> bool { +fn macos_any_window_fullscreen(app: &cap_desktop_runtime::AppHandle) -> bool { app.webview_windows() .values() .any(|window| window.is_fullscreen().unwrap_or(false)) @@ -195,12 +195,14 @@ fn macos_any_window_fullscreen(app: &tauri::AppHandle) -> bool { #[cfg(target_os = "macos")] pub(crate) fn prepare_macos_panel_window( - app: &tauri::AppHandle, + app: &cap_desktop_runtime::AppHandle, ) -> MacosPanelWindowActivationGuard { let prev = MACOS_PENDING_PANEL_WINDOWS.fetch_add(1, Ordering::AcqRel); if prev == 0 && !macos_any_window_fullscreen(app) { - if let Err(err) = app.set_activation_policy(tauri::ActivationPolicy::Accessory) { + if let Err(err) = + app.set_activation_policy(cap_desktop_runtime::ActivationPolicy::Accessory) + { tracing::warn!("Failed to prepare macOS panel activation policy: {err}"); } } @@ -209,7 +211,7 @@ pub(crate) fn prepare_macos_panel_window( } #[cfg(target_os = "macos")] -pub(crate) fn sync_macos_dock_visibility(app: &tauri::AppHandle) { +pub(crate) fn sync_macos_dock_visibility(app: &cap_desktop_runtime::AppHandle) { if MACOS_PENDING_PANEL_WINDOWS.load(Ordering::Acquire) > 0 { return; } @@ -249,10 +251,10 @@ pub(crate) fn sync_macos_dock_visibility(app: &tauri::AppHandle) { } #[cfg(target_os = "macos")] -pub(crate) fn schedule_macos_dock_visibility_sync(app: &tauri::AppHandle) { +pub(crate) fn schedule_macos_dock_visibility_sync(app: &cap_desktop_runtime::AppHandle) { let generation = MACOS_DOCK_VISIBILITY_SYNC_GENERATION.fetch_add(1, Ordering::AcqRel) + 1; let app = app.clone(); - tauri::async_runtime::spawn(async move { + cap_desktop_runtime::async_runtime::spawn(async move { tokio::time::sleep(Duration::from_millis(100)).await; if MACOS_DOCK_VISIBILITY_SYNC_GENERATION.load(Ordering::Acquire) == generation { sync_macos_dock_visibility(&app); @@ -394,7 +396,7 @@ fn macos_spawn_sck_displays_validation() { if MACOS_SCK_DISPLAYS_VALIDATION_IN_FLIGHT.swap(true, Ordering::AcqRel) { return; } - tauri::async_runtime::spawn_blocking(|| { + cap_desktop_runtime::async_runtime::spawn_blocking(|| { let _reset = MacosSckValidationInFlightReset; macos_validate_sck_displays(); }); @@ -419,7 +421,7 @@ fn macos_validate_sck_displays() -> bool { *last_attempt = Some(std::time::Instant::now()); let validated = objc2::rc::autoreleasepool(|_| { - tauri::async_runtime::block_on(async { + cap_desktop_runtime::async_runtime::block_on(async { // Bounded: a wedged replayd (post-sleep, or right after a TCC // grant before relaunch) can leave this future unresolved forever, // which would park whichever thread is validating. @@ -484,7 +486,7 @@ fn macos_validate_sck_displays() -> bool { } #[cfg(target_os = "macos")] -fn macos_request_permission(app: &tauri::AppHandle, permission: &OSPermission) { +fn macos_request_permission(app: &cap_desktop_runtime::AppHandle, permission: &OSPermission) { match permission { OSPermission::ScreenRecording => { if macos_run_on_main_thread(app, macos_prompt_screen_recording_access).is_none() { @@ -558,7 +560,7 @@ async fn macos_wait_for_permission_update(permission: &OSPermission) -> bool { } #[cfg(target_os = "macos")] -fn macos_open_permission_settings(app: &tauri::AppHandle, permission: &OSPermission) { +fn macos_open_permission_settings(app: &cap_desktop_runtime::AppHandle, permission: &OSPermission) { use std::process::Command; let process = Command::new("open") @@ -598,9 +600,9 @@ pub enum OSPermission { Accessibility, } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] -pub fn open_permission_settings(_app: tauri::AppHandle, _permission: OSPermission) { +pub fn open_permission_settings(_app: cap_desktop_runtime::AppHandle, _permission: OSPermission) { #[cfg(target_os = "macos")] { macos_activate_permission_request(&_app); @@ -608,17 +610,17 @@ pub fn open_permission_settings(_app: tauri::AppHandle, _permission: OSPermissio } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(_app))] -pub async fn request_permission(_app: tauri::AppHandle, _permission: OSPermission) { +pub async fn request_permission(_app: cap_desktop_runtime::AppHandle, _permission: OSPermission) { #[cfg(target_os = "macos")] { macos_activate_permission_request(&_app); let permission = _permission; let app = _app.clone(); - tauri::async_runtime::spawn_blocking(move || { + cap_desktop_runtime::async_runtime::spawn_blocking(move || { macos_request_permission(&app, &permission); }) .await @@ -670,7 +672,7 @@ impl OSPermissionsCheck { } } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] pub fn do_permissions_check(_initial_check: bool) -> OSPermissionsCheck { // Pool-wrapped because this runs on tokio/tauri worker threads (which have diff --git a/apps/desktop/src-tauri/src/platform/macos/delegates.rs b/apps/desktop/src-backend/src/platform/macos/delegates.rs similarity index 99% rename from apps/desktop/src-tauri/src/platform/macos/delegates.rs rename to apps/desktop/src-backend/src/platform/macos/delegates.rs index 5b6536b7f73..12d2a988d44 100644 --- a/apps/desktop/src-tauri/src/platform/macos/delegates.rs +++ b/apps/desktop/src-backend/src/platform/macos/delegates.rs @@ -1,4 +1,5 @@ // TODO(Ilya): Re-write all macos code to use `objc2` crates n +use cap_desktop_runtime::{Emitter, LogicalPosition, Runtime, Window}; /// /// Credit to @haasal, @charrondev, Hoppscotch app, Electron, Zed Editor /// @@ -12,7 +13,6 @@ /// (Electron) https://github.com/electron/electron/blob/38512efd25a159ddc64a54c22ef9eb6dd60064ec/shell/browser/native_window_mac.mm#L1454 /// use objc::{class, msg_send, sel, sel_impl}; -use tauri::{Emitter, LogicalPosition, Runtime, Window}; pub struct UnsafeWindowHandle(pub *mut std::ffi::c_void); unsafe impl Send for UnsafeWindowHandle {} @@ -380,7 +380,7 @@ pub fn setup(window: Window, controls_inset: LogicalPosition // // NOTE: `static CLASS` below is a single process-wide instance shared across every // monomorphization of this function, not one per `R`. `setup` is only ever called - // with `R = tauri::Wry` in this app, so this is fine in practice; if it were ever + // with `R = cap_desktop_runtime::Wry` in this app, so this is fine in practice; if it were ever // called with a different `R`, the first call's `on_*::` method pointers would // be baked into the shared class for all `R`. fn get_or_register_delegate_class() -> &'static Class { diff --git a/apps/desktop/src-backend/src/platform/macos/mod.rs b/apps/desktop/src-backend/src/platform/macos/mod.rs new file mode 100644 index 00000000000..dc7fd44cd0a --- /dev/null +++ b/apps/desktop/src-backend/src/platform/macos/mod.rs @@ -0,0 +1,20 @@ +mod sc_shareable_content; + +pub use sc_shareable_content::*; + +pub fn constrain_main_window_to_visible_top( + _window: &cap_desktop_runtime::Window, + _position: cap_desktop_runtime::PhysicalPosition, +) -> Option> { + None +} + +pub fn teardown_all_liquid_glass_on_main(app: &cap_desktop_runtime::AppHandle) -> usize { + let count = app.webview_windows().len(); + let _ = app.native_operation("window.teardownVisualEffects", serde_json::json!({})); + count +} + +pub async fn teardown_all_liquid_glass(app: &cap_desktop_runtime::AppHandle) -> Result<(), String> { + app.native_operation("window.teardownVisualEffects", serde_json::json!({})) +} diff --git a/apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs b/apps/desktop/src-backend/src/platform/macos/sc_shareable_content.rs similarity index 100% rename from apps/desktop/src-tauri/src/platform/macos/sc_shareable_content.rs rename to apps/desktop/src-backend/src/platform/macos/sc_shareable_content.rs diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-backend/src/platform/mod.rs similarity index 96% rename from apps/desktop/src-tauri/src/platform/mod.rs rename to apps/desktop/src-backend/src/platform/mod.rs index e51d3e709a3..c3b9e6a2055 100644 --- a/apps/desktop/src-tauri/src/platform/mod.rs +++ b/apps/desktop/src-backend/src/platform/mod.rs @@ -30,7 +30,7 @@ pub enum HapticPerformanceTime { DrawCompleted = 2, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub fn perform_haptic_feedback( @@ -58,7 +58,7 @@ pub fn perform_haptic_feedback( /// Check if system audio capture is supported on the current platform and OS version. /// On macOS, system audio capture requires macOS 13.0 or later. /// On Windows/Linux, this may have different requirements. -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub fn is_system_audio_capture_supported() -> bool { diff --git a/apps/desktop/src-tauri/src/platform/win.rs b/apps/desktop/src-backend/src/platform/win.rs similarity index 100% rename from apps/desktop/src-tauri/src/platform/win.rs rename to apps/desktop/src-backend/src/platform/win.rs diff --git a/apps/desktop/src-tauri/src/power_observer.rs b/apps/desktop/src-backend/src/power_observer.rs similarity index 98% rename from apps/desktop/src-tauri/src/power_observer.rs rename to apps/desktop/src-backend/src/power_observer.rs index 71e354470bb..f1057ce1239 100644 --- a/apps/desktop/src-tauri/src/power_observer.rs +++ b/apps/desktop/src-backend/src/power_observer.rs @@ -1,5 +1,5 @@ +use cap_desktop_runtime::AppHandle; use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::AppHandle; use tracing::info; static SYSTEM_ASLEEP: AtomicBool = AtomicBool::new(false); @@ -56,6 +56,7 @@ pub fn uninstall(app: &AppHandle) { #[cfg(target_os = "macos")] mod macos { use super::{on_system_did_wake, on_system_will_sleep}; + use cap_desktop_runtime::AppHandle; use objc2::{ AnyThread, DeclaredClass, define_class, msg_send, rc::{Retained, autoreleasepool}, @@ -64,7 +65,6 @@ mod macos { use objc2_app_kit::NSWorkspace; use objc2_foundation::{NSNotification, ns_string}; use std::sync::{Mutex, OnceLock}; - use tauri::AppHandle; use tracing::warn; struct ObserverIvars { @@ -190,9 +190,9 @@ mod windows { use ::windows::Win32::UI::WindowsAndMessaging::{ DEVICE_NOTIFY_CALLBACK, PBT_APMRESUMEAUTOMATIC, PBT_APMSUSPEND, }; + use cap_desktop_runtime::AppHandle; use std::ffi::c_void; use std::sync::{Mutex, OnceLock}; - use tauri::AppHandle; use tracing::warn; struct RegistrationHandle { diff --git a/apps/desktop/src-tauri/src/presets.rs b/apps/desktop/src-backend/src/presets.rs similarity index 97% rename from apps/desktop/src-tauri/src/presets.rs rename to apps/desktop/src-backend/src/presets.rs index c3d8d2f3c8c..cb54eaafc18 100644 --- a/apps/desktop/src-tauri/src/presets.rs +++ b/apps/desktop/src-backend/src/presets.rs @@ -1,9 +1,8 @@ +use cap_desktop_runtime::{AppHandle, Wry}; use cap_project::{ProjectConfiguration, TimelineConfiguration}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; -use tauri::{AppHandle, Wry}; -use tauri_plugin_store::StoreExt; use tracing::error; #[derive(Serialize, Deserialize, Type, Debug, Default)] diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-backend/src/recording.rs similarity index 98% rename from apps/desktop/src-tauri/src/recording.rs rename to apps/desktop/src-backend/src/recording.rs index 31c97af3525..3ea538aecaa 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-backend/src/recording.rs @@ -1,4 +1,6 @@ use anyhow::anyhow; +use cap_desktop_runtime::Event; +use cap_desktop_runtime::{AppHandle, Manager, path::BaseDirectory}; use cap_fail::fail; use cap_media_info::ffmpeg_sample_format_for; use cap_project::CursorMoveEvent; @@ -46,10 +48,6 @@ use std::{ sync::Arc, time::Duration, }; -use tauri::{AppHandle, Manager, path::BaseDirectory}; -use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder}; -use tauri_plugin_global_shortcut::GlobalShortcutExt; -use tauri_specta::Event; use tracing::*; use crate::camera::{CameraPreviewManager, CameraPreviewShape}; @@ -175,7 +173,7 @@ async fn store_current_desktop_background_snapshot( .map_err(|err| format!("Desktop background snapshot task failed: {err}"))? } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub async fn import_current_desktop_background(project_path: String) -> Result { @@ -837,7 +835,7 @@ impl CompletedRecording { } } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] pub async fn list_capture_displays() -> Vec { screen_capture::list_displays() @@ -846,9 +844,9 @@ pub async fn list_capture_displays() -> Vec { .collect() } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] -pub async fn list_capture_windows(window: tauri::Window) -> Vec { +pub async fn list_capture_windows(window: cap_desktop_runtime::Window) -> Vec { let windows = if window.label() == CapWindowId::Settings.label() { screen_capture::list_excludable_windows() } else { @@ -858,7 +856,7 @@ pub async fn list_capture_windows(window: tauri::Window) -> Vec { windows.into_iter().map(|(v, _)| v).collect() } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] pub fn list_cameras() -> Vec { if !permissions::do_permissions_check(false).camera.permitted() { @@ -945,7 +943,7 @@ fn get_best_format(formats: &[CameraFormatInfo]) -> Option { ideal_formats.into_iter().next().cloned() } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] pub fn get_camera_formats(device_id: String) -> Option { if !permissions::do_permissions_check(false).camera.permitted() { @@ -994,7 +992,7 @@ pub struct MicrophoneFormatInfo { pub channels: u16, } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] pub fn get_microphone_info(name: String) -> Option { if !permissions::do_permissions_check(false) @@ -1053,14 +1051,14 @@ fn microphone_format_infos(device: &cpal::Device) -> Vec { .collect() } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub async fn list_displays_with_thumbnails() -> Result, String> { run_non_send_thumbnail_future(collect_displays_with_thumbnails()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument] pub async fn list_windows_with_thumbnails() -> Result, String> { @@ -1073,9 +1071,9 @@ where { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if tokio::runtime::Handle::try_current().is_ok() { - tokio::task::block_in_place(|| tauri::async_runtime::block_on(future)) + tokio::task::block_in_place(|| cap_desktop_runtime::async_runtime::block_on(future)) } else { - tauri::async_runtime::block_on(future) + cap_desktop_runtime::async_runtime::block_on(future) } })); @@ -1123,7 +1121,7 @@ pub enum RecordingInputKind { Camera, } -#[derive(tauri_specta::Event, specta::Type, Clone, Debug, serde::Serialize)] +#[derive(cap_desktop_runtime::Event, specta::Type, Clone, Debug, serde::Serialize)] #[serde(tag = "variant")] pub enum RecordingEvent { Countdown { value: u32 }, @@ -1479,7 +1477,7 @@ pub fn format_project_name<'a>( result.into_owned() } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[tracing::instrument(name = "recording", skip_all)] pub async fn start_recording( @@ -2199,18 +2197,14 @@ pub async fn start_recording( } .emit(&app); - let mut dialog = MessageDialogBuilder::new( - app.dialog().clone(), - "An error occurred".to_string(), - error.clone(), - ) - .kind(tauri_plugin_dialog::MessageDialogKind::Error); - - if let Some(window) = CapWindowId::RecordingControls.get(&app) { - dialog = dialog.parent(&window); - } - - dialog.blocking_show(); + let _ = app.native_operation( + "dialog.message", + serde_json::json!({ + "title": "An error occurred", + "message": error, + "kind": "error" + }), + ); handle_recording_end(app, Err(error), &mut state, project_file_path) .await @@ -2415,7 +2409,7 @@ pub async fn start_recording( Ok(RecordingAction::Started) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, state))] pub async fn pause_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { @@ -2429,7 +2423,7 @@ pub async fn pause_recording(app: AppHandle, state: MutableState<'_, App>) -> Re Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, state))] pub async fn resume_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { @@ -2443,7 +2437,7 @@ pub async fn resume_recording(app: AppHandle, state: MutableState<'_, App>) -> R Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(state))] pub async fn set_mic_recording_muted( @@ -2476,7 +2470,7 @@ pub async fn set_mic_recording_muted( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, state))] pub async fn toggle_pause_recording( @@ -2521,18 +2515,14 @@ async fn handle_spawn_failure( message.contains("no longer available") || message.contains("DeviceNotFound"); if !is_device_not_found { - let mut dialog = MessageDialogBuilder::new( - app.dialog().clone(), - "An error occurred".to_string(), - message.clone(), - ) - .kind(tauri_plugin_dialog::MessageDialogKind::Error); - - if let Some(window) = CapWindowId::RecordingControls.get(app) { - dialog = dialog.parent(&window); - } - - dialog.blocking_show(); + let _ = app.native_operation( + "dialog.message", + serde_json::json!({ + "title": "An error occurred", + "message": message, + "kind": "error" + }), + ); } let mut state = state_mtx.write().await; @@ -2727,7 +2717,7 @@ async fn discard_recording(app: &AppHandle, recording: InProgressRecording) -> R local_delete } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, state))] pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { @@ -2770,7 +2760,7 @@ pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Res Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, state))] pub async fn restart_recording( @@ -2805,7 +2795,7 @@ pub async fn restart_recording( start_recording(app.clone(), state, inputs).await } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] #[instrument(skip(app, state))] pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { @@ -2846,7 +2836,7 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R Ok(()) } -#[tauri::command(async)] +#[cap_desktop_runtime::command(async)] #[specta::specta] #[tracing::instrument(name = "take_screenshot", skip(app))] pub async fn take_screenshot( @@ -2995,7 +2985,7 @@ pub async fn take_screenshot( let app_handle = app.clone(); let pending_state = PendingScreenshots(pending_screenshots.0.clone()); - tauri::async_runtime::spawn(async move { + cap_desktop_runtime::async_runtime::spawn(async move { let encode_result = tokio::task::spawn_blocking(move || -> Result<(), String> { let file = std::fs::File::create(&image_path_for_write) .map_err(|e| format!("Failed to create screenshot file: {e}"))?; @@ -3187,7 +3177,7 @@ async fn handle_recording_end( #[cfg(not(windows))] hide_overlay(&window); if let Some(ref fm) = focus_manager { - fm.destroy(&display_id, handle.global_shortcut()); + fm.destroy(&display_id, &handle); } } } diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-backend/src/recording_settings.rs similarity index 97% rename from apps/desktop/src-tauri/src/recording_settings.rs rename to apps/desktop/src-backend/src/recording_settings.rs index c8935289818..35b2c3afc5d 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-backend/src/recording_settings.rs @@ -1,3 +1,4 @@ +use cap_desktop_runtime::{AppHandle, Wry}; use cap_recording::{ RecordingMode, feeds::{ @@ -7,8 +8,6 @@ use cap_recording::{ sources::screen_capture::ScreenCaptureTarget, }; use std::collections::HashMap; -use tauri::{AppHandle, Wry}; -use tauri_plugin_store::StoreExt; use crate::tray; @@ -87,7 +86,7 @@ pub fn camera_key(id: &DeviceOrModelID) -> String { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub fn set_recording_mode(app: AppHandle, mode: RecordingMode) -> Result<(), String> { RecordingSettingsStore::set_mode(&app, mode)?; diff --git a/apps/desktop/src-tauri/src/recording_telemetry.rs b/apps/desktop/src-backend/src/recording_telemetry.rs similarity index 100% rename from apps/desktop/src-tauri/src/recording_telemetry.rs rename to apps/desktop/src-backend/src/recording_telemetry.rs diff --git a/apps/desktop/src-tauri/src/recordings_locations.rs b/apps/desktop/src-backend/src/recordings_locations.rs similarity index 99% rename from apps/desktop/src-tauri/src/recordings_locations.rs rename to apps/desktop/src-backend/src/recordings_locations.rs index 13f3c57ea55..e1948385a90 100644 --- a/apps/desktop/src-tauri/src/recordings_locations.rs +++ b/apps/desktop/src-backend/src/recordings_locations.rs @@ -3,10 +3,10 @@ use std::{ path::{Path, PathBuf}, }; +use cap_desktop_runtime::Event; +use cap_desktop_runtime::{AppHandle, Manager, Wry}; use serde::{Deserialize, Serialize}; use specta::Type; -use tauri::{AppHandle, Manager, Wry}; -use tauri_specta::Event; use tracing::{info, warn}; use crate::{ @@ -147,7 +147,7 @@ fn migratable_projects(app: &AppHandle) -> Vec { projects } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn count_recordings_to_migrate(app: AppHandle) -> Result { let app = app.clone(); @@ -168,7 +168,7 @@ impl Drop for MigrationLockGuard { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn migrate_recordings_to_current_dir( app: AppHandle, diff --git a/apps/desktop/src-tauri/src/recovery.rs b/apps/desktop/src-backend/src/recovery.rs similarity index 97% rename from apps/desktop/src-tauri/src/recovery.rs rename to apps/desktop/src-backend/src/recovery.rs index 20d00e0df62..ec851eef281 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-backend/src/recovery.rs @@ -1,10 +1,10 @@ +use cap_desktop_runtime::AppHandle; use cap_project::StudioRecordingMeta; use cap_recording::recovery::RecoveryManager; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use specta::Type; use std::path::PathBuf; -use tauri::AppHandle; use tracing::info; use crate::create_screenshot; @@ -39,7 +39,7 @@ pub struct IncompleteRecordingInfo { pub estimated_duration_secs: f64, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn find_incomplete_recordings( app: AppHandle, @@ -68,7 +68,7 @@ pub async fn find_incomplete_recordings( Ok(result) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { let path = PathBuf::from(&project_path); @@ -144,7 +144,7 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result Result<(), String> { let path = PathBuf::from(&project_path); diff --git a/apps/desktop/src-tauri/src/resource.rs b/apps/desktop/src-backend/src/resource.rs similarity index 100% rename from apps/desktop/src-tauri/src/resource.rs rename to apps/desktop/src-backend/src/resource.rs diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-backend/src/screenshot_editor.rs similarity index 99% rename from apps/desktop/src-tauri/src/screenshot_editor.rs rename to apps/desktop/src-backend/src/screenshot_editor.rs index 222da938b07..957303c7426 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-backend/src/screenshot_editor.rs @@ -2,6 +2,10 @@ use crate::PendingScreenshots; use crate::frame_ws::{WSFrame, create_watch_frame_ws}; use crate::gpu_context; use crate::windows::{CapWindowId, ScreenshotEditorWindowIds}; +use cap_desktop_runtime::{ + AppHandle, CommandContext, Manager, Window, + ipc::{CommandArg, InvokeError}, +}; use cap_project::{ ProjectConfiguration, RecordingMeta, RecordingMetaInner, SingleSegment, StudioRecordingMeta, VideoMeta, @@ -20,10 +24,6 @@ use std::io::Cursor; use std::str::FromStr; use std::time::Instant; use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc}; -use tauri::{ - AppHandle, Manager, Runtime, Window, - ipc::{CommandArg, InvokeError}, -}; use tokio::sync::{RwLock, watch}; use tokio_util::sync::CancellationToken; @@ -83,9 +83,9 @@ impl Deref for WindowScreenshotEditorInstance { } } -impl<'de, R: Runtime> CommandArg<'de, R> for WindowScreenshotEditorInstance { - fn from_command(command: tauri::ipc::CommandItem<'de, R>) -> Result { - let window = Window::from_command(command)?; +impl CommandArg for WindowScreenshotEditorInstance { + fn from_command(context: &CommandContext) -> Result { + let window = context.window(); let instances = window.state::(); let instance = futures::executor::block_on(instances.0.read()); @@ -738,7 +738,7 @@ struct ScreenshotOcrImage { height: u32, } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn create_screenshot_editor_instance( window: Window, @@ -906,7 +906,7 @@ pub async fn prewarm_screenshot_renderer() { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn prewarm_screenshot_background(path: String) -> Result<(), String> { let Some(gpu) = gpu_context::get_shared_gpu().await else { @@ -925,7 +925,7 @@ pub async fn prewarm_screenshot_background(path: String) -> Result<(), String> { Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn update_screenshot_config( instance: WindowScreenshotEditorInstance, @@ -961,7 +961,7 @@ pub async fn update_screenshot_config( Ok(()) } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn recognize_screenshot_text( instance: WindowScreenshotEditorInstance, @@ -1450,7 +1450,7 @@ fn clamp_f32_to_u32(value: f32) -> u32 { } } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn render_screenshot_for_export( instance: WindowScreenshotEditorInstance, @@ -1458,7 +1458,7 @@ pub async fn render_screenshot_for_export( render_screenshot_png(&instance).await } -#[tauri::command] +#[cap_desktop_runtime::command] #[specta::specta] pub async fn render_screenshot_project_for_export( app: AppHandle, diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-backend/src/target_select_overlay.rs similarity index 90% rename from apps/desktop/src-tauri/src/target_select_overlay.rs rename to apps/desktop/src-backend/src/target_select_overlay.rs index f48963b398e..6013a2a7123 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-backend/src/target_select_overlay.rs @@ -15,19 +15,18 @@ use crate::{ window_exclusion::WindowExclusion, windows::{CapWindowId, ShowCapWindow, hide_overlay, show_overlay}, }; +use cap_desktop_runtime::Event; +use cap_desktop_runtime::{AppHandle, Manager, WebviewWindow}; use scap_targets::{ Display, DisplayId, Window, WindowId, bounds::{LogicalBounds, LogicalSize, PhysicalSize}, }; use serde::Serialize; use specta::Type; -use tauri::{AppHandle, Manager, WebviewWindow}; -use tauri_plugin_global_shortcut::{GlobalShortcut, GlobalShortcutExt}; -use tauri_specta::Event; use tokio::task::JoinHandle; use tracing::{debug, error, instrument}; -#[derive(tauri_specta::Event, Serialize, Type, Clone)] +#[derive(cap_desktop_runtime::Event, Serialize, Type, Clone)] pub struct TargetUnderCursor { display_id: Option, window: Option, @@ -50,11 +49,11 @@ pub struct DisplayInformation { } #[specta::specta] -#[tauri::command] +#[cap_desktop_runtime::command] #[instrument(skip(app, state))] pub async fn open_target_select_overlays( app: AppHandle, - state: tauri::State<'_, WindowFocusManager>, + state: cap_desktop_runtime::State<'_, WindowFocusManager>, focused_target: Option, specific_display_id: Option, target_mode: Option, @@ -99,7 +98,7 @@ pub async fn open_target_select_overlays( .any(|display_id| display_id == &existing_id) { hide_overlay(&window); - state.destroy(&existing_id, app.global_shortcut()); + state.destroy(&existing_id, &app); } } @@ -210,7 +209,7 @@ pub async fn open_target_select_overlays( { task.abort(); } - state.register_escape(app.global_shortcut()); + state.register_escape(&app); Ok(()) } @@ -260,7 +259,7 @@ fn should_skip_window(window: &Window, exclusions: &[WindowExclusion]) -> bool { } #[specta::specta] -#[tauri::command] +#[cap_desktop_runtime::command] #[instrument(skip(app))] pub async fn update_camera_overlay_bounds( app: AppHandle, @@ -277,16 +276,20 @@ pub async fn update_camera_overlay_bounds( let height_u32 = height as u32; window - .set_size(tauri::Size::Physical(tauri::PhysicalSize { - width: width_u32, - height: height_u32, - })) + .set_size(cap_desktop_runtime::Size::Physical( + cap_desktop_runtime::PhysicalSize { + width: width_u32, + height: height_u32, + }, + )) .map_err(|e| e.to_string())?; window - .set_position(tauri::Position::Physical(tauri::PhysicalPosition { - x: x as i32, - y: y as i32, - })) + .set_position(cap_desktop_runtime::Position::Physical( + cap_desktop_runtime::PhysicalPosition { + x: x as i32, + y: y as i32, + }, + )) .map_err(|e| e.to_string())?; let scale_factor = window.scale_factor().unwrap_or(1.0); @@ -303,11 +306,11 @@ pub async fn update_camera_overlay_bounds( } #[specta::specta] -#[tauri::command] +#[cap_desktop_runtime::command] #[instrument(skip(app, _state))] pub async fn close_target_select_overlays( app: AppHandle, - _state: tauri::State<'_, WindowFocusManager>, + _state: cap_desktop_runtime::State<'_, WindowFocusManager>, ) -> Result<(), String> { close_target_select_overlay_windows(&app); @@ -332,7 +335,7 @@ pub fn close_target_select_overlay_windows(app: &AppHandle) { #[cfg(not(windows))] hide_overlay(&window); if let Some(state) = state.as_ref() { - state.destroy(&display_id, app.global_shortcut()); + state.destroy(&display_id, app); } } } @@ -343,7 +346,7 @@ pub fn close_target_select_overlay_windows(app: &AppHandle) { } #[specta::specta] -#[tauri::command] +#[cap_desktop_runtime::command] #[instrument] pub async fn get_window_icon(window_id: &str) -> Result, String> { let window_id = window_id @@ -357,7 +360,7 @@ pub async fn get_window_icon(window_id: &str) -> Result, String> } #[specta::specta] -#[tauri::command] +#[cap_desktop_runtime::command] #[instrument] pub async fn display_information(display_id: &str) -> Result { let display_id = display_id @@ -375,7 +378,7 @@ pub async fn display_information(display_id: &str) -> Result Result<(), String> { let window = Window::from_id(&window_id).ok_or("Window not found")?; @@ -480,8 +483,7 @@ impl WindowFocusManager { if main_window_was_seen && !main_window_available && !settings_window_available { hide_overlay(&window); - app.state::() - .finish(&display_id, app.global_shortcut()); + app.state::().finish(&display_id, app); break; } @@ -543,19 +545,19 @@ impl WindowFocusManager { labels.clear(); } - fn finish(&self, id: &DisplayId, global_shortcut: &GlobalShortcut) { + fn finish(&self, id: &DisplayId, app: &AppHandle) { let mut tasks = self.tasks.lock().unwrap_or_else(PoisonError::into_inner); tasks.remove(&id.to_string()); drop(tasks); - self.finish_if_idle(global_shortcut); + self.finish_if_idle(app); } - fn finish_if_idle(&self, global_shortcut: &GlobalShortcut) { + fn finish_if_idle(&self, app: &AppHandle) { let tasks = self.tasks.lock().unwrap_or_else(PoisonError::into_inner); if tasks.is_empty() { drop(tasks); - self.unregister_escape(global_shortcut); + self.unregister_escape(app); if let Some(task) = self .task @@ -569,23 +571,23 @@ impl WindowFocusManager { } /// Called when a specific overlay window is destroyed to cleanup it's resources - pub fn destroy(&self, id: &DisplayId, global_shortcut: &GlobalShortcut) { + pub fn destroy(&self, id: &DisplayId, app: &AppHandle) { let mut tasks = self.tasks.lock().unwrap_or_else(PoisonError::into_inner); if let Some(task) = tasks.remove(&id.to_string()) { task.abort(); } drop(tasks); - self.finish_if_idle(global_shortcut); + self.finish_if_idle(app); } pub fn shutdown(&self, app: &AppHandle) { self.abort_all_tasks(); - self.unregister_escape(app.global_shortcut()); + self.unregister_escape(app); } - fn register_escape(&self, global_shortcut: &GlobalShortcut) { + fn register_escape(&self, app: &AppHandle) { let mut registered = self .escape_registered .lock() @@ -594,13 +596,16 @@ impl WindowFocusManager { return; } - match global_shortcut.register("Escape") { + match app.native_operation( + "hotkeys.escapeEnabled", + serde_json::json!({ "enabled": true }), + ) { Ok(()) => *registered = true, Err(err) => error!("Error registering global keyboard shortcut for Escape: {err}"), } } - fn unregister_escape(&self, global_shortcut: &GlobalShortcut) { + fn unregister_escape(&self, app: &AppHandle) { { let mut registered = self .escape_registered @@ -612,7 +617,10 @@ impl WindowFocusManager { *registered = false; } - if let Err(err) = global_shortcut.unregister("Escape") { + if let Err(err) = app.native_operation( + "hotkeys.escapeEnabled", + serde_json::json!({ "enabled": false }), + ) { debug!("Error unregistering global keyboard shortcut for Escape: {err}"); } } diff --git a/apps/desktop/src-tauri/src/telemetry.rs b/apps/desktop/src-backend/src/telemetry.rs similarity index 99% rename from apps/desktop/src-tauri/src/telemetry.rs rename to apps/desktop/src-backend/src/telemetry.rs index 10d1d908667..6ea30f03718 100644 --- a/apps/desktop/src-tauri/src/telemetry.rs +++ b/apps/desktop/src-backend/src/telemetry.rs @@ -1,3 +1,4 @@ +use cap_desktop_runtime::AppHandle; use serde::Serialize; use serde_json::{Map, Value}; use std::{ @@ -7,7 +8,6 @@ use std::{ }, time::Duration, }; -use tauri::AppHandle; use tracing::debug; use crate::auth::AuthStore; @@ -18,7 +18,7 @@ const CLIENT_ID: Option<&str> = option_env!("VITE_OPENPANEL_CLIENT_ID"); /// same auth path the webview SDK uses — so no credential ships in the binary. /// Must stay on the OpenPanel client's CORS allowlist alongside the webview /// origins. -const ORIGIN: &str = "tauri://localhost"; +const ORIGIN: &str = "cap://app"; const API_URL: Option<&str> = option_env!("VITE_OPENPANEL_API_URL"); const DEFAULT_API_URL: &str = "https://api.openpanel.dev"; diff --git a/apps/desktop/src-tauri/src/thumbnails/linux.rs b/apps/desktop/src-backend/src/thumbnails/linux.rs similarity index 100% rename from apps/desktop/src-tauri/src/thumbnails/linux.rs rename to apps/desktop/src-backend/src/thumbnails/linux.rs diff --git a/apps/desktop/src-tauri/src/thumbnails/mac.rs b/apps/desktop/src-backend/src/thumbnails/mac.rs similarity index 100% rename from apps/desktop/src-tauri/src/thumbnails/mac.rs rename to apps/desktop/src-backend/src/thumbnails/mac.rs diff --git a/apps/desktop/src-tauri/src/thumbnails/mod.rs b/apps/desktop/src-backend/src/thumbnails/mod.rs similarity index 100% rename from apps/desktop/src-tauri/src/thumbnails/mod.rs rename to apps/desktop/src-backend/src/thumbnails/mod.rs diff --git a/apps/desktop/src-tauri/src/thumbnails/windows.rs b/apps/desktop/src-backend/src/thumbnails/windows.rs similarity index 100% rename from apps/desktop/src-tauri/src/thumbnails/windows.rs rename to apps/desktop/src-backend/src/thumbnails/windows.rs diff --git a/apps/desktop/src-backend/src/tray.rs b/apps/desktop/src-backend/src/tray.rs new file mode 100644 index 00000000000..b6f1276261b --- /dev/null +++ b/apps/desktop/src-backend/src/tray.rs @@ -0,0 +1,204 @@ +use cap_desktop_runtime::{AppHandle, Event}; +use cap_recording::RecordingMode; +use serde_json::json; + +use crate::{ + RecordingStarted, RecordingStopped, RequestOpenSettings, recording, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + windows::ShowCapWindow, +}; + +fn current_mode(app: &AppHandle) -> RecordingMode { + RecordingSettingsStore::get(app) + .ok() + .flatten() + .and_then(|settings| settings.mode) + .unwrap_or_default() +} + +fn menu(app: &AppHandle) -> serde_json::Value { + let mode = current_mode(app); + let mut items = vec![ + json!({ "id": "open_cap", "text": "Open Cap" }), + json!({ "type": "separator" }), + json!({ "id": "record_display", "text": "Record Display" }), + json!({ "id": "record_window", "text": "Record Window" }), + json!({ "id": "record_area", "text": "Record Area" }), + json!({ "id": "take_screenshot", "text": "Take Screenshot" }), + json!({ "id": "import_video", "text": "Import Media" }), + json!({ + "text": "Recording Mode", + "items": [ + { "id": "mode_studio", "text": "Studio", "checked": mode == RecordingMode::Studio }, + { "id": "mode_instant", "text": "Instant", "checked": mode == RecordingMode::Instant }, + { "id": "mode_screenshot", "text": "Screenshot", "checked": mode == RecordingMode::Screenshot } + ] + }), + json!({ "type": "separator" }), + json!({ "id": "view_all_recordings", "text": "View Recordings" }), + json!({ "id": "view_all_screenshots", "text": "View Screenshots" }), + json!({ "id": "open_settings", "text": "Settings" }), + ]; + if !crate::permissions::do_permissions_check(false).necessary_granted() { + items.push(json!({ "id": "request_permissions", "text": "Grant Permissions" })); + } + items.extend([ + json!({ "id": "upload_logs", "text": "Upload Logs" }), + json!({ "type": "separator" }), + json!({ "id": "quit", "text": "Quit Cap" }), + ]); + json!({ "items": items, "mode": mode }) +} + +pub(crate) fn refresh_tray_menu_for_app(app: &AppHandle) { + let _ = app.native_operation("tray.configure", menu(app)); +} + +pub fn update_tray_icon_for_mode(app: &AppHandle, mode: RecordingMode) { + let _ = app.native_operation("tray.setMode", json!({ "mode": mode })); + refresh_tray_menu_for_app(app); +} + +pub fn create_tray(app: &AppHandle) -> cap_desktop_runtime::Result<()> { + refresh_tray_menu_for_app(app); + + let app_handle = app.clone(); + app.listen("tray://click", move |event| { + let Ok(id) = serde_json::from_str::(event.payload()) else { + return; + }; + dispatch(app_handle.clone(), id); + }); + + let app_handle = app.clone(); + RecordingStarted::listen_any(app, move |_| { + let _ = app_handle.native_operation("tray.setRecording", json!({ "recording": true })); + }); + let app_handle = app.clone(); + RecordingStopped::listen_any(app, move |_| { + let _ = app_handle.native_operation("tray.setRecording", json!({ "recording": false })); + update_tray_icon_for_mode(&app_handle, current_mode(&app_handle)); + }); + Ok(()) +} + +fn dispatch(app: AppHandle, id: String) { + tokio::spawn(async move { + match id.as_str() { + "open_cap" => { + let _ = ShowCapWindow::Main { + init_target_mode: None, + } + .show(&app) + .await; + } + "record_display" => crate::open_target_picker(&app, RecordingTargetMode::Display).await, + "record_window" => crate::open_target_picker(&app, RecordingTargetMode::Window).await, + "record_area" => crate::open_target_picker(&app, RecordingTargetMode::Area).await, + "take_screenshot" => { + use cap_recording::screen_capture::ScreenCaptureTarget; + use scap_targets::Display; + let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); + let target = ScreenCaptureTarget::Display { id: display.id() }; + match recording::take_screenshot(app.clone(), target.clone()).await { + Ok(path) if crate::automation::should_open_screenshot_editor(&app, &target) => { + let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + } + Ok(_) => {} + Err(error) => tracing::error!(%error, "Failed to take screenshot from tray"), + } + } + "stop_recording" => { + let _ = recording::stop_recording(app.clone(), app.state()).await; + } + "import_video" => import_media(&app).await, + "view_all_recordings" => { + let _ = RequestOpenSettings { + page: "recordings".to_string(), + } + .emit(&app); + } + "view_all_screenshots" => { + let _ = RequestOpenSettings { + page: "screenshots".to_string(), + } + .emit(&app); + } + "open_settings" => { + let _ = ShowCapWindow::Settings { page: None }.show(&app).await; + } + "upload_logs" => { + let result = crate::logging::upload_log_file(&app).await; + let message = if result.is_ok() { + "Logs uploaded successfully" + } else { + "Failed to upload logs" + }; + let _ = app.native_operation("dialog.message", json!({ "message": message })); + } + "mode_studio" => set_mode(&app, RecordingMode::Studio), + "mode_instant" => set_mode(&app, RecordingMode::Instant), + "mode_screenshot" => set_mode(&app, RecordingMode::Screenshot), + "request_permissions" => { + let _ = ShowCapWindow::Onboarding.show(&app).await; + } + "quit" => crate::request_app_exit(app.clone()).await, + _ => {} + } + }); +} + +fn set_mode(app: &AppHandle, mode: RecordingMode) { + if let Err(error) = RecordingSettingsStore::set_mode(app, mode) { + tracing::error!(%error, "Failed to set recording mode from tray"); + return; + } + update_tray_icon_for_mode(app, mode); +} + +async fn import_media(app: &AppHandle) { + let path = match app + .native_request::, _>( + "dialog.open", + json!({ + "title": "Import Media", + "filters": [{ + "name": "Media Files", + "extensions": ["mp4", "mov", "avi", "mkv", "webm", "wmv", "m4v", "flv", "png", "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff"] + }] + }), + ) + .await + { + Ok(Some(path)) => std::path::PathBuf::from(path), + Ok(None) => return, + Err(error) => { + tracing::error!(%error, "Failed to open import dialog"); + return; + } + }; + + let result = if crate::import::is_supported_video_import_path(&path) { + crate::import::start_video_import(app.clone(), path) + .await + .map(|project_path| ShowCapWindow::Editor { project_path }) + } else if crate::import::is_supported_image_import_path(&path) { + crate::import::start_image_import(app.clone(), path) + .await + .map(|path| ShowCapWindow::ScreenshotEditor { path }) + } else { + Err("Unsupported media file".to_string()) + }; + + match result { + Ok(window) => { + let _ = window.show(app).await; + } + Err(error) => { + let _ = app.native_operation( + "dialog.message", + json!({ "title": "Import Error", "message": error, "kind": "error" }), + ); + } + } +} diff --git a/apps/desktop/src-tauri/src/update_project_names.rs b/apps/desktop/src-backend/src/update_project_names.rs similarity index 99% rename from apps/desktop/src-tauri/src/update_project_names.rs rename to apps/desktop/src-backend/src/update_project_names.rs index 8d008617064..d7d9a5a501f 100644 --- a/apps/desktop/src-tauri/src/update_project_names.rs +++ b/apps/desktop/src-backend/src/update_project_names.rs @@ -4,10 +4,9 @@ use std::{ sync::Arc, }; +use cap_desktop_runtime::AppHandle; use cap_project::RecordingMeta; use futures::StreamExt; -use tauri::AppHandle; -use tauri_plugin_store::StoreExt; use tokio::{fs, sync::Mutex}; use crate::recordings_path; diff --git a/apps/desktop/src-backend/src/updates.rs b/apps/desktop/src-backend/src/updates.rs new file mode 100644 index 00000000000..c37d55c2d09 --- /dev/null +++ b/apps/desktop/src-backend/src/updates.rs @@ -0,0 +1,82 @@ +use cap_desktop_runtime::AppHandle; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::general_settings::GeneralSettingsStore; + +#[derive(Serialize, Deserialize, Type, Clone, Copy, PartialEq, Eq, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub enum UpdateChannel { + #[default] + Stable, + Nightly, +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCheckResult { + pub version: String, + pub notes: Option, + pub channel: UpdateChannel, +} + +#[derive(Serialize, Type, cap_desktop_runtime::Event, Clone, Debug)] +pub struct UpdateDownloadProgress { + pub downloaded: u32, + pub total: Option, +} + +#[derive(Serialize, Type, cap_desktop_runtime::Event, Clone, Debug)] +pub struct UpdateReady { + pub version: String, + pub installed: bool, +} + +#[derive(Default)] +pub struct UpdatesState; + +fn current_channel(app: &AppHandle) -> UpdateChannel { + GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|settings| settings.update_channel) + .unwrap_or_default() +} + +#[cap_desktop_runtime::command] +#[specta::specta] +pub async fn updates_check(app: AppHandle) -> Result, String> { + let channel = current_channel(&app); + app.native_request("updater.check", serde_json::json!({ "channel": channel })) + .await +} + +#[cap_desktop_runtime::command] +#[specta::specta] +pub async fn updates_download_and_install(app: AppHandle) -> Result<(), String> { + app.native_request::( + "updater.downloadAndInstall", + serde_json::json!({ "channel": current_channel(&app) }), + ) + .await + .map(|_| ()) +} + +#[cap_desktop_runtime::command] +#[specta::specta] +pub fn updates_channel_changed(app: AppHandle) -> Result<(), String> { + app.native_operation( + "updater.setChannel", + serde_json::json!({ "channel": current_channel(&app) }), + ) +} + +pub fn spawn_background_loop(app: AppHandle) { + if cfg!(debug_assertions) { + return; + } + let _ = app.native_operation( + "updater.configure", + serde_json::json!({ "channel": current_channel(&app) }), + ); +} diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-backend/src/upload.rs similarity index 99% rename from apps/desktop/src-tauri/src/upload.rs rename to apps/desktop/src-backend/src/upload.rs index 8d197da43c1..cb1d7c0b8d3 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-backend/src/upload.rs @@ -9,6 +9,8 @@ use crate::{ }; use async_stream::{stream, try_stream}; use bytes::Bytes; +use cap_desktop_runtime::Event; +use cap_desktop_runtime::{AppHandle, Manager, ipc::Channel}; use cap_project::{RecordingMeta, S3UploadMeta, UploadMeta}; use cap_utils::spawn_actor; use ffmpeg::ffi::AV_TIME_BASE; @@ -27,9 +29,6 @@ use std::{ sync::{Arc, Mutex, PoisonError}, time::Duration, }; -use tauri::{AppHandle, Manager, ipc::Channel}; -use tauri_plugin_clipboard_manager::ClipboardExt; -use tauri_specta::Event; use tokio::{ fs::File, io::{AsyncReadExt, AsyncSeekExt, BufReader}, @@ -47,7 +46,7 @@ pub struct UploadedItem { // pub config: S3UploadMeta, } -#[derive(Clone, Serialize, Type, tauri_specta::Event)] +#[derive(Clone, Serialize, Type, cap_desktop_runtime::Event)] pub struct UploadProgressEvent { video_id: String, uploaded: String, @@ -690,7 +689,10 @@ impl InstantMultipartUpload { .save_for_project() .map_err(|err| format!("Error reading project meta from {recording_dir:?}: {err}"))?; - let _ = app.clipboard().write_text(pre_created_video.link.clone()); + let _ = app.native_operation( + "clipboard.writeText", + serde_json::json!({ "text": pre_created_video.link }), + ); Ok(metadata) } @@ -1634,7 +1636,10 @@ impl SegmentUploader { .save_for_project() .map_err(|err| format!("Error saving project meta for {recording_dir:?}: {err}"))?; - let _ = app.clipboard().write_text(pre_created_video.link.clone()); + let _ = app.native_operation( + "clipboard.writeText", + serde_json::json!({ "text": pre_created_video.link }), + ); let total_bytes = state .lock() diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-backend/src/web_api.rs similarity index 99% rename from apps/desktop/src-tauri/src/web_api.rs rename to apps/desktop/src-backend/src/web_api.rs index a1bf685c4d7..4cf1252ea99 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-backend/src/web_api.rs @@ -1,5 +1,5 @@ +use cap_desktop_runtime::{Emitter, Manager, Runtime}; use reqwest::StatusCode; -use tauri::{Emitter, Manager, Runtime}; use thiserror::Error; use tracing::{debug, error, warn}; diff --git a/apps/desktop/src-tauri/src/window_exclusion.rs b/apps/desktop/src-backend/src/window_exclusion.rs similarity index 94% rename from apps/desktop/src-tauri/src/window_exclusion.rs rename to apps/desktop/src-backend/src/window_exclusion.rs index 23ef1e373f5..dfa0eafffb4 100644 --- a/apps/desktop/src-tauri/src/window_exclusion.rs +++ b/apps/desktop/src-backend/src/window_exclusion.rs @@ -120,12 +120,12 @@ pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { #[cfg(target_os = "macos")] pub fn append_matching_webview_window_ids( ids: &mut Vec, - app: &tauri::AppHandle, + app: &cap_desktop_runtime::AppHandle, exclusions: &[WindowExclusion], ) { use crate::windows::CapWindowId; + use cap_desktop_runtime::Manager; use std::str::FromStr; - use tauri::Manager; for (label, window) in app.webview_windows() { let Ok(window_id) = CapWindowId::from_str(&label) else { @@ -139,7 +139,7 @@ pub fn append_matching_webview_window_ids( warn!( label = %label, title = %title, - "Excluded Tauri webview window has no native window id" + "Excluded Electron window has no native window id" ); continue; }; @@ -149,14 +149,14 @@ pub fn append_matching_webview_window_ids( window_id = %native_id, label = %label, title = %title, - "Excluded Tauri webview window id is not visible to CGWindowList" + "Excluded Electron window id is not visible to CGWindowList" ); } else { debug!( window_id = %native_id, label = %label, title = %title, - "Skipping hidden excluded Tauri webview window" + "Skipping hidden excluded Electron window" ); } continue; @@ -166,7 +166,7 @@ pub fn append_matching_webview_window_ids( window_id = %native_id, label = %label, title = %title, - "Excluded Tauri webview window id already resolved" + "Excluded Electron window id already resolved" ); continue; } @@ -174,22 +174,21 @@ pub fn append_matching_webview_window_ids( window_id = %native_id, label = %label, title = %title, - "Resolved excluded Tauri webview window" + "Resolved excluded Electron window" ); ids.push(native_id); } } #[cfg(target_os = "macos")] -fn webview_window_id(window: &tauri::WebviewWindow) -> Option { - let ns_window = window.ns_window().ok()? as *const objc2_app_kit::NSWindow; - let number = unsafe { (*ns_window).windowNumber() }; - - if number <= 0 { - return None; - } - - number.to_string().parse().ok() +fn webview_window_id(window: &cap_desktop_runtime::WebviewWindow) -> Option { + window + .native_window_id() + .ok() + .flatten()? + .to_string() + .parse() + .ok() } #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/window_position_persistence.rs b/apps/desktop/src-backend/src/window_position_persistence.rs similarity index 98% rename from apps/desktop/src-tauri/src/window_position_persistence.rs rename to apps/desktop/src-backend/src/window_position_persistence.rs index 2831a1bb5af..f499d0576d9 100644 --- a/apps/desktop/src-tauri/src/window_position_persistence.rs +++ b/apps/desktop/src-backend/src/window_position_persistence.rs @@ -1,7 +1,7 @@ +use cap_desktop_runtime::{AppHandle, Manager}; use scap_targets::DisplayId; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tauri::{AppHandle, Manager}; use tokio::sync::Notify; use crate::general_settings::{GeneralSettingsStore, WindowPosition}; diff --git a/apps/desktop/src-backend/src/windows.rs b/apps/desktop/src-backend/src/windows.rs new file mode 100644 index 00000000000..a04864ee6b0 --- /dev/null +++ b/apps/desktop/src-backend/src/windows.rs @@ -0,0 +1,860 @@ +use std::{ + ops::Deref, + path::{Path, PathBuf}, + str::FromStr, + sync::{ + Arc, Mutex, + atomic::{AtomicU32, Ordering}, + }, + time::Duration, +}; + +use cap_desktop_runtime::{ + AppHandle, DesktopOperation, Event, LogicalPosition, Position, WebviewWindow, Window, + WindowOptions, WindowState, +}; +use cap_recording::{feeds, sources::screen_capture::ScreenCaptureTarget}; +use scap_targets::DisplayId; +use serde::Deserialize; +use specta::Type; +use tracing::{error, warn}; + +use crate::{ + App, ArcLock, NewNotification, camera_preview_error_message, + editor_window::PendingEditorInstances, + emit_camera_preview_clear, emit_camera_preview_error, + recording::{RecordingEvent, RecordingInputKind}, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + screenshot_editor::PendingScreenshotEditorInstances, +}; + +pub fn hide_overlay(window: &WebviewWindow) { + let _ = window.set_ignore_cursor_events(true); + let _ = window.hide(); +} + +pub fn show_overlay(window: &WebviewWindow) { + let _ = window.set_ignore_cursor_events(false); + let _ = window.show(); +} + +pub fn logical_point_position(pos_x: f64, pos_y: f64) -> Position { + LogicalPosition::new(pos_x, pos_y).into() +} + +pub(crate) async fn ensure_camera_input_active(app_state: &mut App) { + if let Some(id) = app_state.selected_camera_id.clone() + && !app_state.camera_in_use + { + let settings = RecordingSettingsStore::camera_settings_for(&app_state.handle, &id); + match app_state + .camera_feed + .ask(feeds::camera::SetInput { id, settings }) + .await + { + Ok(ready_future) => { + if let Err(error) = ready_future.await { + error!(%error, "Camera failed to initialize"); + return; + } + } + Err(error) => { + error!(%error, "Failed to send SetInput to camera feed"); + return; + } + } + app_state.camera_in_use = true; + app_state.camera_cleanup_done = false; + } +} + +pub(crate) async fn restore_main_window_inputs(app: &AppHandle) { + let Some(state) = app.try_state::>() else { + warn!("App state unavailable while restoring main window inputs"); + return; + }; + if !state + .try_read() + .map(|state| !state.is_recording_active_or_pending()) + .unwrap_or(false) + { + return; + } + + let settings = RecordingSettingsStore::get(app) + .ok() + .flatten() + .unwrap_or_default(); + if let Err(error) = crate::set_mic_input(state.clone(), settings.mic_name).await { + warn!(%error, "Failed to restore microphone input for main window"); + } + + let camera_to_restore = state + .try_read() + .ok() + .and_then(|state| { + (!state.camera_cleanup_done && !state.camera_in_use) + .then(|| { + state + .selected_camera_id + .clone() + .or(settings.camera_id.clone()) + }) + .flatten() + }) + .filter(crate::is_camera_available); + + let Some(camera_id) = camera_to_restore else { + return; + }; + emit_camera_preview_clear(app); + let camera_settings = RecordingSettingsStore::camera_settings_for(app, &camera_id); + let camera_feed = { + let app_state = &mut *state.write().await; + app_state.selected_camera_id = Some(camera_id.clone()); + app_state.camera_in_use = true; + app_state.camera_cleanup_done = false; + app_state.camera_feed.clone() + }; + + let mut attempts = 0; + let result = loop { + attempts += 1; + match camera_feed + .ask(feeds::camera::SetInput { + id: camera_id.clone(), + settings: camera_settings, + }) + .await + { + Ok(ready) => match ready.await { + Ok(_) => break Ok(()), + Err(error) if attempts < 3 => { + warn!(%error, attempts, "Camera restore attempt failed"); + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(error) => break Err(error.to_string()), + }, + Err(error) if attempts < 3 => { + warn!(%error, attempts, "Camera restore request failed"); + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(error) => break Err(error.to_string()), + } + }; + + if let Err(error) = result { + let message = camera_preview_error_message(&error); + let _ = camera_feed.ask(feeds::camera::RemoveInput).await; + let emit_input_lost = { + let app_state = &mut *state.write().await; + app_state.selected_camera_id = None; + app_state.camera_in_use = false; + app_state + .disconnected_inputs + .insert(RecordingInputKind::Camera) + }; + if emit_input_lost { + let _ = RecordingEvent::InputLost { + input: RecordingInputKind::Camera, + } + .emit(app); + } + emit_camera_preview_error(app, message.clone()); + let _ = NewNotification { + title: "Camera unavailable".to_string(), + body: message, + is_error: true, + } + .emit(app); + } +} + +pub(crate) async fn cleanup_camera_window( + app: &AppHandle, + window: Option<&WebviewWindow>, + _reset_panel: bool, + wait_for_removal: bool, +) -> bool { + let windows = window + .cloned() + .map(|window| vec![window]) + .unwrap_or_else(|| { + app.webview_windows() + .into_iter() + .filter(|(label, _)| is_camera_window_label(label)) + .map(|(_, window)| window) + .collect() + }); + for window in windows { + let _ = window.destroy(); + } + if wait_for_removal { + tokio::time::sleep(Duration::from_millis(100)).await; + } + !app.webview_windows() + .keys() + .any(|label| is_camera_window_label(label)) +} + +fn is_camera_window_label(label: &str) -> bool { + label == "camera" + || label + .strip_prefix("camera-") + .is_some_and(|suffix| suffix.parse::().is_ok()) +} + +#[derive(Clone, Deserialize, Type)] +pub enum CapWindowId { + Main, + Settings, + Editor { id: u32 }, + RecordingsOverlay, + WindowCaptureOccluder { screen_id: DisplayId }, + TargetSelectOverlay { display_id: DisplayId }, + CaptureArea, + Camera, + RecordingControls, + Upgrade, + ModeSelect, + Debug, + ScreenshotEditor { id: u32 }, + Onboarding, + Teleprompter, +} + +impl FromStr for CapWindowId { + type Err = String; + + fn from_str(value: &str) -> Result { + Ok(match value { + "main" => Self::Main, + "settings" => Self::Settings, + value if is_camera_window_label(value) => Self::Camera, + "capture-area" => Self::CaptureArea, + "in-progress-recording" => Self::RecordingControls, + "recordings-overlay" => Self::RecordingsOverlay, + "upgrade" => Self::Upgrade, + "mode-select" => Self::ModeSelect, + "debug" => Self::Debug, + "onboarding" => Self::Onboarding, + "teleprompter" => Self::Teleprompter, + value if value.starts_with("editor-") => Self::Editor { + id: value + .strip_prefix("editor-") + .expect("guarded by starts_with") + .parse::() + .map_err(|error| error.to_string())?, + }, + value if value.starts_with("screenshot-editor-") => Self::ScreenshotEditor { + id: value + .strip_prefix("screenshot-editor-") + .expect("guarded by starts_with") + .parse::() + .map_err(|error| error.to_string())?, + }, + value if value.starts_with("window-capture-occluder-") => Self::WindowCaptureOccluder { + screen_id: value + .strip_prefix("window-capture-occluder-") + .expect("guarded by starts_with") + .parse::() + .map_err(|error| error.to_string())?, + }, + value if value.starts_with("target-select-overlay-") => Self::TargetSelectOverlay { + display_id: value + .strip_prefix("target-select-overlay-") + .expect("guarded by starts_with") + .parse::() + .map_err(|error| error.to_string())?, + }, + _ => return Err(format!("unknown window label: {value}")), + }) + } +} + +impl std::fmt::Display for CapWindowId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Main => write!(formatter, "main"), + Self::Settings => write!(formatter, "settings"), + Self::Editor { id } => write!(formatter, "editor-{id}"), + Self::RecordingsOverlay => write!(formatter, "recordings-overlay"), + Self::WindowCaptureOccluder { screen_id } => { + write!(formatter, "window-capture-occluder-{screen_id}") + } + Self::TargetSelectOverlay { display_id } => { + write!(formatter, "target-select-overlay-{display_id}") + } + Self::CaptureArea => write!(formatter, "capture-area"), + Self::Camera => write!(formatter, "camera"), + Self::RecordingControls => write!(formatter, "in-progress-recording"), + Self::Upgrade => write!(formatter, "upgrade"), + Self::ModeSelect => write!(formatter, "mode-select"), + Self::Debug => write!(formatter, "debug"), + Self::ScreenshotEditor { id } => write!(formatter, "screenshot-editor-{id}"), + Self::Onboarding => write!(formatter, "onboarding"), + Self::Teleprompter => write!(formatter, "teleprompter"), + } + } +} + +impl CapWindowId { + pub fn label(&self) -> String { + self.to_string() + } + + #[cfg(target_os = "macos")] + pub fn activates_dock(&self) -> bool { + matches!( + self, + Self::Main + | Self::Editor { .. } + | Self::ScreenshotEditor { .. } + | Self::Settings + | Self::Upgrade + | Self::ModeSelect + | Self::Onboarding + ) + } + + pub fn title(&self) -> String { + match self { + Self::Settings => "Cap Settings", + Self::WindowCaptureOccluder { .. } => "Cap Window Capture Occluder", + Self::CaptureArea => "Cap Capture Area", + Self::RecordingControls => "Cap Recording Controls", + Self::Editor { .. } => "Cap Editor", + Self::ScreenshotEditor { .. } => "Cap Screenshot Editor", + Self::ModeSelect => "Cap Mode Selection", + Self::Onboarding => "Welcome to Cap", + Self::Camera => "Cap Camera", + Self::RecordingsOverlay => "Cap Recordings Overlay", + Self::TargetSelectOverlay { .. } => "Cap Target Select", + Self::Teleprompter => "Cap Teleprompter", + _ => "Cap", + } + .to_string() + } + + pub fn get(&self, app: &AppHandle) -> Option { + if matches!(self, Self::Camera) { + return app + .webview_windows() + .into_iter() + .filter(|(label, _)| is_camera_window_label(label)) + .max_by_key(|(label, _)| label.clone()) + .map(|(_, window)| window); + } + app.get_webview_window(&self.label()) + } + + fn min_size(&self) -> Option<(f64, f64)> { + Some(match self { + Self::Main => (330.0, 395.0), + Self::Editor { .. } => (1275.0, 800.0), + Self::ScreenshotEditor { .. } => (800.0, 600.0), + Self::Settings => (780.0, 560.0), + Self::Camera => (200.0, 200.0), + Self::Upgrade => (950.0, 850.0), + Self::ModeSelect => (580.0, 340.0), + Self::Onboarding => (860.0, 690.0), + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Type, Deserialize)] +pub enum ShowCapWindow { + Main { + init_target_mode: Option, + }, + Settings { + page: Option, + }, + Editor { + project_path: PathBuf, + }, + RecordingsOverlay, + WindowCaptureOccluder { + screen_id: DisplayId, + }, + TargetSelectOverlay { + display_id: DisplayId, + target_mode: Option, + }, + CaptureArea { + screen_id: DisplayId, + }, + Camera { + centered: bool, + }, + InProgressRecording { + countdown: Option, + #[serde(default)] + capture_target: Option, + }, + Upgrade, + ModeSelect, + ScreenshotEditor { + path: PathBuf, + }, + Onboarding, +} + +impl ShowCapWindow { + pub async fn show(&self, app: &AppHandle) -> cap_desktop_runtime::Result { + self.prepare(app).await; + let id = self.id(app); + if let Some(window) = id.get(app) { + window.show()?; + window.set_focus()?; + return Ok(window); + } + + let (route, width, height, always_on_top, skip_taskbar, focus, visible) = match self { + Self::Main { init_target_mode } => ( + format!("/{}", target_mode_query(init_target_mode.as_ref())), + 352.0, + 520.0, + false, + false, + true, + true, + ), + Self::Settings { page } => ( + format!("/settings/{}", page.as_deref().unwrap_or_default()), + 782.0, + 775.0, + false, + false, + true, + true, + ), + Self::Editor { .. } => ( + "/editor".to_string(), + 1275.0, + 800.0, + false, + false, + true, + false, + ), + Self::ScreenshotEditor { .. } => ( + "/screenshot-editor".to_string(), + 1100.0, + 760.0, + false, + false, + true, + false, + ), + Self::RecordingsOverlay => ( + "/recordings-overlay".to_string(), + 440.0, + 560.0, + true, + true, + false, + false, + ), + Self::WindowCaptureOccluder { .. } => ( + "/window-capture-occluder".to_string(), + 800.0, + 600.0, + true, + true, + false, + true, + ), + Self::TargetSelectOverlay { + display_id, + target_mode, + } => ( + format!( + "/target-select-overlay?displayId={display_id}{}", + target_mode_param(target_mode.as_ref()) + ), + 800.0, + 600.0, + true, + true, + false, + true, + ), + Self::CaptureArea { screen_id } => ( + format!("/capture-area?screenId={screen_id}"), + 800.0, + 600.0, + true, + true, + true, + true, + ), + Self::Camera { .. } => ("/camera".to_string(), 320.0, 320.0, true, true, false, true), + Self::InProgressRecording { countdown, .. } => ( + format!( + "/in-progress-recording?countdown={}", + countdown.unwrap_or_default() + ), + 360.0, + 120.0, + true, + true, + false, + true, + ), + Self::Upgrade => ( + "/upgrade".to_string(), + 950.0, + 850.0, + false, + false, + true, + true, + ), + Self::ModeSelect => ( + "/mode-select".to_string(), + 580.0, + 340.0, + false, + false, + true, + true, + ), + Self::Onboarding => ( + "/onboarding".to_string(), + 860.0, + 690.0, + false, + false, + true, + true, + ), + }; + let (min_width, min_height) = id + .min_size() + .map_or((None, None), |(width, height)| (Some(width), Some(height))); + let initialization = self.initialization(app).await?; + let label = id.label(); + app.send(cap_desktop_runtime::BackendMessage::CreateWindow { + options: WindowOptions { + label: label.clone(), + route, + title: id.title(), + x: None, + y: None, + width, + height, + min_width, + min_height, + transparent: matches!( + id, + CapWindowId::Main + | CapWindowId::Onboarding + | CapWindowId::Camera + | CapWindowId::CaptureArea + | CapWindowId::TargetSelectOverlay { .. } + | CapWindowId::RecordingControls + | CapWindowId::RecordingsOverlay + | CapWindowId::WindowCaptureOccluder { .. } + ), + decorations: false, + resizable: !matches!( + id, + CapWindowId::TargetSelectOverlay { .. } + | CapWindowId::RecordingControls + | CapWindowId::RecordingsOverlay + ), + always_on_top, + visible_on_all_workspaces: always_on_top, + skip_taskbar, + content_protected: true, + focus, + visible, + initialization, + }, + })?; + app.update_window_state(label.clone(), WindowState::default()); + Ok(Window::new(app.clone(), label)) + } + + async fn initialization( + &self, + app: &AppHandle, + ) -> cap_desktop_runtime::Result { + match self { + Self::Main { init_target_mode } => Ok(serde_json::json!({ + "initialTargetMode": init_target_mode, + })), + Self::TargetSelectOverlay { .. } => Ok(serde_json::json!({ + "cameraWsPort": camera_ws_port(app).await?, + })), + Self::Camera { centered } => Ok(serde_json::json!({ + "cameraWsPort": camera_ws_port(app).await?, + "cameraOnlyMode": centered, + "enableNativeCameraPreview": + crate::general_settings::GeneralSettingsStore::native_camera_preview_enabled(app), + })), + Self::InProgressRecording { countdown, .. } => Ok(serde_json::json!({ + "countdown": countdown.unwrap_or_default(), + })), + _ => Ok(serde_json::json!({})), + } + } + + async fn prepare(&self, app: &AppHandle) { + match self { + Self::Editor { project_path } => { + let state = app.state::(); + let id = state.id_for(project_path); + PendingEditorInstances::start_prewarm( + app, + CapWindowId::Editor { id }.label(), + project_path.clone(), + ) + .await; + } + Self::ScreenshotEditor { path } => { + let state = app.state::(); + let id = state.id_for(path); + PendingScreenshotEditorInstances::start_prewarm( + app, + CapWindowId::ScreenshotEditor { id }.label(), + path.clone(), + ) + .await; + } + _ => {} + } + } + + pub fn id(&self, app: &AppHandle) -> CapWindowId { + match self { + Self::Main { .. } => CapWindowId::Main, + Self::Settings { .. } => CapWindowId::Settings, + Self::Editor { project_path } => CapWindowId::Editor { + id: app.state::().id_for(project_path), + }, + Self::RecordingsOverlay => CapWindowId::RecordingsOverlay, + Self::WindowCaptureOccluder { screen_id } => CapWindowId::WindowCaptureOccluder { + screen_id: screen_id.clone(), + }, + Self::TargetSelectOverlay { display_id, .. } => CapWindowId::TargetSelectOverlay { + display_id: display_id.clone(), + }, + Self::CaptureArea { .. } => CapWindowId::CaptureArea, + Self::Camera { .. } => CapWindowId::Camera, + Self::InProgressRecording { .. } => CapWindowId::RecordingControls, + Self::Upgrade => CapWindowId::Upgrade, + Self::ModeSelect => CapWindowId::ModeSelect, + Self::ScreenshotEditor { path } => CapWindowId::ScreenshotEditor { + id: app.state::().id_for(path), + }, + Self::Onboarding => CapWindowId::Onboarding, + } + } +} + +async fn camera_ws_port(app: &AppHandle) -> cap_desktop_runtime::Result { + let state = app + .try_state::>() + .ok_or_else(|| "App state unavailable while creating camera window".to_string())?; + #[allow(deprecated)] + let port = state.read().await.camera_ws_port; + Ok(port) +} + +fn target_mode_query(mode: Option<&RecordingTargetMode>) -> String { + mode.map(|mode| format!("?targetMode={}", mode_name(mode))) + .unwrap_or_default() +} + +fn target_mode_param(mode: Option<&RecordingTargetMode>) -> String { + mode.map(|mode| format!("&targetMode={}", mode_name(mode))) + .unwrap_or_default() +} + +fn mode_name(mode: &RecordingTargetMode) -> &'static str { + match mode { + RecordingTargetMode::Display => "display", + RecordingTargetMode::Window => "window", + RecordingTargetMode::Area => "area", + RecordingTargetMode::Camera => "camera", + } +} + +pub fn update_window_rasterization_scale(_window: &WebviewWindow, _scale_factor: f64) {} + +#[cap_desktop_runtime::command] +pub fn set_theme(window: Window, theme: crate::general_settings::AppTheme) { + let theme = match theme { + crate::general_settings::AppTheme::System => "system", + crate::general_settings::AppTheme::Light => "light", + crate::general_settings::AppTheme::Dark => "dark", + }; + let _ = window.operation(DesktopOperation::SetTheme { + theme: theme.to_string(), + }); +} + +#[cap_desktop_runtime::command] +pub fn position_traffic_lights(window: Window, controls_inset: Option<(f64, f64)>) { + let (x, y) = controls_inset + .map(|(x, y)| (Some(x), Some(y))) + .unwrap_or((None, None)); + let _ = window.operation(DesktopOperation::SetTrafficLightPosition { x, y }); +} + +#[cap_desktop_runtime::command] +pub fn set_teleprompter_window_level(window: Window, always_on_top: bool) { + let _ = window.set_always_on_top(always_on_top); +} + +#[cap_desktop_runtime::command] +pub fn set_teleprompter_window_opacity(window: Window, opacity: f64) { + let _ = window.set_opacity(opacity); +} + +#[cfg(target_os = "windows")] +pub fn capture_exclusion_hides_ui() -> bool { + true +} + +#[cfg(not(target_os = "windows"))] +pub fn capture_exclusion_hides_ui() -> bool { + false +} + +pub fn apply_content_protection(app: &AppHandle, enabled: bool) { + for window in app.webview_windows().into_values() { + let _ = window.set_content_protected(enabled); + } +} + +#[cap_desktop_runtime::command] +pub fn refresh_window_content_protection(app: AppHandle) -> Result<(), String> { + apply_content_protection(&app, true); + Ok(()) +} + +#[cap_desktop_runtime::command] +pub async fn apply_macos_liquid_glass_background( + _window: Window, + enabled: bool, + radius: f64, +) -> Result { + let _ = (enabled, radius); + Ok(false) +} + +#[cap_desktop_runtime::command] +pub fn set_window_transparent(_window: Window, value: bool) { + let _ = value; +} + +#[derive(Default)] +pub struct EditorWindowIds { + pub ids: Arc>>, + counter: AtomicU32, +} + +impl EditorWindowIds { + fn id_for(&self, path: &Path) -> u32 { + let mut ids = self.ids.lock().expect("editor window ids lock poisoned"); + if let Some((_, id)) = ids.iter().find(|(current, _)| current == path) { + return *id; + } + let id = self.counter.fetch_add(1, Ordering::SeqCst); + ids.push((path.to_path_buf(), id)); + id + } + + pub fn get(app: &AppHandle) -> cap_desktop_runtime::State<'_, Self> { + app.state() + } +} + +#[derive(Default)] +pub struct ScreenshotEditorWindowIds { + pub ids: Arc>>, + counter: AtomicU32, +} + +impl ScreenshotEditorWindowIds { + fn id_for(&self, path: &Path) -> u32 { + let mut ids = self + .ids + .lock() + .expect("screenshot editor window ids lock poisoned"); + if let Some((_, id)) = ids.iter().find(|(current, _)| current == path) { + return *id; + } + let id = self.counter.fetch_add(1, Ordering::SeqCst); + ids.push((path.to_path_buf(), id)); + id + } + + pub fn get(app: &AppHandle) -> cap_desktop_runtime::State<'_, Self> { + app.state() + } +} + +#[derive(Clone, Default)] +pub struct EditorRecordingTarget(pub Arc>>); + +impl EditorRecordingTarget { + pub fn get(app: &AppHandle) -> Self { + app.state::().deref().clone() + } + + pub fn set(app: &AppHandle, path: Option) { + *Self::get(app).0.lock().unwrap() = path; + } + + pub fn current(app: &AppHandle) -> Option { + Self::get(app).0.lock().unwrap().clone() + } + + pub fn take(app: &AppHandle) -> Option { + Self::get(app).0.lock().unwrap().take() + } +} + +impl Deref for EditorRecordingTarget { + type Target = Arc>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub fn editor_window_for_path(app: &AppHandle, path: &Path) -> Option { + let ids = EditorWindowIds::get(app); + let id = ids + .ids + .lock() + .ok()? + .iter() + .find(|(candidate, _)| candidate == path) + .map(|(_, id)| *id)?; + CapWindowId::Editor { id }.get(app) +} + +#[cfg(test)] +mod tests { + use super::CapWindowId; + use std::str::FromStr; + + #[test] + fn parses_dynamic_window_labels_without_fixed_byte_offsets() { + for label in [ + "editor-42", + "screenshot-editor-7", + "window-capture-occluder-1", + "target-select-overlay-1", + ] { + let parsed = CapWindowId::from_str(label).unwrap(); + assert_eq!(parsed.to_string(), label); + } + } +} diff --git a/apps/desktop/src-tauri/tests/exit_shutdown.rs b/apps/desktop/src-backend/tests/exit_shutdown.rs similarity index 100% rename from apps/desktop/src-tauri/tests/exit_shutdown.rs rename to apps/desktop/src-backend/tests/exit_shutdown.rs diff --git a/apps/desktop/src-tauri/tests/web_api_startup.rs b/apps/desktop/src-backend/tests/web_api_startup.rs similarity index 72% rename from apps/desktop/src-tauri/tests/web_api_startup.rs rename to apps/desktop/src-backend/tests/web_api_startup.rs index c9cf9f24809..646d212ecaf 100644 --- a/apps/desktop/src-tauri/tests/web_api_startup.rs +++ b/apps/desktop/src-backend/tests/web_api_startup.rs @@ -2,13 +2,18 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::Arc; use cap_desktop_lib::{App, web_api::ManagerExt}; -use tauri::{Manager, test::mock_app}; +use cap_desktop_runtime::{AppHandle, AppPaths}; use tokio::sync::RwLock; +fn mock_app() -> AppHandle { + let (outbound, _messages) = tokio::sync::mpsc::unbounded_channel(); + let paths = AppPaths::discover("so.cap.desktop.test", std::env::temp_dir()).unwrap(); + AppHandle::new(outbound, paths) +} + #[test] fn make_app_url_before_manage_does_not_panic_and_uses_default_server_url() { - let app = mock_app(); - let handle = app.handle().clone(); + let handle = mock_app(); let raw_state_access = catch_unwind(AssertUnwindSafe(|| { let _ = handle.state::>>(); @@ -26,18 +31,13 @@ fn make_app_url_before_manage_does_not_panic_and_uses_default_server_url() { "/api/upload/multipart/initiate" ) ); - - std::mem::forget(app); } #[test] fn is_server_url_custom_before_manage_is_safe_and_false() { - let app = mock_app(); - let handle = app.handle().clone(); + let handle = mock_app(); let is_custom = futures::executor::block_on(handle.is_server_url_custom()); assert!(!is_custom); - - std::mem::forget(app); } diff --git a/apps/desktop/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore deleted file mode 100644 index b21bd681d99..00000000000 --- a/apps/desktop/src-tauri/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -/target/ - -# Generated by Tauri -# will have schema files for capabilities auto-completion -/gen/schemas diff --git a/apps/desktop/src-tauri/Info.plist b/apps/desktop/src-tauri/Info.plist deleted file mode 100644 index dd740dcf952..00000000000 --- a/apps/desktop/src-tauri/Info.plist +++ /dev/null @@ -1,33 +0,0 @@ - - - - - CFBundleDocumentTypes - - - CFBundleTypeExtensions - - cap - - CFBundleTypeIconFile - icon.icns - CFBundleTypeName - Cap Recording - CFBundleTypeRole - Editor - LSHandlerRank - Default - LSTypeIsPackage - - - - NSCameraUsageDescription - Cap uses your camera to record you in your Caps - NSMicrophoneUsageDescription - Cap uses your microphone to record you in your Caps - NSScreenCaptureUsageDescription - Cap captures your screen as part of recording your Caps - NSCameraUseContinuityCameraDeviceType - - - diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs deleted file mode 100644 index 261851f6b60..00000000000 --- a/apps/desktop/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build(); -} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json deleted file mode 100644 index 745254f6eb4..00000000000 --- a/apps/desktop/src-tauri/capabilities/default.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": ["*"], - "permissions": [ - "fs:default", - "fs:allow-resource-read", - "fs:allow-resource-read-recursive", - "fs:write-all", - "fs:read-all", - "fs:allow-appdata-read", - "fs:allow-appdata-write", - "fs:allow-picture-read-recursive", - { - "identifier": "fs:scope", - "allow": [ - { "path": "$APPDATA/**" }, - { "path": "$HOME/**" }, - { "path": "$RESOURCE/**" } - ] - }, - "core:path:allow-resolve-directory", - "core:path:default", - "core:event:default", - "core:menu:default", - "core:resources:allow-close", - "core:window:default", - "core:window:allow-close", - "core:window:allow-destroy", - "core:window:allow-hide", - "core:window:allow-show", - "core:window:allow-center", - "core:window:allow-minimize", - "core:window:allow-unminimize", - "core:window:allow-maximize", - "core:window:allow-unmaximize", - "core:window:allow-set-size", - "core:window:allow-set-focus", - "core:window:allow-start-dragging", - "core:window:allow-set-position", - "core:window:allow-set-theme", - "core:window:allow-set-progress-bar", - "core:window:allow-set-effects", - "core:window:allow-set-ignore-cursor-events", - "core:window:allow-set-visible-on-all-workspaces", - "core:window:deny-set-visible-on-all-workspaces", - "core:window:allow-set-content-protected", - "core:window:allow-set-always-on-bottom", - "core:window:allow-set-always-on-top", - "core:webview:default", - "core:webview:allow-create-webview-window", - "core:app:allow-version", - "shell:default", - "core:image:default", - "dialog:default", - "store:default", - "process:default", - "oauth:allow-start", - "updater:default", - "notification:default", - "deep-link:default", - { - "identifier": "http:default", - "allow": [ - { "url": "http://*" }, - { "url": "https://*" }, - { "url": "http://localhost:*" } - ] - }, - "clipboard-manager:allow-write-text", - "opener:allow-reveal-item-in-dir" - ] -} diff --git a/apps/desktop/src-tauri/src/flags.rs b/apps/desktop/src-tauri/src/flags.rs deleted file mode 100644 index 0d763f50210..00000000000 --- a/apps/desktop/src-tauri/src/flags.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub mod plugin { - use cap_flags::FLAGS; - use tauri::{ - Runtime, - plugin::{Builder, TauriPlugin}, - }; - - pub fn init() -> TauriPlugin { - Builder::new("cap-flags") - .js_init_script(format!( - "window.FLAGS = {}", - serde_json::to_string_pretty(&FLAGS).unwrap() - )) - .build() - } -} diff --git a/apps/desktop/src-tauri/src/platform/macos/mod.rs b/apps/desktop/src-tauri/src/platform/macos/mod.rs deleted file mode 100644 index 0dc6904bcc3..00000000000 --- a/apps/desktop/src-tauri/src/platform/macos/mod.rs +++ /dev/null @@ -1,655 +0,0 @@ -// use std::ffi::c_void; - -// use cocoa::{ -// base::{id, nil}, -// foundation::NSString, -// }; -// use core_graphics::{ -// base::boolean_t, -// display::{CFDictionaryRef, CGRect}, -// }; -// use objc::{class, msg_send, sel, sel_impl}; - -pub mod delegates; -mod sc_shareable_content; - -pub use sc_shareable_content::*; - -fn constrain_position_to_visible_top( - position: tauri::PhysicalPosition, - monitor_top: i32, - scale_factor: f64, - visible_frame_top_inset: f64, - safe_area_top_inset: f64, -) -> Option> { - let top_inset = visible_frame_top_inset.max(safe_area_top_inset).max(0.0); - let minimum_y = monitor_top.saturating_add((top_inset * scale_factor).round() as i32); - - (position.y < minimum_y).then(|| tauri::PhysicalPosition::new(position.x, minimum_y)) -} - -pub fn constrain_main_window_to_visible_top( - window: &tauri::Window, - position: tauri::PhysicalPosition, -) -> Option> { - use objc2::{runtime::NSObjectProtocol, sel}; - use objc2_app_kit::NSWindow; - - let monitor = window.current_monitor().ok().flatten()?; - let ns_window = window.ns_window().ok()? as *const NSWindow; - let screen = unsafe { (*ns_window).screen() }?; - let frame = screen.frame(); - let visible_frame = screen.visibleFrame(); - let safe_area_top_inset = if screen.respondsToSelector(sel!(safeAreaInsets)) { - unsafe { screen.safeAreaInsets().top } - } else { - 0.0 - }; - // Tauri runtime 2.8 omits this macOS top offset from Monitor::work_area().position. - let visible_frame_top_inset = - frame.origin.y + frame.size.height - visible_frame.origin.y - visible_frame.size.height; - - constrain_position_to_visible_top( - position, - monitor.position().y, - monitor.scale_factor(), - visible_frame_top_inset, - safe_area_top_inset, - ) -} - -pub fn set_window_level(window: tauri::Window, level: objc2_app_kit::NSWindowLevel) { - let c_window = window.clone(); - _ = window.run_on_main_thread(move || unsafe { - let Ok(ns_win) = c_window.ns_window() else { - return; - }; - let ns_win = ns_win as *const objc2_app_kit::NSWindow; - (*ns_win).setLevel(level); - }); -} - -pub fn set_window_opacity(window: tauri::Window, opacity: f64) { - let opacity = opacity.clamp(0.45, 1.0); - let c_window = window.clone(); - _ = window.run_on_main_thread(move || unsafe { - use cocoa::base::id; - use objc::{msg_send, sel, sel_impl}; - - let Ok(ns_win) = c_window.ns_window() else { - return; - }; - let ns_win = ns_win as id; - let _: () = msg_send![ns_win, setAlphaValue: opacity]; - }); -} - -pub fn apply_squircle_corners(window: &tauri::WebviewWindow, radius: f64) { - use cocoa::base::{id, nil}; - use cocoa::foundation::NSString; - use objc::{msg_send, sel, sel_impl}; - - let Ok(ns_win) = window.ns_window() else { - return; - }; - - unsafe { - let ns_win = ns_win as id; - let content_view: id = msg_send![ns_win, contentView]; - - if content_view != nil { - let _: () = msg_send![content_view, setWantsLayer: true]; - - let layer: id = msg_send![content_view, layer]; - if layer != nil { - let _: () = msg_send![layer, setCornerRadius: radius]; - let _: () = msg_send![layer, setMasksToBounds: true]; - - let continuous = NSString::alloc(nil).init_str("continuous"); - let _: () = msg_send![layer, setCornerCurve: continuous]; - } - } - } -} - -const TAURI_VIBRANCY_VIEW_TAG: isize = 91376254; -const LIQUID_GLASS_IDENTIFIER: &str = "so.cap.liquid-glass-background"; -const NS_GLASS_EFFECT_VIEW_STYLE_REGULAR: isize = 0; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum LiquidGlassActivity { - SystemManaged, - AlwaysActive, -} - -pub fn apply_main_window_liquid_glass_background( - window: &tauri::Window, - enabled: bool, - radius: f64, -) -> Result { - apply_liquid_glass_background_inner(window, enabled, radius, LiquidGlassActivity::SystemManaged) -} - -unsafe fn remove_tagged_subview(container: cocoa::base::id, tag: isize) { - use objc::{msg_send, sel, sel_impl}; - - unsafe { - let responds_to_view_with_tag: bool = - msg_send![container, respondsToSelector: sel!(viewWithTag:)]; - if !responds_to_view_with_tag { - return; - } - - let view: cocoa::base::id = msg_send![container, viewWithTag: tag]; - if view != cocoa::base::nil { - let _: () = msg_send![view, removeFromSuperview]; - } - } -} - -unsafe fn remove_liquid_glass_subviews( - container: cocoa::base::id, - identifier: cocoa::base::id, - glass_class: *const objc::runtime::Class, -) { - use cocoa::base::{id, nil}; - use objc::{msg_send, sel, sel_impl}; - - unsafe { - let subviews: id = msg_send![container, subviews]; - if subviews == nil { - return; - } - - let count: usize = msg_send![subviews, count]; - for index in (0..count).rev() { - let view: id = msg_send![subviews, objectAtIndex: index]; - if view == nil { - continue; - } - - let is_glass_view: bool = msg_send![view, isKindOfClass: glass_class]; - if is_glass_view { - let _: () = msg_send![view, removeFromSuperview]; - // Balance the `alloc` ownership claim taken when this view was - // created in `apply_liquid_glass_background`. Without this, - // removing it from the hierarchy only drops the superview's - // retain, leaving it stuck at refcount 1 forever — a leaked - // GPU/IOSurface-backed NSGlassEffectView on every re-apply. - let _: () = msg_send![view, release]; - continue; - } - - let responds_to_identifier: bool = - msg_send![view, respondsToSelector: sel!(identifier)]; - if !responds_to_identifier { - continue; - } - - let view_identifier: id = msg_send![view, identifier]; - if view_identifier == nil { - continue; - } - - let matches_identifier: bool = msg_send![view_identifier, isEqualToString: identifier]; - if matches_identifier { - let _: () = msg_send![view, removeFromSuperview]; - } - } - } -} - -pub fn apply_liquid_glass_background( - window: &tauri::Window, - enabled: bool, - radius: f64, -) -> Result { - apply_liquid_glass_background_inner(window, enabled, radius, LiquidGlassActivity::AlwaysActive) -} - -fn apply_liquid_glass_background_inner( - window: &tauri::Window, - enabled: bool, - radius: f64, - activity: LiquidGlassActivity, -) -> Result { - use cocoa::{ - base::{id, nil}, - foundation::{NSRect, NSString}, - }; - use objc::{class, msg_send, runtime::Class, sel, sel_impl}; - - unsafe { - let ns_window = window - .ns_window() - .map_err(|error| format!("Failed to get native window handle: {error}"))? - as id; - let content_view: id = msg_send![ns_window, contentView]; - - if content_view == nil { - return Err("Window has no content view".into()); - } - - let Some(glass_class) = Class::get("NSGlassEffectView") else { - // No NSGlassEffectView (pre-macOS-26) — the caller uses vibrancy. - if enabled { - crate::crash_sentinel::set_liquid_glass_outcome("unsupported"); - } - return Ok(false); - }; - let glass_class = glass_class as *const Class; - let glass_identifier = NSString::alloc(nil).init_str(LIQUID_GLASS_IDENTIFIER); - - if !enabled { - teardown_liquid_glass_ns(ns_window); - return Ok(false); - } - - remove_liquid_glass_subviews(content_view, glass_identifier, glass_class); - - // Clip the content view itself to the same continuous (squircle) curve we apply - // to the NSGlassEffectView below. Without this, WebKit's CSS border-radius on - // `.cap-window-shell` and the window's Core Animation corner are rasterised by - // different engines and don't align pixel-perfectly, producing the visible inner - // "second border" inside the rounded corner. This is plain Core Animation (no - // private SPI / no WindowServer risk), so we apply it up front — it must clip - // BOTH the glass view and the vibrancy fallback, otherwise the artifact returns - // on the fallback path (most visibly on the larger-radius settings window). - let _: () = msg_send![content_view, setWantsLayer: true]; - let content_layer: id = msg_send![content_view, layer]; - if content_layer != nil { - let _: () = msg_send![content_layer, setCornerRadius: radius]; - let _: () = msg_send![content_layer, setMasksToBounds: true]; - let continuous = NSString::alloc(nil).init_str("continuous"); - let _: () = msg_send![content_layer, setCornerCurve: continuous]; - } - - let bounds: NSRect = msg_send![content_view, bounds]; - let glass_view: id = msg_send![glass_class, alloc]; - let glass_view: id = msg_send![glass_view, initWithFrame: bounds]; - - if glass_view == nil { - crate::crash_sentinel::set_liquid_glass_outcome("fallback"); - return Ok(false); - } - - let responds_to_set_identifier: bool = - msg_send![glass_view, respondsToSelector: sel!(setIdentifier:)]; - if responds_to_set_identifier { - let _: () = msg_send![glass_view, setIdentifier: glass_identifier]; - } - - let responds_to_set_corner_radius: bool = - msg_send![glass_view, respondsToSelector: sel!(setCornerRadius:)]; - if responds_to_set_corner_radius { - let _: () = msg_send![glass_view, setCornerRadius: radius]; - } - - if activity == LiquidGlassActivity::SystemManaged { - let _: () = msg_send![glass_view, setStyle: NS_GLASS_EFFECT_VIEW_STYLE_REGULAR]; - } - - // Pin the glass to its "always-active" representation so it keeps re-rendering - // the live backdrop regardless of which window currently has key state. The - // default for an NSVisualEffectView-derived view is FollowsWindowActiveState, - // which dims the material whenever another Cap window (camera, settings, etc.) - // becomes key and masks the backdrop reactivity that's the whole point of - // Liquid Glass. The always-active state is private SPI, so we probe multiple - // state knobs (`setState:`, `setActive:`) instead of assuming one inheritance - // path. The main window does not enter this branch and remains system-managed. - // - // macOS 26.3 shipped an NSGlassEffectView that responds to neither selector, - // so the pin silently fails. In that state the material can't be relied on - // (it dims whenever another window becomes key), so abandon the private SPI - // entirely and let the caller fall back to NSVisualEffectView vibrancy - // (Ok(false)). - if activity == LiquidGlassActivity::AlwaysActive - && !force_glass_view_always_active(glass_view) - { - // Never entered the view hierarchy; balance the alloc and bail. The - // content-layer squircle clip applied above is kept (plain Core Animation, - // not a WindowServer/occlusion mutation) so the vibrancy fallback still gets - // the aligned rounded corner. - let _: () = msg_send![glass_view, release]; - crate::crash_sentinel::set_liquid_glass_outcome("fallback"); - return Ok(false); - } - - remove_tagged_subview(content_view, TAURI_VIBRANCY_VIEW_TAG); - - let clear_color: id = msg_send![class!(NSColor), clearColor]; - let _: () = msg_send![ns_window, setOpaque: false]; - let _: () = msg_send![ns_window, setBackgroundColor: clear_color]; - - // Deliberately DO NOT disable window/WKWebView occlusion detection here. - // Earlier builds called the private `_setWindowOcclusionDetectionEnabled:` / - // `_setWebViewWindowOcclusionDetectionEnabled:` SPI so the glass backdrop kept - // sampling while Cap was unfocused, but an occlusion-suppressed surface is one - // WindowServer can never quiesce — on macOS 26 that wedged the compositor when - // the window was later hidden or the app quit (often after sleep/lid-close), - // watchdog-stalling WindowServer and soft-restarting the whole login session. - // Gating it on the always-active pin was not enough: the main window's - // close-to-tray path hides the window without any teardown, so any future - // macOS build where the pin succeeds would silently re-arm the wedge. The - // only field-verified-safe configuration is to leave occlusion detection - // alone entirely; the trade-off is merely cosmetic (backdrop sampling may - // pause on the last frame while the app is deactivated). - - let _: () = msg_send![glass_view, setAutoresizingMask: 18usize]; - let _: () = msg_send![ - content_view, - addSubview: glass_view - positioned: -1isize - relativeTo: nil - ]; - - if activity == LiquidGlassActivity::AlwaysActive { - // Re-apply after the view enters the hierarchy: some private AppKit views - // reset state machine fields on `viewDidMoveToWindow:`, so the post-add pass - // is what actually sticks. - force_glass_view_always_active(glass_view); - } - - crate::crash_sentinel::set_liquid_glass_outcome("applied"); - Ok(true) - } -} - -/// Pin the glass material to its always-active representation. Returns whether the -/// view actually responded to a known pinning selector. A `false` return means this -/// macOS build's NSGlassEffectView SPI differs from what we understand (notably 26.3, -/// where it responds to neither `setState:` nor `setActive:`) and the material must -/// NOT be relied upon — see `apply_liquid_glass_background`. -unsafe fn force_glass_view_always_active(glass_view: cocoa::base::id) -> bool { - use cocoa::base::id; - use objc::{msg_send, sel, sel_impl}; - - unsafe { - let responds_to_set_state: bool = - msg_send![glass_view, respondsToSelector: sel!(setState:)]; - if responds_to_set_state { - // NSVisualEffectStateActive == 1 - let _: () = msg_send![glass_view, setState: 1isize]; - tracing::info!(target: "cap_desktop_lib::liquid_glass", "NSGlassEffectView responds to setState:"); - } - - let responds_to_set_active: bool = - msg_send![glass_view, respondsToSelector: sel!(setActive:)]; - if responds_to_set_active { - let _: () = msg_send![glass_view, setActive: true]; - tracing::info!(target: "cap_desktop_lib::liquid_glass", "NSGlassEffectView responds to setActive:"); - } - - if !responds_to_set_state && !responds_to_set_active { - tracing::debug!( - target: "cap_desktop_lib::liquid_glass", - "NSGlassEffectView responds to neither setState: nor setActive: — \ - cannot pin material to always-active; falling back to vibrancy" - ); - return false; - } - - // If the glass view internally wraps an NSVisualEffectView (rather than - // subclassing it), the state property on the outer view won't propagate. - // Walk one level of subviews and pin any visual-effect-like state we find. - let subviews: id = msg_send![glass_view, subviews]; - if subviews != cocoa::base::nil { - let count: usize = msg_send![subviews, count]; - for index in 0..count { - let subview: id = msg_send![subviews, objectAtIndex: index]; - if subview == cocoa::base::nil { - continue; - } - - let subview_responds_to_set_state: bool = - msg_send![subview, respondsToSelector: sel!(setState:)]; - if subview_responds_to_set_state { - let _: () = msg_send![subview, setState: 1isize]; - } - } - } - - true - } -} - -unsafe fn enable_window_occlusion_detection(ns_window: cocoa::base::id) { - use objc::{msg_send, sel, sel_impl}; - - // Defense-in-depth heal: current builds never disable occlusion detection, but - // re-assert the OS default via whichever private SPI variant this AppKit build - // responds to, so the OS can always quiesce the surface. - unsafe { - let responds: bool = msg_send![ - ns_window, - respondsToSelector: sel!(_setWindowOcclusionDetectionEnabled:) - ]; - if responds { - let _: () = msg_send![ns_window, _setWindowOcclusionDetectionEnabled: true]; - return; - } - - let responds: bool = msg_send![ - ns_window, - respondsToSelector: sel!(setWindowOcclusionDetectionEnabled:) - ]; - if responds { - let _: () = msg_send![ns_window, setWindowOcclusionDetectionEnabled: true]; - return; - } - - let responds: bool = msg_send![ - ns_window, - respondsToSelector: sel!(_setOcclusionDetectionEnabled:) - ]; - if responds { - let _: () = msg_send![ns_window, _setOcclusionDetectionEnabled: true]; - return; - } - - let responds: bool = msg_send![ - ns_window, - respondsToSelector: sel!(setOcclusionDetectionEnabled:) - ]; - if responds { - let _: () = msg_send![ns_window, setOcclusionDetectionEnabled: true]; - } - } -} - -unsafe fn enable_webview_occlusion_detection(content_view: cocoa::base::id) { - use cocoa::base::id; - use objc::{msg_send, runtime::Class, sel, sel_impl}; - - // Re-enumerate the live WKWebView subview (its identity/index can change between - // apply and teardown) and restore occlusion detection, mirroring the disable path. - unsafe { - let Some(wkwebview_class) = Class::get("WKWebView") else { - return; - }; - let wkwebview_class = wkwebview_class as *const Class; - - let subviews: id = msg_send![content_view, subviews]; - if subviews == cocoa::base::nil { - return; - } - - let count: usize = msg_send![subviews, count]; - for index in 0..count { - let subview: id = msg_send![subviews, objectAtIndex: index]; - if subview == cocoa::base::nil { - continue; - } - - let is_webview: bool = msg_send![subview, isKindOfClass: wkwebview_class]; - if !is_webview { - continue; - } - - let responds: bool = msg_send![ - subview, - respondsToSelector: sel!(_setWebViewWindowOcclusionDetectionEnabled:) - ]; - if responds { - let _: () = msg_send![subview, _setWebViewWindowOcclusionDetectionEnabled: true]; - return; - } - - let responds: bool = msg_send![ - subview, - respondsToSelector: sel!(_setWindowOcclusionDetectionEnabled:) - ]; - if responds { - let _: () = msg_send![subview, _setWindowOcclusionDetectionEnabled: true]; - return; - } - - return; - } - } -} - -/// Reverse every WindowServer-visible mutation `apply_liquid_glass_background` makes: -/// remove the NSGlassEffectView, then restore window/WKWebView occlusion detection and -/// window opacity. MUST run on the AppKit main thread. -unsafe fn teardown_liquid_glass_ns(ns_window: cocoa::base::id) { - use cocoa::{ - base::{id, nil}, - foundation::NSString, - }; - use objc::{msg_send, runtime::Class, sel, sel_impl}; - - unsafe { - let content_view: id = msg_send![ns_window, contentView]; - if content_view == nil { - return; - } - - if let Some(glass_class) = Class::get("NSGlassEffectView") { - let glass_identifier = NSString::alloc(nil).init_str(LIQUID_GLASS_IDENTIFIER); - remove_liquid_glass_subviews( - content_view, - glass_identifier, - glass_class as *const Class, - ); - } - - enable_window_occlusion_detection(ns_window); - enable_webview_occlusion_detection(content_view); - let _: () = msg_send![ns_window, setOpaque: true]; - } -} - -/// Tear down liquid glass on every webview window. MUST be called on the AppKit main -/// thread (e.g. from the `RunEvent::Exit` handler, which runs on main). Returns the -/// number of windows processed. -pub fn teardown_all_liquid_glass_on_main(app: &tauri::AppHandle) -> usize { - use tauri::Manager; - - let mut count = 0usize; - for (_label, window) in app.webview_windows() { - if let Ok(ns_window) = window.ns_window() { - unsafe { - teardown_liquid_glass_ns(ns_window as cocoa::base::id); - } - count += 1; - } - } - count -} - -/// Dispatch liquid-glass teardown onto the main thread and await it. Safe to call -/// from a tokio worker during exit: the AppKit run loop is still pumping because the -/// macOS terminate handler returned NSTerminateCancel before spawning the async exit. -pub async fn teardown_all_liquid_glass(app: &tauri::AppHandle) -> Result<(), String> { - let app_for_closure = app.clone(); - let (tx, rx) = tokio::sync::oneshot::channel(); - - app.run_on_main_thread(move || { - let count = teardown_all_liquid_glass_on_main(&app_for_closure); - let _ = tx.send(count); - }) - .map_err(|error| error.to_string())?; - - let count = rx - .await - .map_err(|_| "liquid glass teardown task was cancelled".to_string())?; - tracing::info!( - target: "cap_desktop_lib::liquid_glass", - windows = count, - "Tore down liquid glass before exit" - ); - Ok(()) -} - -// pub fn get_ns_window_number(ns_window: *mut c_void) -> isize { -// let ns_window = ns_window as *const objc2_app_kit::NSWindow; - -// unsafe { (*ns_window).windowNumber() } -// } - -// #[link(name = "CoreGraphics", kind = "framework")] -// unsafe extern "C" { -// pub fn CGRectMakeWithDictionaryRepresentation( -// dict: CFDictionaryRef, -// rect: *mut CGRect, -// ) -> boolean_t; -// } - -// /// Makes the background of the WKWebView layer transparent. -// /// This differs from Tauri's implementation as it does not change the window background which causes performance performance issues and artifacts when shadows are enabled on the window. -// /// Use Tauri's implementation to make the window itself transparent. -// pub fn make_webview_transparent(target: &tauri::WebviewWindow) -> tauri::Result<()> { -// target.with_webview(|webview| unsafe { -// let wkwebview = webview.inner() as id; -// let no: id = msg_send![class!(NSNumber), numberWithBool:0]; -// // [https://developer.apple.com/documentation/webkit/webview/1408486-drawsbackground] -// let _: id = msg_send![wkwebview, setValue:no forKey: NSString::alloc(nil).init_str("drawsBackground")]; -// }) -// } - -#[cfg(test)] -mod tests { - use super::constrain_position_to_visible_top; - use tauri::PhysicalPosition; - - #[test] - fn moves_a_window_below_a_notched_visible_frame() { - let position = PhysicalPosition::new(640, 0); - - assert_eq!( - constrain_position_to_visible_top(position, 0, 2.0, 19.0, 18.0), - Some(PhysicalPosition::new(640, 38)) - ); - } - - #[test] - fn uses_the_safe_area_when_the_menu_bar_is_hidden() { - let position = PhysicalPosition::new(200, 0); - - assert_eq!( - constrain_position_to_visible_top(position, 0, 2.0, 0.0, 37.0), - Some(PhysicalPosition::new(200, 74)) - ); - } - - #[test] - fn preserves_negative_coordinates_on_a_monitor_above_the_primary() { - let position = PhysicalPosition::new(-600, -1800); - - assert_eq!( - constrain_position_to_visible_top(position, -1800, 1.5, 25.0, 0.0), - Some(PhysicalPosition::new(-600, -1762)) - ); - } - - #[test] - fn leaves_an_accessible_position_unchanged() { - let position = PhysicalPosition::new(300, 100); - - assert_eq!( - constrain_position_to_visible_top(position, 0, 2.0, 19.0, 18.0), - None - ); - } -} diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs deleted file mode 100644 index 4d0d8f7e3e7..00000000000 --- a/apps/desktop/src-tauri/src/tray.rs +++ /dev/null @@ -1,1125 +0,0 @@ -use crate::{ - NewScreenshotAdded, NewStudioRecordingAdded, RecordingStarted, RecordingStopped, - RequestOpenSettings, recording, - recording_settings::{RecordingSettingsStore, RecordingTargetMode}, - windows::ShowCapWindow, -}; -use cap_recording::RecordingMode; - -use cap_project::{RecordingMeta, RecordingMetaInner}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::{ - path::PathBuf, - sync::{Arc, Mutex}, -}; -use tauri::Manager; -use tauri::menu::{IconMenuItem, MenuId, PredefinedMenuItem, Submenu}; -use tauri::{ - AppHandle, - image::Image, - menu::{Menu, MenuItem}, - tray::{TrayIcon, TrayIconBuilder}, -}; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_opener::OpenerExt; -use tauri_specta::Event; - -const PREVIOUS_ITEM_PREFIX: &str = "previous_item_"; -const MAX_PREVIOUS_ITEMS: usize = 6; -const MAX_TITLE_LENGTH: usize = 30; -const THUMBNAIL_SIZE: u32 = 32; - -#[cfg(target_os = "linux")] -#[derive(Clone, Copy)] -enum LinuxTrayIcon { - Default, - Instant, - Screenshot, - Studio, - Stop, -} - -#[cfg(target_os = "linux")] -impl LinuxTrayIcon { - fn name(self) -> &'static str { - match self { - Self::Default => "so.cap.desktop-tray-default-symbolic", - Self::Instant => "so.cap.desktop-tray-instant-symbolic", - Self::Screenshot => "so.cap.desktop-tray-screenshot-symbolic", - Self::Studio => "so.cap.desktop-tray-studio-symbolic", - Self::Stop => "so.cap.desktop-tray-stop-symbolic", - } - } - - fn svg(self) -> &'static str { - match self { - Self::Default => { - include_str!("../icons/linux/so.cap.desktop-tray-default-symbolic.svg") - } - Self::Instant => { - include_str!("../icons/linux/so.cap.desktop-tray-instant-symbolic.svg") - } - Self::Screenshot => { - include_str!("../icons/linux/so.cap.desktop-tray-screenshot-symbolic.svg") - } - Self::Studio => include_str!("../icons/linux/so.cap.desktop-tray-studio-symbolic.svg"), - Self::Stop => include_str!("../icons/linux/so.cap.desktop-tray-stop-symbolic.svg"), - } - } -} - -#[derive(Debug)] -pub enum TrayItem { - OpenCap, - RecordDisplay, - RecordWindow, - RecordArea, - TakeScreenshot, - ImportVideo, - ViewAllRecordings, - ViewAllScreenshots, - OpenSettings, - UploadLogs, - Quit, - PreviousItem(String), - ModeStudio, - ModeInstant, - ModeScreenshot, - RequestPermissions, -} - -impl From for MenuId { - fn from(value: TrayItem) -> Self { - match value { - TrayItem::OpenCap => "open_cap", - TrayItem::RecordDisplay => "record_display", - TrayItem::RecordWindow => "record_window", - TrayItem::RecordArea => "record_area", - TrayItem::TakeScreenshot => "take_screenshot", - TrayItem::ImportVideo => "import_video", - TrayItem::ViewAllRecordings => "view_all_recordings", - TrayItem::ViewAllScreenshots => "view_all_screenshots", - TrayItem::OpenSettings => "open_settings", - TrayItem::UploadLogs => "upload_logs", - TrayItem::Quit => "quit", - TrayItem::PreviousItem(id) => { - return format!("{PREVIOUS_ITEM_PREFIX}{id}").into(); - } - TrayItem::ModeStudio => "mode_studio", - TrayItem::ModeInstant => "mode_instant", - TrayItem::ModeScreenshot => "mode_screenshot", - TrayItem::RequestPermissions => "request_permissions", - } - .into() - } -} - -impl TryFrom for TrayItem { - type Error = String; - - fn try_from(value: MenuId) -> Result { - let id_str = value.0.as_str(); - - if let Some(path) = id_str.strip_prefix(PREVIOUS_ITEM_PREFIX) { - return Ok(TrayItem::PreviousItem(path.to_string())); - } - - match id_str { - "open_cap" => Ok(TrayItem::OpenCap), - "record_display" => Ok(TrayItem::RecordDisplay), - "record_window" => Ok(TrayItem::RecordWindow), - "record_area" => Ok(TrayItem::RecordArea), - "take_screenshot" => Ok(TrayItem::TakeScreenshot), - "import_video" => Ok(TrayItem::ImportVideo), - "view_all_recordings" => Ok(TrayItem::ViewAllRecordings), - "view_all_screenshots" => Ok(TrayItem::ViewAllScreenshots), - "open_settings" => Ok(TrayItem::OpenSettings), - "upload_logs" => Ok(TrayItem::UploadLogs), - "quit" => Ok(TrayItem::Quit), - "mode_studio" => Ok(TrayItem::ModeStudio), - "mode_instant" => Ok(TrayItem::ModeInstant), - "mode_screenshot" => Ok(TrayItem::ModeScreenshot), - "request_permissions" => Ok(TrayItem::RequestPermissions), - value => Err(format!("Invalid tray item id {value}")), - } - } -} - -#[derive(Debug, Clone)] -enum PreviousItemType { - StudioRecording, - InstantRecording { - #[allow(dead_code)] - link: Option, - }, - Screenshot, -} - -#[derive(Clone)] -struct CachedPreviousItem { - path: PathBuf, - pretty_name: String, - thumbnail: Option>, - thumbnail_width: u32, - thumbnail_height: u32, - item_type: PreviousItemType, - created_at: std::time::SystemTime, -} - -#[derive(Default)] -struct PreviousItemsCache { - items: Vec, -} - -fn screenshots_path(app: &AppHandle) -> PathBuf { - let path = app.path().app_data_dir().unwrap().join("screenshots"); - std::fs::create_dir_all(&path).unwrap_or_default(); - path -} - -fn truncate_title(title: &str) -> String { - if title.chars().count() <= MAX_TITLE_LENGTH { - title.to_string() - } else { - let truncate_at = MAX_TITLE_LENGTH - 1; - let byte_index = title - .char_indices() - .nth(truncate_at) - .map(|(i, _)| i) - .unwrap_or(title.len()); - format!("{}…", &title[..byte_index]) - } -} - -fn load_thumbnail_data(path: &PathBuf) -> Option<(Vec, u32, u32)> { - use image::imageops::FilterType; - use image::{GenericImageView, RgbaImage}; - - let image_data = std::fs::read(path).ok()?; - let img = image::load_from_memory(&image_data).ok()?; - - let (orig_w, orig_h) = img.dimensions(); - let size = THUMBNAIL_SIZE; - - let scale = (size as f32 / orig_w as f32).max(size as f32 / orig_h as f32); - let scaled_w = (orig_w as f32 * scale).round() as u32; - let scaled_h = (orig_h as f32 * scale).round() as u32; - - let scaled = img.resize_exact(scaled_w, scaled_h, FilterType::Triangle); - - let x_offset = (scaled_w.saturating_sub(size)) / 2; - let y_offset = (scaled_h.saturating_sub(size)) / 2; - - let mut result = RgbaImage::new(size, size); - for y in 0..size { - for x in 0..size { - let src_x = x + x_offset; - let src_y = y + y_offset; - if src_x < scaled_w && src_y < scaled_h { - result.put_pixel(x, y, scaled.get_pixel(src_x, src_y)); - } - } - } - - Some((result.into_raw(), size, size)) -} - -fn load_single_item( - path: &PathBuf, - screenshots_dir: &PathBuf, - load_thumbnail: bool, -) -> Option { - if !path.is_dir() { - return None; - } - - let meta = RecordingMeta::load_for_project(path).ok()?; - let created_at = path - .metadata() - .and_then(|m| m.created()) - .unwrap_or_else(|_| std::time::SystemTime::now()); - - let is_screenshot = path.extension().and_then(|s| s.to_str()) == Some("cap") - && path.parent().map(|p| p == screenshots_dir).unwrap_or(false); - - let (thumbnail_path, item_type) = if is_screenshot { - let png_path = std::fs::read_dir(path).ok().and_then(|dir| { - dir.flatten() - .find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("png")) - .map(|e| e.path()) - }); - (png_path, PreviousItemType::Screenshot) - } else { - let thumb = path.join("screenshots/display.jpg"); - let thumb_path = if thumb.exists() { Some(thumb) } else { None }; - let item_type = match &meta.inner { - RecordingMetaInner::Studio(_) => PreviousItemType::StudioRecording, - RecordingMetaInner::Instant(_) => PreviousItemType::InstantRecording { - link: meta.sharing.as_ref().map(|s| s.link.clone()), - }, - }; - (thumb_path, item_type) - }; - - let (thumbnail, thumbnail_width, thumbnail_height) = if load_thumbnail { - thumbnail_path - .as_ref() - .and_then(load_thumbnail_data) - .map(|(data, w, h)| (Some(data), w, h)) - .unwrap_or((None, 0, 0)) - } else { - (None, 0, 0) - }; - - Some(CachedPreviousItem { - path: path.clone(), - pretty_name: meta.pretty_name, - thumbnail, - thumbnail_width, - thumbnail_height, - item_type, - created_at, - }) -} - -fn load_all_previous_items(app: &AppHandle, load_thumbnails: bool) -> Vec { - let mut items = Vec::new(); - let screenshots_dir = screenshots_path(app); - - for recordings_dir in crate::recordings_locations::known_recordings_dirs(app) { - let Ok(entries) = std::fs::read_dir(&recordings_dir) else { - continue; - }; - for entry in entries.flatten() { - if let Some(item) = load_single_item(&entry.path(), &screenshots_dir, load_thumbnails) { - items.push(item); - } - } - } - - if screenshots_dir.exists() - && let Ok(entries) = std::fs::read_dir(&screenshots_dir) - { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("cap") - && let Some(item) = load_single_item(&path, &screenshots_dir, load_thumbnails) - { - items.push(item); - } - } - } - - items.sort_by_key(|b| std::cmp::Reverse(b.created_at)); - items.truncate(MAX_PREVIOUS_ITEMS); - items -} - -fn create_previous_submenu( - app: &AppHandle, - cache: &PreviousItemsCache, -) -> tauri::Result> { - if cache.items.is_empty() { - let submenu = Submenu::with_id(app, "previous", "Previous", false)?; - submenu.append(&MenuItem::with_id( - app, - "previous_empty", - "No recent items", - false, - None::<&str>, - )?)?; - return Ok(submenu); - } - - let submenu = Submenu::with_id(app, "previous", "Previous", true)?; - - for item in &cache.items { - let id = TrayItem::PreviousItem(item.path.to_string_lossy().to_string()); - let title = truncate_title(&item.pretty_name); - - let type_indicator = match &item.item_type { - PreviousItemType::StudioRecording => "🎬 ", - PreviousItemType::InstantRecording { .. } => "⚡ ", - PreviousItemType::Screenshot => "📷 ", - }; - let display_title = format!("{type_indicator}{title}"); - - let icon = item.thumbnail.as_ref().map(|data| { - Image::new_owned(data.clone(), item.thumbnail_width, item.thumbnail_height) - }); - - let menu_item = IconMenuItem::with_id(app, id, display_title, true, icon, None::<&str>)?; - submenu.append(&menu_item)?; - } - - Ok(submenu) -} - -fn get_current_mode(app: &AppHandle) -> RecordingMode { - RecordingSettingsStore::get(app) - .ok() - .flatten() - .and_then(|s| s.mode) - .unwrap_or_default() -} - -fn should_use_minimal_onboarding_tray_menu(app: &AppHandle) -> bool { - if !app.webview_windows().contains_key("onboarding") { - return false; - } - !crate::permissions::do_permissions_check(false).necessary_granted() -} - -pub(crate) struct TrayMenuCache { - cache: Arc>, -} - -pub(crate) fn refresh_tray_menu_for_app(app: &AppHandle) { - let Some(state) = app.try_state::() else { - return; - }; - refresh_tray_menu(app, &state.cache); -} - -fn create_mode_submenu(app: &AppHandle) -> tauri::Result> { - let current_mode = get_current_mode(app); - - let submenu = Submenu::with_id(app, "select_mode", "Select Mode", true)?; - - let modes = [ - (TrayItem::ModeStudio, RecordingMode::Studio, "Studio"), - (TrayItem::ModeInstant, RecordingMode::Instant, "Instant"), - ( - TrayItem::ModeScreenshot, - RecordingMode::Screenshot, - "Screenshot", - ), - ]; - - for (tray_item, mode, label) in modes { - let is_selected = current_mode == mode; - let display_label = if is_selected { - format!("✓ {label}") - } else { - format!(" {label}") - }; - - let menu_item = MenuItem::with_id(app, tray_item, display_label, true, None::<&str>)?; - submenu.append(&menu_item)?; - } - - Ok(submenu) -} - -fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result> { - if should_use_minimal_onboarding_tray_menu(app) { - return Menu::with_items( - app, - &[ - &MenuItem::with_id( - app, - TrayItem::RequestPermissions, - "Request Permissions", - true, - None::<&str>, - )?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id( - app, - "version", - format!("Cap v{}", env!("CARGO_PKG_VERSION")), - false, - None::<&str>, - )?, - &MenuItem::with_id(app, TrayItem::Quit, "Quit Cap", true, None::<&str>)?, - ], - ); - } - - let previous_submenu = create_previous_submenu(app, cache)?; - let mode_submenu = create_mode_submenu(app)?; - let current_mode = get_current_mode(app); - let is_screenshot_mode = current_mode == RecordingMode::Screenshot; - - let menu = Menu::new(app)?; - - menu.append(&MenuItem::with_id( - app, - TrayItem::OpenCap, - "Open Main Window", - true, - None::<&str>, - )?)?; - - if is_screenshot_mode { - menu.append(&MenuItem::with_id( - app, - TrayItem::RecordDisplay, - "Screenshot Display", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::RecordWindow, - "Screenshot Window", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::RecordArea, - "Screenshot Area", - true, - None::<&str>, - )?)?; - } else { - menu.append(&MenuItem::with_id( - app, - TrayItem::RecordDisplay, - "Record Display", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::RecordWindow, - "Record Window", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::RecordArea, - "Record Area", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::TakeScreenshot, - "Take a Screenshot", - true, - None::<&str>, - )?)?; - } - - menu.append(&MenuItem::with_id( - app, - TrayItem::ImportVideo, - "Import Media...", - true, - None::<&str>, - )?)?; - - menu.append(&PredefinedMenuItem::separator(app)?)?; - menu.append(&mode_submenu)?; - menu.append(&previous_submenu)?; - menu.append(&PredefinedMenuItem::separator(app)?)?; - - menu.append(&MenuItem::with_id( - app, - TrayItem::ViewAllRecordings, - "View all recordings", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::ViewAllScreenshots, - "View all screenshots", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::OpenSettings, - "Settings", - true, - None::<&str>, - )?)?; - - menu.append(&PredefinedMenuItem::separator(app)?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::UploadLogs, - "Upload Logs", - true, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - "version", - format!("Cap v{}", env!("CARGO_PKG_VERSION")), - false, - None::<&str>, - )?)?; - menu.append(&MenuItem::with_id( - app, - TrayItem::Quit, - "Quit Cap", - true, - None::<&str>, - )?)?; - - Ok(menu) -} - -fn add_new_item_to_cache(cache: &Arc>, app: &AppHandle, path: PathBuf) { - let screenshots_dir = screenshots_path(app); - - let Some(new_item) = load_single_item(&path, &screenshots_dir, true) else { - return; - }; - - let mut cache_guard = cache.lock().unwrap(); - - cache_guard.items.retain(|item| item.path != path); - - cache_guard.items.insert(0, new_item); - - cache_guard.items.truncate(MAX_PREVIOUS_ITEMS); -} - -fn refresh_tray_menu(app: &AppHandle, cache: &Arc>) { - let app_clone = app.clone(); - let cache_clone = cache.clone(); - - let _ = app.run_on_main_thread(move || { - let Some(tray) = app_clone.tray_by_id("tray") else { - return; - }; - - let cache_guard = cache_clone.lock().unwrap(); - if let Ok(menu) = build_tray_menu(&app_clone, &cache_guard) { - let _ = tray.set_menu(Some(menu)); - } - }); -} - -fn handle_previous_item_click(app: &AppHandle, path_str: &str) { - let path = PathBuf::from(path_str); - - let screenshots_dir = screenshots_path(app); - let is_screenshot = path.extension().and_then(|s| s.to_str()) == Some("cap") - && path.parent().map(|p| p == screenshots_dir).unwrap_or(false); - - if is_screenshot { - let app = app.clone(); - let screenshot_path = path; - tokio::spawn(async move { - let _ = ShowCapWindow::ScreenshotEditor { - path: screenshot_path, - } - .show(&app) - .await; - }); - return; - } - - let meta = match RecordingMeta::load_for_project(&path) { - Ok(m) => m, - Err(e) => { - tracing::error!("Failed to load recording meta for previous item: {e}"); - return; - } - }; - - match &meta.inner { - RecordingMetaInner::Studio(_) => { - let app = app.clone(); - let project_path = path.clone(); - tokio::spawn(async move { - let _ = ShowCapWindow::Editor { project_path }.show(&app).await; - }); - } - RecordingMetaInner::Instant(_) => { - if let Some(sharing) = &meta.sharing { - let _ = app.opener().open_url(&sharing.link, None::); - } else { - let mp4_path = path.join("content/output.mp4"); - if mp4_path.exists() { - let _ = app - .opener() - .open_path(mp4_path.to_str().unwrap_or_default(), None::); - } - } - } - } -} - -pub fn get_tray_icon() -> &'static [u8] { - include_bytes!("../icons/tray-default-icon.png") -} - -pub fn get_mode_icon(mode: RecordingMode) -> &'static [u8] { - if cfg!(target_os = "windows") { - return get_tray_icon(); - } - match mode { - RecordingMode::Studio => include_bytes!("../icons/tray-default-icon-studio.png"), - RecordingMode::Instant => include_bytes!("../icons/tray-default-icon-instant.png"), - RecordingMode::Screenshot => include_bytes!("../icons/tray-default-icon-screenshot.png"), - } -} - -#[cfg(target_os = "linux")] -fn linux_tray_icon_for_mode(mode: RecordingMode) -> LinuxTrayIcon { - match mode { - RecordingMode::Studio => LinuxTrayIcon::Studio, - RecordingMode::Instant => LinuxTrayIcon::Instant, - RecordingMode::Screenshot => LinuxTrayIcon::Screenshot, - } -} - -#[cfg(target_os = "linux")] -fn write_linux_tray_symbolic_icon(icon: LinuxTrayIcon) -> std::io::Result { - let dir = dirs::runtime_dir() - .unwrap_or_else(std::env::temp_dir) - .join("cap-tray-icons"); - std::fs::create_dir_all(&dir)?; - - let path = dir.join(format!("{}.svg", icon.name())); - let svg = icon.svg().as_bytes(); - if std::fs::read(&path).ok().as_deref() != Some(svg) { - std::fs::write(path, svg)?; - } - - Ok(dir) -} - -#[cfg(target_os = "linux")] -fn set_linux_tray_icon(tray: &TrayIcon, icon: LinuxTrayIcon) -> tauri::Result<()> { - let icon_dir = write_linux_tray_symbolic_icon(icon).map_err(tauri::Error::Io)?; - let icon_name = icon.name().to_string(); - - tray.with_inner_tray_icon(move |inner| unsafe { - let indicator = inner.app_indicator() as *mut libappindicator::AppIndicator; - if let Some(indicator) = indicator.as_mut() { - indicator.set_icon_theme_path(&icon_dir.to_string_lossy()); - indicator.set_icon_full(&icon_name, "Cap tray icon"); - } - })?; - - Ok(()) -} - -#[cfg(target_os = "linux")] -fn set_tray_icon_for_mode(tray: &TrayIcon, mode: RecordingMode) -> tauri::Result<()> { - set_linux_tray_icon(tray, linux_tray_icon_for_mode(mode)) -} - -#[cfg(not(target_os = "linux"))] -fn set_tray_icon_for_mode(tray: &TrayIcon, mode: RecordingMode) -> tauri::Result<()> { - let icon = Image::from_bytes(get_mode_icon(mode))?; - tray.set_icon(Some(icon))?; - tray.set_icon_as_template(true)?; - Ok(()) -} - -#[cfg(target_os = "linux")] -fn set_tray_stop_icon(tray: &TrayIcon) -> tauri::Result<()> { - set_linux_tray_icon(tray, LinuxTrayIcon::Stop) -} - -#[cfg(not(target_os = "linux"))] -fn set_tray_stop_icon(tray: &TrayIcon) -> tauri::Result<()> { - let icon = Image::from_bytes(include_bytes!("../icons/tray-stop-icon.png"))?; - tray.set_icon(Some(icon))?; - tray.set_icon_as_template(true)?; - Ok(()) -} - -pub fn update_tray_icon_for_mode(app: &AppHandle, mode: RecordingMode) { - if cfg!(target_os = "windows") { - return; - } - - let Some(tray) = app.tray_by_id("tray") else { - return; - }; - - if let Err(error) = set_tray_icon_for_mode(&tray, mode) { - tracing::warn!("Failed to update tray icon: {error}"); - } -} - -fn handle_mode_selection( - app: &AppHandle, - mode: RecordingMode, - cache: &Arc>, -) { - if let Err(e) = RecordingSettingsStore::set_mode(app, mode) { - tracing::error!("Failed to set recording mode: {e}"); - return; - } - - update_tray_icon_for_mode(app, mode); - refresh_tray_menu(app, cache); -} - -pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { - let items = load_all_previous_items(app, false); - let cache = Arc::new(Mutex::new(PreviousItemsCache { items })); - - app.manage(TrayMenuCache { - cache: cache.clone(), - }); - - let menu = { - let cache_guard = cache.lock().unwrap(); - build_tray_menu(app, &cache_guard)? - }; - let app = app.clone(); - let is_recording = Arc::new(AtomicBool::new(false)); - - let current_mode = get_current_mode(&app); - let initial_icon = Image::from_bytes(get_mode_icon(current_mode))?; - - let _ = TrayIconBuilder::with_id("tray") - .icon(initial_icon) - .icon_as_template(cfg!(target_os = "macos")) - .menu(&menu) - .show_menu_on_left_click(true) - .on_menu_event({ - let app_handle = app.clone(); - let cache = cache.clone(); - move |app: &AppHandle, event| match TrayItem::try_from(event.id) { - Ok(TrayItem::OpenCap) => { - let app = app.clone(); - tokio::spawn(async move { - let _ = ShowCapWindow::Main { - init_target_mode: None, - } - .show(&app) - .await; - }); - } - Ok(TrayItem::RecordDisplay) => { - let app = app.clone(); - tokio::spawn(async move { - crate::open_target_picker(&app, RecordingTargetMode::Display).await; - }); - } - Ok(TrayItem::RecordWindow) => { - let app = app.clone(); - tokio::spawn(async move { - crate::open_target_picker(&app, RecordingTargetMode::Window).await; - }); - } - Ok(TrayItem::RecordArea) => { - let app = app.clone(); - tokio::spawn(async move { - crate::open_target_picker(&app, RecordingTargetMode::Area).await; - }); - } - Ok(TrayItem::TakeScreenshot) => { - let app = app.clone(); - tokio::spawn(async move { - use cap_recording::screen_capture::ScreenCaptureTarget; - use scap_targets::Display; - - let display = - Display::get_containing_cursor().unwrap_or_else(Display::primary); - let target = ScreenCaptureTarget::Display { id: display.id() }; - - match recording::take_screenshot(app.clone(), target.clone()).await { - Ok(path) => { - if crate::automation::should_open_screenshot_editor(&app, &target) { - let _ = - ShowCapWindow::ScreenshotEditor { path }.show(&app).await; - } - } - Err(e) => { - tracing::error!("Failed to take screenshot: {e}"); - } - } - }); - } - Ok(TrayItem::ImportVideo) => { - let app = app.clone(); - tokio::spawn(async move { - let file_path = app - .dialog() - .file() - .add_filter( - "Media Files", - &[ - "mp4", "mov", "avi", "mkv", "webm", "wmv", "m4v", "flv", "png", - "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff", - ], - ) - .add_filter( - "Video Files", - &["mp4", "mov", "avi", "mkv", "webm", "wmv", "m4v", "flv"], - ) - .add_filter( - "Image Files", - &["png", "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff"], - ) - .blocking_pick_file(); - - if let Some(file_path) = file_path { - let path = match file_path.into_path() { - Ok(p) => p, - Err(e) => { - tracing::error!("Invalid file path: {e}"); - return; - } - }; - - if crate::import::is_supported_video_import_path(&path) { - match crate::import::start_video_import(app.clone(), path).await { - Ok(project_path) => { - let _ = - ShowCapWindow::Editor { project_path }.show(&app).await; - } - Err(e) => { - tracing::error!("Failed to import video: {e}"); - app.dialog() - .message(format!("Failed to import video: {e}")) - .title("Import Error") - .kind(tauri_plugin_dialog::MessageDialogKind::Error) - .blocking_show(); - } - } - } else if crate::import::is_supported_image_import_path(&path) { - match crate::import::start_image_import(app.clone(), path).await { - Ok(path) => { - let _ = ShowCapWindow::ScreenshotEditor { path } - .show(&app) - .await; - } - Err(e) => { - tracing::error!("Failed to import image: {e}"); - app.dialog() - .message(format!("Failed to import image: {e}")) - .title("Import Error") - .kind(tauri_plugin_dialog::MessageDialogKind::Error) - .blocking_show(); - } - } - } else { - tracing::error!("Unsupported media import path: {:?}", path); - app.dialog() - .message("Unsupported media file.") - .title("Import Error") - .kind(tauri_plugin_dialog::MessageDialogKind::Error) - .blocking_show(); - } - } - }); - } - Ok(TrayItem::ViewAllRecordings) => { - let _ = RequestOpenSettings { - page: "recordings".to_string(), - } - .emit(&app_handle); - } - Ok(TrayItem::ViewAllScreenshots) => { - let _ = RequestOpenSettings { - page: "screenshots".to_string(), - } - .emit(&app_handle); - } - Ok(TrayItem::OpenSettings) => { - let app = app.clone(); - tokio::spawn( - async move { ShowCapWindow::Settings { page: None }.show(&app).await }, - ); - } - Ok(TrayItem::UploadLogs) => { - let app = app.clone(); - tokio::spawn(async move { - match crate::logging::upload_log_file(&app).await { - Ok(_) => { - tracing::info!("Successfully uploaded logs"); - app.dialog() - .message("Logs uploaded successfully") - .show(|_| {}); - } - Err(e) => { - tracing::error!("Failed to upload logs: {e:#}"); - app.dialog().message("Failed to upload logs").show(|_| {}); - } - } - }); - } - Ok(TrayItem::Quit) => { - let app = app.clone(); - tokio::spawn(async move { - crate::request_app_exit(app).await; - }); - } - Ok(TrayItem::PreviousItem(path)) => { - handle_previous_item_click(app, &path); - } - Ok(TrayItem::ModeStudio) => { - handle_mode_selection(app, RecordingMode::Studio, &cache); - } - Ok(TrayItem::ModeInstant) => { - handle_mode_selection(app, RecordingMode::Instant, &cache); - } - Ok(TrayItem::ModeScreenshot) => { - handle_mode_selection(app, RecordingMode::Screenshot, &cache); - } - Ok(TrayItem::RequestPermissions) => { - let app = app.clone(); - tokio::spawn(async move { - let _ = ShowCapWindow::Onboarding.show(&app).await; - }); - } - _ => {} - } - }) - .on_tray_icon_event({ - let is_recording = Arc::clone(&is_recording); - let app_handle = app.clone(); - move |tray, event| { - if let tauri::tray::TrayIconEvent::Click { .. } = event { - if is_recording.load(Ordering::Relaxed) { - let app = app_handle.clone(); - tokio::spawn(async move { - let _ = recording::stop_recording(app.clone(), app.state()).await; - }); - } else { - let _ = tray.set_visible(true); - } - } - } - }) - .build(&app); - - #[cfg(target_os = "linux")] - if let Some(tray) = app.tray_by_id("tray") - && let Err(error) = set_tray_icon_for_mode(&tray, current_mode) - { - tracing::warn!("Failed to initialize Linux tray icon: {error}"); - } - - { - let app_clone = app.clone(); - let cache_clone = cache.clone(); - std::thread::spawn(move || { - let screenshots_dir = screenshots_path(&app_clone); - let items_needing_thumbnails: Vec = { - let cache_guard = cache_clone.lock().unwrap(); - cache_guard - .items - .iter() - .filter(|item| item.thumbnail.is_none()) - .map(|item| item.path.clone()) - .collect() - }; - - if items_needing_thumbnails.is_empty() { - return; - } - - for path in items_needing_thumbnails { - if let Some(updated_item) = load_single_item(&path, &screenshots_dir, true) { - let mut cache_guard = cache_clone.lock().unwrap(); - if let Some(existing) = cache_guard.items.iter_mut().find(|i| i.path == path) { - existing.thumbnail = updated_item.thumbnail; - existing.thumbnail_width = updated_item.thumbnail_width; - existing.thumbnail_height = updated_item.thumbnail_height; - } - } - } - - let app_for_refresh = app_clone.clone(); - let cache_for_refresh = cache_clone.clone(); - let _ = app_clone.run_on_main_thread(move || { - if let Some(tray) = app_for_refresh.tray_by_id("tray") { - let cache_guard = cache_for_refresh.lock().unwrap(); - if let Ok(menu) = build_tray_menu(&app_for_refresh, &cache_guard) { - let _ = tray.set_menu(Some(menu)); - } - } - }); - }); - } - - RecordingStarted::listen_any(&app, { - let app = app.clone(); - let is_recording = is_recording.clone(); - move |_| { - is_recording.store(true, Ordering::Relaxed); - - if cfg!(target_os = "windows") { - return; - } - - let Some(tray) = app.tray_by_id("tray") else { - return; - }; - - if let Err(error) = set_tray_stop_icon(&tray) { - tracing::warn!("Failed to update tray icon for recording start: {error}"); - } - } - }); - - RecordingStopped::listen_any(&app, { - let app_handle = app.clone(); - let is_recording = is_recording.clone(); - move |_| { - is_recording.store(false, Ordering::Relaxed); - - if cfg!(target_os = "windows") { - return; - } - - let Some(tray) = app_handle.tray_by_id("tray") else { - return; - }; - - let current_mode = get_current_mode(&app_handle); - if let Err(error) = set_tray_icon_for_mode(&tray, current_mode) { - tracing::warn!("Failed to update tray icon for recording stop: {error}"); - } - } - }); - - NewStudioRecordingAdded::listen_any(&app, { - let app_handle = app.clone(); - let cache_clone = cache.clone(); - move |event| { - add_new_item_to_cache(&cache_clone, &app_handle, event.payload.path.clone()); - refresh_tray_menu(&app_handle, &cache_clone); - } - }); - - NewScreenshotAdded::listen_any(&app, { - let app_handle = app.clone(); - let cache_clone = cache.clone(); - move |event| { - let path = if event.payload.path.extension().and_then(|s| s.to_str()) == Some("png") { - event.payload.path.parent().map(|p| p.to_path_buf()) - } else { - Some(event.payload.path.clone()) - }; - - if let Some(path) = path { - add_new_item_to_cache(&cache_clone, &app_handle, path); - refresh_tray_menu(&app_handle, &cache_clone); - } - } - }); - - crate::recordings_locations::RecordingsMigrationProgress::listen_any(&app, { - let app_handle = app.clone(); - let cache_clone = cache.clone(); - move |event| { - // Rebuild once when a storage-folder migration finishes: moved - // projects changed paths, so cached entries are stale. - if event.payload.done == event.payload.total { - let items = load_all_previous_items(&app_handle, false); - cache_clone.lock().unwrap().items = items; - refresh_tray_menu(&app_handle, &cache_clone); - } - } - }); - - Ok(()) -} diff --git a/apps/desktop/src-tauri/src/updates.rs b/apps/desktop/src-tauri/src/updates.rs deleted file mode 100644 index ac2e1cb37f8..00000000000 --- a/apps/desktop/src-tauri/src/updates.rs +++ /dev/null @@ -1,380 +0,0 @@ -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::time::Duration; -use tauri::{AppHandle, Manager, Url}; -use tauri_plugin_updater::{Update, UpdaterExt}; -use tauri_specta::Event; -use tokio::sync::{Mutex, Notify}; -use tracing::{info, warn}; - -use crate::general_settings::GeneralSettingsStore; - -const UPDATE_ENDPOINT: &str = - "https://cdn.crabnebula.app/update/cap/cap/{{target}}/{{current_version}}"; - -const FIRST_CHECK_DELAY: Duration = Duration::from_secs(60); -const CHECK_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60); -const BUSY_RETRY_DELAY: Duration = Duration::from_secs(5 * 60); - -#[derive(Serialize, Deserialize, Type, Clone, Copy, PartialEq, Eq, Debug, Default)] -#[serde(rename_all = "camelCase")] -pub enum UpdateChannel { - #[default] - Stable, - Nightly, -} - -#[derive(Serialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct UpdateCheckResult { - pub version: String, - pub notes: Option, - pub channel: UpdateChannel, -} - -#[derive(Serialize, Type, tauri_specta::Event, Clone, Debug)] -pub struct UpdateDownloadProgress { - pub downloaded: u32, - pub total: Option, -} - -#[derive(Serialize, Type, tauri_specta::Event, Clone, Debug)] -pub struct UpdateReady { - pub version: String, - pub installed: bool, -} - -#[derive(Clone)] -struct PendingUpdate { - update: Update, - version: String, - installed: bool, -} - -#[derive(Default)] -pub struct UpdatesState { - pending: Mutex>, - announced_version: Mutex>, - notify: Notify, -} - -fn current_channel(app: &AppHandle) -> UpdateChannel { - GeneralSettingsStore::get(app) - .ok() - .flatten() - .map(|s| s.update_channel) - .unwrap_or_default() -} - -// Mirrors `updaterTarget()` in src/utils/updater.ts; the plugin's built-in -// target reports "macos"/"linux" while CrabNebula releases are keyed on -// "darwin-*" / "linux-*-deb". -fn updater_target() -> String { - let arch = if cfg!(target_arch = "aarch64") { - "aarch64" - } else { - "x86_64" - }; - - if cfg!(target_os = "macos") { - format!("darwin-{arch}") - } else if cfg!(target_os = "linux") { - format!("linux-{arch}-deb") - } else { - format!("windows-{arch}") - } -} - -fn endpoint(channel: UpdateChannel) -> Result { - let url = match channel { - UpdateChannel::Stable => UPDATE_ENDPOINT.to_string(), - UpdateChannel::Nightly => format!("{UPDATE_ENDPOINT}?channel=nightly"), - }; - Url::parse(&url).map_err(|e| e.to_string()) -} - -async fn check_channel( - app: &AppHandle, - channel: UpdateChannel, - allow_stable_downgrade: bool, -) -> Result, String> { - let builder = app - .updater_builder() - .target(updater_target()) - .endpoints(vec![endpoint(channel)?]) - .map_err(|e| e.to_string())?; - - // A user on a nightly prerelease who switches back to Stable should land - // on the newest stable build even though it is semver-lower than their - // current version, so any differing non-prerelease remote counts as an - // update (deliberate downgrade-on-channel-switch semantics). This must - // ONLY apply when the user's configured channel is Stable: a nightly user - // on the latest nightly would otherwise see the older stable release - // qualify and flip-flop between the two channels forever. - let builder = if allow_stable_downgrade { - builder.version_comparator(|current, remote| { - remote.version > current - || (!current.pre.is_empty() - && remote.version.pre.is_empty() - && remote.version != current) - }) - } else { - builder.version_comparator(|current, remote| remote.version > current) - }; - - builder - .build() - .map_err(|e| e.to_string())? - .check() - .await - .map_err(|e| e.to_string()) -} - -fn pick_higher_version(a: Option, b: Option) -> Option { - match (a, b) { - (Some(a), Some(b)) => { - match ( - semver::Version::parse(&a.version), - semver::Version::parse(&b.version), - ) { - (Ok(a_version), Ok(b_version)) => Some(if b_version > a_version { b } else { a }), - (Ok(_), Err(_)) => Some(a), - _ => Some(b), - } - } - (a, b) => a.or(b), - } -} - -pub async fn check(app: &AppHandle) -> Result, String> { - let channel = current_channel(app); - - let stable = check_channel(app, UpdateChannel::Stable, channel == UpdateChannel::Stable).await; - - let update = if channel == UpdateChannel::Nightly { - let nightly = check_channel(app, UpdateChannel::Nightly, false).await; - - // Both channels can produce a candidate when a stable release was - // promoted after the last nightly; the higher version wins. - match (stable, nightly) { - (Ok(stable), Ok(nightly)) => pick_higher_version(stable, nightly), - (Ok(update), Err(err)) | (Err(err), Ok(update)) => { - warn!("Update check failed for one channel: {err}"); - update - } - (Err(err), Err(_)) => return Err(err), - } - } else { - stable? - }; - - let state = app.state::(); - let mut pending = state.pending.lock().await; - *pending = update.as_ref().map(|update| PendingUpdate { - // Keep the installed flag if the background loop already installed - // this version silently. - installed: pending - .as_ref() - .is_some_and(|p| p.version == update.version && p.installed), - version: update.version.clone(), - update: update.clone(), - }); - - Ok(update) -} - -async fn download_with_progress(app: &AppHandle, update: &Update) -> Result, String> { - let mut downloaded: u32 = 0; - update - .download( - |chunk, total| { - downloaded = downloaded.saturating_add(chunk as u32); - let _ = UpdateDownloadProgress { - downloaded, - total: total.and_then(|t| u32::try_from(t).ok()), - } - .emit(app); - }, - || {}, - ) - .await - .map_err(|e| e.to_string()) -} - -async fn is_busy(app: &AppHandle) -> bool { - if crate::export::export_session_active() { - return true; - } - - let Some(state) = app.try_state::>() else { - return true; - }; - - state.read().await.is_recording_active_or_pending() -} - -#[tauri::command] -#[specta::specta] -pub async fn updates_check(app: AppHandle) -> Result, String> { - let channel = current_channel(&app); - Ok(check(&app).await?.map(|update| UpdateCheckResult { - version: update.version.clone(), - notes: update.body.clone(), - channel, - })) -} - -#[tauri::command] -#[specta::specta] -pub async fn updates_download_and_install(app: AppHandle) -> Result<(), String> { - let state = app.state::(); - - let pending = match state.pending.lock().await.clone() { - Some(pending) => pending, - None => { - let Some(update) = check(&app).await? else { - return Err("No update available".to_string()); - }; - PendingUpdate { - version: update.version.clone(), - update, - installed: false, - } - } - }; - - // The macOS background loop may have already installed this version - // silently; restarting is all that's left to do. - if pending.installed { - return Ok(()); - } - - let bytes = download_with_progress(&app, &pending.update).await?; - - info!("Installing update {}", pending.version); - pending.update.install(bytes).map_err(|e| e.to_string())?; - - let mut guard = state.pending.lock().await; - if let Some(p) = guard.as_mut() - && p.version == pending.version - { - p.installed = true; - } - - Ok(()) -} - -#[tauri::command] -#[specta::specta] -pub fn updates_channel_changed(app: AppHandle) -> Result<(), String> { - app.state::().notify.notify_one(); - Ok(()) -} - -pub fn spawn_background_loop(app: AppHandle) { - // Never auto-update dev builds. - if cfg!(debug_assertions) { - return; - } - - tokio::spawn(async move { - let mut delay = FIRST_CHECK_DELAY; - - loop { - { - let state = app.state::(); - tokio::select! { - _ = tokio::time::sleep(delay) => {} - _ = state.notify.notified() => {} - } - } - - delay = CHECK_INTERVAL; - - // Stable updates stay frontend-driven; this loop only owns nightly. - if current_channel(&app) != UpdateChannel::Nightly { - continue; - } - - if is_busy(&app).await { - delay = BUSY_RETRY_DELAY; - continue; - } - - let update = match check(&app).await { - Ok(Some(update)) => update, - Ok(None) => continue, - Err(err) => { - warn!("Nightly update check failed: {err}"); - continue; - } - }; - - let version = update.version.clone(); - let state = app.state::(); - - if state.announced_version.lock().await.as_deref() == Some(version.as_str()) { - continue; - } - - let installed = if cfg!(target_os = "macos") { - let already_installed = state - .pending - .lock() - .await - .as_ref() - .is_some_and(|p| p.version == version && p.installed); - - if already_installed { - true - } else { - let bytes = match update.download(|_, _| {}, || {}).await { - Ok(bytes) => bytes, - Err(err) => { - warn!("Failed to download nightly update {version}: {err}"); - continue; - } - }; - - // A recording or export may have started mid-download; - // don't touch the install while one is running. - if is_busy(&app).await { - delay = BUSY_RETRY_DELAY; - continue; - } - - // Safe while Cap runs: the .app bundle is swapped in place - // and takes effect on relaunch. - if let Err(err) = update.install(bytes) { - warn!("Failed to install nightly update {version}: {err}"); - continue; - } - - let mut pending = state.pending.lock().await; - if let Some(p) = pending.as_mut() - && p.version == version - { - p.installed = true; - } - - info!("Nightly update {version} installed; restart to apply"); - true - } - } else { - // Windows (NSIS) install exits the app mid-session and Linux - // (deb) prompts for privileges, so never auto-install there; - // announce and let the user trigger the install. - info!("Nightly update {version} available"); - false - }; - - let _ = UpdateReady { - version: version.clone(), - installed, - } - .emit(&app); - - *state.announced_version.lock().await = Some(version); - } - }); -} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs deleted file mode 100644 index 5deba345c4e..00000000000 --- a/apps/desktop/src-tauri/src/windows.rs +++ /dev/null @@ -1,3707 +0,0 @@ -#![allow(unused_mut)] -#![allow(unused_imports)] - -use anyhow::anyhow; -use futures::pin_mut; -use scap_targets::{Display, DisplayId}; -use serde::Deserialize; -use specta::Type; -use std::{ - ops::Deref, - path::PathBuf, - str::FromStr, - sync::{ - Arc, Mutex, - atomic::{AtomicU32, AtomicU64, Ordering}, - }, - time::Duration, -}; -use tauri::{ - AppHandle, LogicalPosition, LogicalSize, Manager, Monitor, PhysicalPosition, PhysicalSize, - WebviewUrl, WebviewWindow, WebviewWindowBuilder, Wry, -}; -use tauri_specta::Event; -use tokio::sync::RwLock; -use tracing::{debug, error, info, instrument, warn}; - -#[cfg(target_os = "macos")] -use crate::panel_manager::{PanelManager, PanelState, PanelWindowType, is_window_handle_valid}; - -use crate::{ - App, ArcLock, CameraWindowCloseGate, CameraWindowPositionGuard, MainWindowReadyState, - NewNotification, RequestSetTargetMode, camera_preview_error_message, - editor_window::PendingEditorInstances, - emit_camera_preview_clear, emit_camera_preview_error, fake_window, - general_settings::{self, AppTheme, GeneralSettingsStore}, - permissions, - recording::{RecordingEvent, RecordingInputKind}, - recording_settings::RecordingTargetMode, - screenshot_editor::PendingScreenshotEditorInstances, - target_select_overlay::WindowFocusManager, - window_exclusion::WindowExclusion, -}; -use cap_recording::{feeds, sources::screen_capture::ScreenCaptureTarget}; - -#[cfg(target_os = "macos")] -const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); - -#[cfg(target_os = "macos")] -const MAIN_PANEL_LEVEL: i32 = 100; - -#[cfg(target_os = "macos")] -const TELEPROMPTER_PANEL_LEVEL: objc2_app_kit::NSWindowLevel = MAIN_PANEL_LEVEL as isize + 1; - -const DEFAULT_FALLBACK_DISPLAY_WIDTH: f64 = 1920.0; -const DEFAULT_FALLBACK_DISPLAY_HEIGHT: f64 = 1080.0; - -#[cfg(windows)] -const WINDOWS_WEBVIEW2_BROWSER_ARGS: &str = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --disable-vulkan --use-angle=d3d11"; - -#[cfg(windows)] -fn windows_webview2_browser_args() -> String { - let mut args = WINDOWS_WEBVIEW2_BROWSER_ARGS.to_string(); - if cap_rendering::force_software_wgpu_adapter() - || std::env::args_os().any(|arg| arg.to_str() == Some("--disable-gpu")) - { - args.push_str(" --disable-gpu"); - } - args -} - -#[cfg(target_os = "macos")] -fn is_system_dark_mode() -> bool { - use cocoa::base::{id, nil}; - use cocoa::foundation::NSString; - use objc::{class, msg_send, sel, sel_impl}; - - unsafe { - let app: id = msg_send![class!(NSApplication), sharedApplication]; - let appearance: id = msg_send![app, effectiveAppearance]; - if appearance == nil { - return false; - } - let name: id = msg_send![appearance, name]; - if name == nil { - return false; - } - let dark_appearance = NSString::alloc(nil).init_str("NSAppearanceNameDarkAqua"); - let vibrant_dark = NSString::alloc(nil).init_str("NSAppearanceNameVibrantDark"); - let is_dark: bool = msg_send![name, isEqualToString: dark_appearance]; - let is_vibrant_dark: bool = msg_send![name, isEqualToString: vibrant_dark]; - is_dark || is_vibrant_dark - } -} - -#[cfg(target_os = "windows")] -fn is_system_dark_mode() -> bool { - use winreg::RegKey; - use winreg::enums::HKEY_CURRENT_USER; - - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(key) = - hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize") - && let Ok(value) = key.get_value::("AppsUseLightTheme") - { - return value == 0; - } - false -} - -#[cfg(target_os = "linux")] -fn is_system_dark_mode() -> bool { - let output = std::process::Command::new("gsettings") - .args(["get", "org.gnome.desktop.interface", "color-scheme"]) - .output(); - if let Ok(output) = output - && output.status.success() - { - return String::from_utf8_lossy(&output.stdout).contains("dark"); - } - false -} - -pub fn hide_overlay(window: &WebviewWindow) { - let _ = window.set_ignore_cursor_events(true); - let _ = window.hide(); -} - -pub fn show_overlay(window: &WebviewWindow) { - let _ = window.set_ignore_cursor_events(false); - let _ = window.show(); -} - -fn emit_app_event(app: &AppHandle, event: E) -where - E: Event + serde::Serialize + Clone, -{ - let event_name = std::any::type_name::(); - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| event.emit(app))) { - Ok(Ok(())) => {} - Ok(Err(error)) => warn!(event = event_name, %error, "Failed to emit app event"), - Err(panic) => { - let message = crate::panic_payload_message(&panic); - error!(event = event_name, panic = %message, "Suppressed panic while emitting app event"); - } - } -} - -fn hide_recording_windows(app: &AppHandle, restore_target_select_overlays: bool) { - let focus_manager = app.try_state::(); - - for (label, window) in app.webview_windows() { - if let Ok(id) = CapWindowId::from_str(&label) - && matches!( - id, - CapWindowId::TargetSelectOverlay { .. } | CapWindowId::Main | CapWindowId::Camera - ) - { - if matches!(id, CapWindowId::TargetSelectOverlay { .. }) { - if restore_target_select_overlays - && window.is_visible().unwrap_or(false) - && let Some(focus_manager) = focus_manager.as_ref() - { - focus_manager.remember_overlay_for_restore(label); - } - hide_overlay(&window); - } else { - let _ = window.hide(); - } - } - } -} - -/// Release the live camera preview feed after `hide_recording_windows` when a -/// foreground window (Settings, an editor) takes over. Hiding the camera window -/// alone leaves the capture session running, so the OS camera-in-use indicator -/// stays lit while the user is in the editor. `restore_main_window_inputs` -/// re-attaches the feed when the main window comes back. -fn release_camera_preview_if_idle(app: &AppHandle) { - let is_recording = app - .try_state::>() - .and_then(|state| { - state - .try_read() - .ok() - .map(|state| state.is_recording_active_or_pending()) - }) - .unwrap_or(true); - - if is_recording { - return; - } - - let app = app.clone(); - tokio::spawn(async move { - if let Some(state) = app.try_state::>() { - let app_state = &mut *state.write().await; - app_state.camera_preview.pause(); - let _ = app_state.camera_feed.ask(feeds::camera::RemoveInput).await; - app_state.camera_in_use = false; - } else { - warn!("App state unavailable while pausing camera preview"); - } - }); -} - -fn bump_camera_window_session(app: &AppHandle) -> u64 { - app.state::>().fetch_add(1, Ordering::AcqRel) + 1 -} - -fn camera_window_label_for_session(session_id: u64) -> String { - format!("camera-{session_id}") -} - -fn is_camera_window_label(label: &str) -> bool { - label == "camera" - || label - .strip_prefix("camera-") - .is_some_and(|suffix| suffix.parse::().is_ok()) -} - -fn camera_window_rank(label: &str) -> u64 { - if label == "camera" { - return 0; - } - - label - .strip_prefix("camera-") - .and_then(|suffix| suffix.parse::().ok()) - .unwrap_or(0) -} - -#[cfg(target_os = "macos")] -fn camera_window_labels(app: &AppHandle) -> Vec { - app.webview_windows() - .into_keys() - .filter(|label| is_camera_window_label(label)) - .collect() -} - -fn camera_webview_window_entries(app: &AppHandle) -> Vec<(String, WebviewWindow)> { - app.webview_windows() - .into_iter() - .filter(|(label, _)| is_camera_window_label(label)) - .collect() -} - -fn camera_webview_windows(app: &AppHandle) -> Vec { - camera_webview_window_entries(app) - .into_iter() - .map(|(_, window)| window) - .collect() -} - -fn current_camera_window(app: &AppHandle) -> Option { - #[cfg(target_os = "macos")] - { - camera_webview_window_entries(app) - .into_iter() - .filter(|(_, window)| is_window_handle_valid(window)) - .max_by_key(|(label, _)| camera_window_rank(label)) - .map(|(_, window)| window) - } - - #[cfg(not(target_os = "macos"))] - { - camera_webview_window_entries(app) - .into_iter() - .max_by_key(|(label, _)| camera_window_rank(label)) - .map(|(_, window)| window) - } -} - -fn destroy_camera_window_handle( - app: &AppHandle, - window: WebviewWindow, -) -> tokio::sync::oneshot::Receiver<()> { - let (destroy_tx, destroy_rx) = tokio::sync::oneshot::channel(); - let _ = window.as_ref().close(); - app.run_on_main_thread({ - let window = window.clone(); - move || { - let _ = window.destroy(); - let _ = destroy_tx.send(()); - } - }) - .ok(); - destroy_rx -} - -async fn init_native_camera_preview( - app_state: &mut App, - window: WebviewWindow, -) -> Result<(), String> { - let camera_feed = app_state.camera_feed.clone(); - let init_result = app_state - .camera_preview - .init_window(window, camera_feed.clone()) - .await; - - match init_result { - Ok(()) => { - #[allow(deprecated)] - let camera_ws_sender = app_state.camera_ws_sender.clone(); - #[allow(deprecated)] - if let Err(err) = camera_feed - .ask(feeds::camera::RemoveSender(camera_ws_sender)) - .await - { - warn!(error = %err, "Failed to remove legacy camera preview sender"); - } - Ok(()) - } - Err(err) => { - #[allow(deprecated)] - let camera_ws_sender = app_state.camera_ws_sender.clone(); - #[allow(deprecated)] - if let Err(add_err) = camera_feed - .ask(feeds::camera::AddSender(camera_ws_sender)) - .await - { - warn!(error = %add_err, "Failed to restore legacy camera preview sender"); - } - Err(err.to_string()) - } - } -} - -pub(crate) async fn ensure_camera_input_active(app_state: &mut App) { - if let Some(id) = app_state.selected_camera_id.clone() - && !app_state.camera_in_use - { - let settings = crate::recording_settings::RecordingSettingsStore::camera_settings_for( - &app_state.handle, - &id, - ); - match app_state - .camera_feed - .ask(feeds::camera::SetInput { id, settings }) - .await - { - Ok(ready_future) => { - if let Err(err) = ready_future.await { - error!("Camera failed to initialize: {err}"); - return; - } - } - Err(err) => { - error!("Failed to send SetInput to camera feed: {err}"); - return; - } - } - - app_state.camera_in_use = true; - app_state.camera_cleanup_done = false; - } -} - -pub(crate) async fn restore_main_window_inputs(app: &AppHandle) { - let Some(state) = app.try_state::>() else { - warn!("App state unavailable while restoring main window inputs"); - return; - }; - - let should_restore = state - .try_read() - .map(|state| !state.is_recording_active_or_pending()) - .unwrap_or(false); - - if !should_restore { - return; - } - - let settings = crate::recording_settings::RecordingSettingsStore::get(app) - .ok() - .flatten() - .unwrap_or_default(); - let stored_camera_id = settings.camera_id.clone(); - - if let Err(err) = crate::set_mic_input(state.clone(), settings.mic_name).await { - warn!("Failed to restore microphone input for main window: {err}"); - } - - let Some(operation_lock) = app.try_state::() else { - warn!("CameraWindowOperationLock unavailable while restoring main window inputs"); - return; - }; - let operation_guard = operation_lock.lock().await; - - let camera_to_restore = state - .try_read() - .map(|s| { - if !s.camera_cleanup_done && !s.camera_in_use { - s.selected_camera_id - .clone() - .or_else(|| stored_camera_id.clone()) - } else { - None - } - }) - .unwrap_or(None) - // A remembered camera that isn't connected must not run the init/retry - // loop below: it would flash the preview window and toast an error on - // every main-window reveal while the device is away. - .filter(crate::is_camera_available); - - if let Some(camera_id) = camera_to_restore { - emit_camera_preview_clear(app); - let settings = - crate::recording_settings::RecordingSettingsStore::camera_settings_for(app, &camera_id); - - let (camera_feed, camera_ws_sender, native_sender) = { - let app_state = &mut *state.write().await; - app_state.selected_camera_id = Some(camera_id.clone()); - app_state.camera_in_use = true; - app_state.camera_cleanup_done = false; - #[allow(deprecated)] - ( - app_state.camera_feed.clone(), - app_state.camera_ws_sender.clone(), - app_state.camera_preview.sender(), - ) - }; - - if let Some(sender) = native_sender { - #[allow(deprecated)] - let _ = camera_feed - .ask(feeds::camera::RemoveSender(camera_ws_sender)) - .await; - if let Err(err) = sender.attach(&camera_feed).await { - warn!(error = %err, "Failed to add native preview camera sender"); - } - } else { - #[allow(deprecated)] - let _ = camera_feed - .ask(feeds::camera::AddSender(camera_ws_sender)) - .await; - } - - let mut showed_camera_window = false; - let mut attempts = 0; - let init_result: Result<(), String> = loop { - attempts += 1; - let request = camera_feed - .ask(feeds::camera::SetInput { - id: camera_id.clone(), - settings, - }) - .await - .map_err(|e| e.to_string()); - - if !showed_camera_window { - showed_camera_window = true; - crate::show_camera_window_unlocked(app); - } - - match request { - Ok(future) => match future.await { - Ok(_) => { - emit_camera_preview_clear(app); - break Ok(()); - } - Err(e) => { - if attempts == 1 { - emit_camera_preview_error( - app, - camera_preview_error_message(&e.to_string()), - ); - } - if attempts >= 3 { - break Err(format!( - "Failed to restore camera after {attempts} attempts: {e}" - )); - } - warn!("Camera restore attempt {attempts} failed: {e}. Retrying..."); - tokio::time::sleep(Duration::from_millis(500)).await; - } - }, - Err(e) => { - if attempts >= 3 { - break Err(e); - } - warn!("Camera restore attempt {attempts} failed: {e}. Retrying..."); - tokio::time::sleep(Duration::from_millis(500)).await; - } - } - }; - - drop(operation_guard); - - match init_result { - Ok(()) => crate::restore_camera_window(app), - Err(error) => { - let message = camera_preview_error_message(&error); - warn!("Failed to restore camera input for main window: {error}"); - let _ = camera_feed.ask(feeds::camera::RemoveInput).await; - let emit_input_lost = { - let app_state = &mut *state.write().await; - app_state.selected_camera_id = None; - app_state.camera_in_use = false; - app_state - .disconnected_inputs - .insert(RecordingInputKind::Camera) - }; - crate::show_camera_window_unlocked(app); - if emit_input_lost { - let _ = RecordingEvent::InputLost { - input: RecordingInputKind::Camera, - } - .emit(app); - } - emit_camera_preview_error(app, message.clone()); - let _ = NewNotification { - title: "Camera unavailable".to_string(), - body: message, - is_error: true, - } - .emit(app); - } - } - } -} - -pub(crate) async fn cleanup_camera_window( - app: &AppHandle, - window: Option<&WebviewWindow>, - #[allow(unused_variables)] reset_panel: bool, - wait_for_removal: bool, -) -> bool { - use crate::CameraWindowCloseGate; - - #[cfg(target_os = "macos")] - if reset_panel { - let panel_manager = app.state::(); - panel_manager.force_reset(PanelWindowType::Camera).await; - } - - app.state::().set_allow_close(true); - - #[cfg(target_os = "macos")] - { - let panel_labels = window - .map(|window| vec![window.label().to_string()]) - .unwrap_or_else(|| camera_window_labels(app)); - let (panel_close_tx, panel_close_rx) = tokio::sync::oneshot::channel(); - let app_for_close = app.clone(); - app.run_on_main_thread(move || { - use tauri_nspanel::ManagerExt; - for label in panel_labels { - if let Ok(panel) = app_for_close.get_webview_panel(&label) { - panel.released_when_closed(false); - panel.close(); - } - } - let _ = panel_close_tx.send(()); - }) - .ok(); - let _ = tokio::time::timeout(std::time::Duration::from_millis(500), panel_close_rx).await; - } - - let windows = window - .cloned() - .map(|window| vec![window]) - .unwrap_or_else(|| camera_webview_windows(app)); - for window in windows { - let destroy_rx = destroy_camera_window_handle(app, window); - let _ = tokio::time::timeout(std::time::Duration::from_millis(500), destroy_rx).await; - } - - if wait_for_removal { - let start = std::time::Instant::now(); - let timeout = std::time::Duration::from_millis(2000); - while start.elapsed() < timeout && !camera_webview_windows(app).is_empty() { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - - let still_exists = !camera_webview_windows(app).is_empty(); - app.state::().set_allow_close(false); - - !still_exists -} - -struct CursorMonitorInfo { - x: f64, - y: f64, - width: f64, - height: f64, - // On Windows each monitor's "logical" rect is its physical rect divided by - // its own scale, so logical rects of mixed-DPI monitors overlap and tao's - // LogicalPosition conversion (which uses whatever monitor the window - // currently occupies) can land a window on the wrong monitor. Positioning - // must go through this monitor's own scale, as a physical position. - #[cfg(windows)] - scale: f64, -} - -impl CursorMonitorInfo { - fn get() -> Self { - Self::from_display(&Display::get_containing_cursor().unwrap_or_else(Display::primary)) - } - - fn from_display(display: &Display) -> Self { - let bounds = display.raw_handle().logical_bounds(); - - #[cfg(windows)] - let scale = bounds - .as_ref() - .map(|b| b.size().width()) - .filter(|width| *width > 0.0) - .and_then(|logical_width| { - display - .physical_size() - .map(|physical| physical.width() / logical_width) - }) - .filter(|scale| scale.is_finite() && *scale > 0.0) - .unwrap_or(1.0); - - let (x, y, width, height) = bounds - .map(|b| { - ( - b.position().x(), - b.position().y(), - b.size().width(), - b.size().height(), - ) - }) - .unwrap_or(( - 0.0, - 0.0, - DEFAULT_FALLBACK_DISPLAY_WIDTH, - DEFAULT_FALLBACK_DISPLAY_HEIGHT, - )); - - Self { - x, - y, - width, - height, - #[cfg(windows)] - scale, - } - } - - /// Converts a global-logical point on this monitor into a `Position` that - /// lands exactly there regardless of which monitor the window currently - /// occupies. Logical on macOS/Linux (a true global space there), physical - /// on Windows. - fn position(&self, x: f64, y: f64) -> tauri::Position { - #[cfg(windows)] - return tauri::Position::Physical(tauri::PhysicalPosition::new( - (x * self.scale).round() as i32, - (y * self.scale).round() as i32, - )); - - #[cfg(not(windows))] - tauri::Position::Logical(tauri::LogicalPosition::new(x, y)) - } - - fn center_position(&self, window_width: f64, window_height: f64) -> (f64, f64) { - let pos_x = self.x + (self.width - window_width) / 2.0; - let pos_y = self.y + (self.height - window_height) / 2.0; - (pos_x, pos_y) - } - - fn bottom_center_position( - &self, - window_width: f64, - window_height: f64, - offset_y: f64, - ) -> (f64, f64) { - let pos_x = self.x + (self.width - window_width) / 2.0; - let pos_y = self.y + self.height - window_height - offset_y; - (pos_x, pos_y) - } - - fn from_window(window: &tauri::WebviewWindow) -> Self { - let Ok(window_pos) = window.outer_position() else { - return Self::get(); - }; - - // outer_position is physical. On Windows, resolve the display in - // physical space (per-monitor logical rects overlap in mixed-DPI - // layouts). On macOS, convert to logical points, a true global space. - // On Linux scap reports logical bounds in unscaled physical units, so - // the raw position compares directly. - #[cfg(windows)] - { - let (pos_x, pos_y) = (window_pos.x as f64, window_pos.y as f64); - for display in Display::list() { - if let Some(bounds) = display.raw_handle().physical_bounds() { - let (x, y, width, height) = ( - bounds.position().x(), - bounds.position().y(), - bounds.size().width(), - bounds.size().height(), - ); - - if pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height { - return Self::from_display(&display); - } - } - } - - Self::get() - } - - #[cfg(target_os = "macos")] - { - let scale = window.scale_factor().unwrap_or(1.0); - let pos = window_pos.to_logical::(scale); - - for display in Display::list() { - if display_contains_logical(&display, pos.x, pos.y) { - return Self::from_display(&display); - } - } - - Self::get() - } - - #[cfg(target_os = "linux")] - { - let (pos_x, pos_y) = (window_pos.x as f64, window_pos.y as f64); - - for display in Display::list() { - if display_contains_logical(&display, pos_x, pos_y) { - return Self::from_display(&display); - } - } - - Self::get() - } - } -} - -fn display_contains_logical(display: &Display, pos_x: f64, pos_y: f64) -> bool { - display - .raw_handle() - .logical_bounds() - .map(|bounds| { - let (x, y, width, height) = ( - bounds.position().x(), - bounds.position().y(), - bounds.size().width(), - bounds.size().height(), - ); - - pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height - }) - .unwrap_or(false) -} - -fn display_containing_logical(pos_x: f64, pos_y: f64) -> Option { - Display::list() - .into_iter() - .find(|display| display_contains_logical(display, pos_x, pos_y)) -} - -/// Resolves the display a persisted window position belongs to, preferring the -/// display it was saved on. On Windows the saved logical coordinates are only -/// meaningful relative to that display (mixed-DPI logical rects overlap), so -/// restores must convert through its scale rather than the window's current one. -fn display_for_saved_position( - pos_x: f64, - pos_y: f64, - display_id: Option<&DisplayId>, -) -> Option { - display_id - .and_then(Display::from_id) - .filter(|display| display_contains_logical(display, pos_x, pos_y)) - .or_else(|| display_containing_logical(pos_x, pos_y)) -} - -/// Converts a global-logical point into a `Position` that lands exactly there, -/// resolving the owning display by containment when the caller doesn't know it. -/// Falls back to a plain logical position when no display contains the point. -pub fn logical_point_position(pos_x: f64, pos_y: f64) -> tauri::Position { - #[cfg(windows)] - if let Some(display) = display_containing_logical(pos_x, pos_y) { - return CursorMonitorInfo::from_display(&display).position(pos_x, pos_y); - } - - tauri::Position::Logical(tauri::LogicalPosition::new(pos_x, pos_y)) -} - -fn center_camera_window(app: &AppHandle, window: &WebviewWindow) { - let camera_state = match app.try_state::>() { - Some(state) => state - .try_read() - .ok() - .and_then(|guard| guard.camera_preview.get_state().ok()) - .unwrap_or_default(), - None => crate::camera::CameraPreviewState::default(), - }; - - let toolbar_height = 56.0; - let size = camera_state.size as f64; - let is_full = camera_state.shape == crate::camera::CameraPreviewShape::Full; - let aspect_ratio = crate::camera::WIDE_CAMERA_ASPECT_RATIO as f64; - - let window_width = if is_full { size * aspect_ratio } else { size }; - let window_height = size + toolbar_height; - - let monitor_info = CursorMonitorInfo::get(); - let (pos_x, pos_y) = monitor_info.center_position(window_width, window_height); - - let _ = window.set_size(tauri::LogicalSize::new(window_width, window_height)); - if let Some(guard) = app.try_state::() { - guard.ignore_for(1000); - } - let _ = window.set_position(monitor_info.position(pos_x, pos_y)); - - if let Some(state) = app.try_state::>() - && let Ok(guard) = state.try_read() - { - guard - .camera_preview - .notify_window_resized(window_width as u32, window_height as u32); - } -} - -fn is_position_on_display(display_id: &DisplayId, pos_x: f64, pos_y: f64) -> bool { - Display::from_id(display_id) - .and_then(|display| display.raw_handle().logical_bounds()) - .map(|bounds| { - let (x, y, width, height) = ( - bounds.position().x(), - bounds.position().y(), - bounds.size().width(), - bounds.size().height(), - ); - - pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height - }) - .unwrap_or(false) -} - -fn display_name_for_position(pos_x: f64, pos_y: f64) -> Option { - Display::list().into_iter().find_map(|display| { - let bounds = display.raw_handle().logical_bounds()?; - let (x, y, width, height) = ( - bounds.position().x(), - bounds.position().y(), - bounds.size().width(), - bounds.size().height(), - ); - - if pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height { - display.name().filter(|name| !name.trim().is_empty()) - } else { - None - } - }) -} - -fn is_position_on_monitor_name(monitor_name: &str, pos_x: f64, pos_y: f64) -> bool { - Display::list().into_iter().any(|display| { - if display.name().as_deref() != Some(monitor_name) { - return false; - } - - display - .raw_handle() - .logical_bounds() - .map(|bounds| { - let (x, y, width, height) = ( - bounds.position().x(), - bounds.position().y(), - bounds.size().width(), - bounds.size().height(), - ); - - pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height - }) - .unwrap_or(false) - }) -} - -fn is_position_on_any_screen(pos_x: f64, pos_y: f64) -> bool { - for display in Display::list() { - if let Some(bounds) = display.raw_handle().logical_bounds() { - let (x, y, width, height) = ( - bounds.position().x(), - bounds.position().y(), - bounds.size().width(), - bounds.size().height(), - ); - - if pos_x >= x && pos_x < x + width && pos_y >= y && pos_y < y + height { - return true; - } - } - } - false -} - -// Recovers a window that ended up entirely off every connected display (e.g. the -// monitor it was on got disconnected), which otherwise leaves it open but unreachable. -fn recenter_window_if_offscreen(window: &WebviewWindow) { - let Ok(position) = window.outer_position() else { - return; - }; - let Ok(size) = window.outer_size() else { - return; - }; - let scale = window.scale_factor().unwrap_or(1.0); - - let on_screen = Display::list() - .iter() - .any(|display| display.intersects(position, size, scale)); - if on_screen { - return; - } - - let monitor = CursorMonitorInfo::get(); - let (pos_x, pos_y) = - monitor.center_position(size.width as f64 / scale, size.height as f64 / scale); - let _ = window.set_position(monitor.position(pos_x, pos_y)); -} - -fn ensure_settings_window_bounds(window: &WebviewWindow) { - const MIN_W: f64 = 780.0; - const MIN_H: f64 = 560.0; - let _ = window.set_min_size(Some(LogicalSize::new(MIN_W, MIN_H))); - if let (Ok(physical), Ok(scale)) = (window.inner_size(), window.scale_factor()) { - let width = physical.width as f64 / scale; - let height = physical.height as f64 / scale; - if width < MIN_W || height < MIN_H { - let _ = window.set_size(LogicalSize::new(width.max(MIN_W), height.max(MIN_H))); - } - } -} - -#[derive(Clone, Deserialize, Type)] -pub enum CapWindowId { - Main, - Settings, - Editor { id: u32 }, - RecordingsOverlay, - WindowCaptureOccluder { screen_id: DisplayId }, - TargetSelectOverlay { display_id: DisplayId }, - CaptureArea, - Camera, - RecordingControls, - Upgrade, - ModeSelect, - Debug, - ScreenshotEditor { id: u32 }, - Onboarding, - Teleprompter, -} - -impl FromStr for CapWindowId { - type Err = String; - - fn from_str(s: &str) -> Result { - Ok(match s { - "main" => Self::Main, - "settings" => Self::Settings, - s if is_camera_window_label(s) => Self::Camera, - "capture-area" => Self::CaptureArea, - // legacy identifier - "in-progress-recording" => Self::RecordingControls, - "recordings-overlay" => Self::RecordingsOverlay, - "upgrade" => Self::Upgrade, - "mode-select" => Self::ModeSelect, - "debug" => Self::Debug, - "onboarding" => Self::Onboarding, - "teleprompter" => Self::Teleprompter, - s if s.starts_with("editor-") => Self::Editor { - id: s - .replace("editor-", "") - .parse::() - .map_err(|e| e.to_string())?, - }, - s if s.starts_with("screenshot-editor-") => Self::ScreenshotEditor { - id: s - .replace("screenshot-editor-", "") - .parse::() - .map_err(|e| e.to_string())?, - }, - s if s.starts_with("window-capture-occluder-") => Self::WindowCaptureOccluder { - screen_id: s - .replace("window-capture-occluder-", "") - .parse::() - .map_err(|e| e.to_string())?, - }, - s if s.starts_with("target-select-overlay-") => Self::TargetSelectOverlay { - display_id: s - .replace("target-select-overlay-", "") - .parse::() - .map_err(|e| e.to_string())?, - }, - _ => return Err(format!("unknown window label: {s}")), - }) - } -} - -impl std::fmt::Display for CapWindowId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Main => write!(f, "main"), - Self::Settings => write!(f, "settings"), - Self::Camera => write!(f, "camera"), - Self::WindowCaptureOccluder { screen_id } => { - write!(f, "window-capture-occluder-{screen_id}") - } - Self::CaptureArea => write!(f, "capture-area"), - Self::TargetSelectOverlay { display_id } => { - write!(f, "target-select-overlay-{display_id}") - } - Self::RecordingControls => write!(f, "in-progress-recording"), // legacy identifier - Self::RecordingsOverlay => write!(f, "recordings-overlay"), - Self::Upgrade => write!(f, "upgrade"), - Self::ModeSelect => write!(f, "mode-select"), - Self::Editor { id } => write!(f, "editor-{id}"), - Self::Debug => write!(f, "debug"), - Self::ScreenshotEditor { id } => write!(f, "screenshot-editor-{id}"), - Self::Onboarding => write!(f, "onboarding"), - Self::Teleprompter => write!(f, "teleprompter"), - } - } -} - -impl CapWindowId { - pub fn label(&self) -> String { - self.to_string() - } - - pub fn title(&self) -> String { - match self { - Self::Settings => "Cap Settings".to_string(), - Self::WindowCaptureOccluder { .. } => "Cap Window Capture Occluder".to_string(), - Self::CaptureArea => "Cap Capture Area".to_string(), - Self::RecordingControls => "Cap Recording Controls".to_string(), - Self::Editor { .. } => "Cap Editor".to_string(), - Self::ScreenshotEditor { .. } => "Cap Screenshot Editor".to_string(), - Self::ModeSelect => "Cap Mode Selection".to_string(), - Self::Onboarding => "Welcome to Cap".to_string(), - Self::Camera => "Cap Camera".to_string(), - Self::RecordingsOverlay => "Cap Recordings Overlay".to_string(), - Self::TargetSelectOverlay { .. } => "Cap Target Select".to_string(), - Self::Teleprompter => "Cap Teleprompter".to_string(), - _ => "Cap".to_string(), - } - } - - #[cfg(target_os = "macos")] - pub fn activates_dock(&self) -> bool { - matches!( - self, - Self::Main - | Self::Editor { .. } - | Self::ScreenshotEditor { .. } - | Self::Settings - | Self::Upgrade - | Self::ModeSelect - | Self::Onboarding - ) - } - - pub fn is_transparent(&self) -> bool { - if matches!(self, Self::Settings) { - return cfg!(target_os = "macos"); - } - - matches!( - self, - Self::Main - | Self::Onboarding - | Self::Camera - | Self::WindowCaptureOccluder { .. } - | Self::CaptureArea - | Self::RecordingControls - | Self::RecordingsOverlay - | Self::TargetSelectOverlay { .. } - ) - } - - pub fn get(&self, app: &AppHandle) -> Option { - if matches!(self, Self::Camera) { - return current_camera_window(app); - } - - let label = self.label(); - app.get_webview_window(&label) - } - - #[cfg(target_os = "macos")] - pub fn traffic_lights_position(&self) -> Option>> { - match self { - Self::Editor { .. } | Self::ScreenshotEditor { .. } => { - Some(Some(LogicalPosition::new(20.0, 32.0))) - } - Self::Camera - | Self::Main - | Self::Onboarding - | Self::WindowCaptureOccluder { .. } - | Self::CaptureArea - | Self::RecordingsOverlay - | Self::RecordingControls - | Self::TargetSelectOverlay { .. } => None, - Self::Settings => Some(Some(LogicalPosition::new(22.0, 22.0))), - Self::Teleprompter => Some(Some(LogicalPosition::new(14.0, 14.0))), - _ => Some(None), - } - } - - pub fn min_size(&self) -> Option<(f64, f64)> { - Some(match self { - Self::Main => (330.0, 395.0), - Self::Editor { .. } => (1275.0, 800.0), - Self::ScreenshotEditor { .. } => (800.0, 600.0), - Self::Settings => (780.0, 560.0), - Self::Camera => (200.0, 200.0), - Self::Upgrade => (950.0, 850.0), - Self::ModeSelect => (580.0, 340.0), - Self::Onboarding => (860.0, 690.0), - _ => return None, - }) - } -} - -#[derive(Debug, Clone, Type, Deserialize)] -pub enum ShowCapWindow { - Main { - init_target_mode: Option, - }, - Settings { - page: Option, - }, - Editor { - project_path: PathBuf, - }, - RecordingsOverlay, - WindowCaptureOccluder { - screen_id: DisplayId, - }, - TargetSelectOverlay { - display_id: DisplayId, - target_mode: Option, - }, - CaptureArea { - screen_id: DisplayId, - }, - Camera { - centered: bool, - }, - InProgressRecording { - countdown: Option, - #[serde(default)] - capture_target: Option, - }, - Upgrade, - ModeSelect, - ScreenshotEditor { - path: PathBuf, - }, - Onboarding, -} - -impl ShowCapWindow { - pub async fn show(&self, app: &AppHandle) -> tauri::Result { - if let Self::Editor { project_path } = &self { - let state = app.state::(); - let window_id = { - let mut s = state.ids.lock().unwrap(); - if !s.iter().any(|(path, _)| path == project_path) { - let id = state - .counter - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - s.push((project_path.clone(), id)); - id - } else { - s.iter().find(|(path, _)| path == project_path).unwrap().1 - } - }; - - let window_label = CapWindowId::Editor { id: window_id }.label(); - PendingEditorInstances::start_prewarm(app, window_label, project_path.clone()).await; - } - - if let Self::ScreenshotEditor { path } = &self { - let state = app.state::(); - { - let mut s = state.ids.lock().unwrap(); - if !s.iter().any(|(p, _)| p == path) { - let id = state - .counter - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - s.push((path.clone(), id)); - } - } - } - - let camera_window_label = if matches!(self, Self::Camera { .. }) { - Some(camera_window_label_for_session(bump_camera_window_session( - app, - ))) - } else { - None - }; - - if let Self::Camera { centered } = self { - #[cfg(target_os = "macos")] - { - let panel_manager = app.state::(); - let mut panel_state = panel_manager.get_state(PanelWindowType::Camera).await; - - if panel_state == PanelState::Destroying { - debug!("Camera window is being destroyed, waiting..."); - let wait_result = panel_manager - .wait_for_state( - PanelWindowType::Camera, - &[PanelState::None], - std::time::Duration::from_millis(500), - ) - .await; - - if !wait_result { - warn!("Camera destroy wait timed out, force resetting state"); - panel_manager.force_reset(PanelWindowType::Camera).await; - } - panel_state = panel_manager.get_state(PanelWindowType::Camera).await; - } - - if panel_state == PanelState::Creating { - debug!("Camera window is being created, waiting..."); - panel_manager - .wait_for_state( - PanelWindowType::Camera, - &[PanelState::Ready], - std::time::Duration::from_millis(500), - ) - .await; - } - } - - if let Some(window) = self.id(app).get(app) { - #[cfg(target_os = "macos")] - { - use crate::panel_manager::is_window_handle_valid; - - let handle_valid = is_window_handle_valid(&window); - - if !handle_valid { - warn!( - "Camera window exists but handle is invalid, destroying and recreating..." - ); - let cleanup_success = - cleanup_camera_window(app, Some(&window), true, true).await; - if !cleanup_success { - warn!( - "Camera window still in registry after cleanup attempts, will retry later" - ); - return Err(tauri::Error::WindowNotFound); - } - debug!("Camera window successfully removed from registry"); - } else { - let panel_manager = app.state::(); - let mut panel_state = - panel_manager.get_state(PanelWindowType::Camera).await; - - if panel_state == PanelState::Creating { - debug!( - "Camera window valid but state is Creating, waiting for completion" - ); - panel_manager - .wait_for_state( - PanelWindowType::Camera, - &[PanelState::Ready, PanelState::None], - std::time::Duration::from_millis(1000), - ) - .await; - panel_state = panel_manager.get_state(PanelWindowType::Camera).await; - } - - if panel_state != PanelState::Ready { - debug!( - "Camera window exists but panel state is {:?}, updating to Ready", - panel_state - ); - panel_manager.force_reset(PanelWindowType::Camera).await; - panel_manager.mark_ready(PanelWindowType::Camera, 0).await; - } - - let Some(state) = app.try_state::>() else { - warn!("App state unavailable while showing camera window"); - return Err(tauri::Error::WindowNotFound); - }; - let mut app_state = state.write().await; - - let enable_native_camera_preview = - GeneralSettingsStore::native_camera_preview_enabled(app); - - let shutdown_preview = if !enable_native_camera_preview { - app_state.camera_preview.begin_shutdown() - } else { - None - }; - - ensure_camera_input_active(&mut app_state).await; - - if enable_native_camera_preview - && let Err(err) = - init_native_camera_preview(&mut app_state, window.clone()).await - { - error!( - "Error reinitializing camera preview for existing window: {err}" - ); - } - - drop(app_state); - - if let Some(rx) = shutdown_preview { - let _ = tokio::time::timeout(Duration::from_millis(500), rx).await; - } - - let (show_tx, show_rx) = tokio::sync::oneshot::channel(); - app.run_on_main_thread({ - let window = window.clone(); - move || { - use crate::panel_manager::try_to_panel; - - // IMPORTANT: We intentionally use window.show() + set_focus() here - // instead of panel.order_front_regardless(). - // - // order_front_regardless() was found to cause a crash after ~4-5 - // camera toggle cycles due to macOS internal state accumulation. - // The crash manifested as a hard crash in the Metal/CAMetalLayer - // subsystem, not in our Rust code. - // - // Using standard Tauri window APIs avoids this macOS-specific issue - // while still properly showing and focusing the camera preview window. - let _ = window.show(); - let _ = window.set_focus(); - let _ = show_tx.send(true); - } - }) - .ok(); - - let show_result = show_rx.await.unwrap_or(false); - - if show_result { - if *centered { - center_camera_window(app, &window); - } - return Ok(window); - } else { - warn!("Camera panel show failed, will recreate window"); - let cleanup_success = - cleanup_camera_window(app, Some(&window), true, true).await; - if !cleanup_success { - warn!( - "Camera window still in registry after show failure, will retry later" - ); - return Err(tauri::Error::WindowNotFound); - } - debug!("Camera window successfully removed after show failure"); - } - } - } - - #[cfg(not(target_os = "macos"))] - { - let Some(state) = app.try_state::>() else { - warn!("App state unavailable while showing camera window"); - return Err(tauri::Error::WindowNotFound); - }; - let mut app_state = state.write().await; - - let enable_native_camera_preview = - GeneralSettingsStore::native_camera_preview_enabled(app); - - let shutdown_preview = if !enable_native_camera_preview { - app_state.camera_preview.begin_shutdown() - } else { - None - }; - - ensure_camera_input_active(&mut app_state).await; - - if enable_native_camera_preview - && let Err(err) = - init_native_camera_preview(&mut app_state, window.clone()).await - { - error!("Error reinitializing camera preview for existing window: {err}"); - } - - drop(app_state); - - if let Some(rx) = shutdown_preview { - let _ = tokio::time::timeout(Duration::from_millis(500), rx).await; - } - - if *centered { - center_camera_window(app, &window); - } - window.show().ok(); - window.set_focus().ok(); - return Ok(window); - } - } - } - - if matches!(self, Self::Settings { .. }) { - hide_recording_windows(app, true); - release_camera_preview_if_idle(app); - } - - #[cfg(target_os = "macos")] - if let Self::InProgressRecording { capture_target, .. } = self - && let Some(window) = self.id(app).get(app) - { - use crate::panel_manager::is_window_handle_valid; - - if is_window_handle_valid(&window) { - debug!("InProgressRecording: reusing existing window"); - let width = 320.0; - let height = 150.0; - let (pos_x, pos_y) = capture_target - .as_ref() - .and_then(fake_window::calculate_recording_controls_position_for_target) - .unwrap_or_else(|| { - CursorMonitorInfo::get().bottom_center_position(width, height, 120.0) - }); - let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); - - let label = window.label().to_string(); - app.run_on_main_thread({ - let app = app.clone(); - move || { - use tauri_nspanel::ManagerExt; - if let Ok(panel) = app.get_webview_panel(&label) { - panel.order_front_regardless(); - panel.show(); - } - } - }) - .ok(); - fake_window::spawn_fake_window_listener(app.clone(), window.clone()); - return Ok(window); - } else { - warn!("InProgressRecording window handle invalid, destroying and recreating..."); - let _ = window.destroy(); - - let window_id = self.id(app); - let max_wait = std::time::Duration::from_millis(500); - let poll_interval = std::time::Duration::from_millis(25); - let start = std::time::Instant::now(); - while start.elapsed() < max_wait { - if window_id.get(app).is_none() { - debug!( - "InProgressRecording window removed from registry after {:?}", - start.elapsed() - ); - break; - } - tokio::time::sleep(poll_interval).await; - } - - if window_id.get(app).is_some() { - error!("InProgressRecording window STILL in registry, cannot recreate"); - return Err(tauri::Error::WindowNotFound); - } - debug!("InProgressRecording window cleaned up, will recreate"); - } - } - - #[cfg(not(target_os = "macos"))] - if let Self::InProgressRecording { capture_target, .. } = self - && let Some(window) = self.id(app).get(app) - { - let width = 320.0; - let height = 150.0; - let (pos_x, pos_y) = capture_target - .as_ref() - .and_then(fake_window::calculate_recording_controls_position_for_target) - .unwrap_or_else(|| { - CursorMonitorInfo::get().bottom_center_position(width, height, 120.0) - }); - let _ = window.set_position(logical_point_position(pos_x, pos_y)); - window.show().ok(); - window.set_focus().ok(); - fake_window::spawn_fake_window_listener(app.clone(), window.clone()); - return Ok(window); - } - - if !matches!(self, Self::Camera { .. } | Self::InProgressRecording { .. }) - && let Some(window) = self.id(app).get(app) - { - if matches!(self, Self::Main { .. }) && crate::should_show_onboarding(app) { - return Box::pin(Self::Onboarding.show(app)).await; - } - - #[cfg(target_os = "macos")] - if matches!(self, Self::Main { .. }) && !app.state::().is_ready() - { - return Ok(window); - } - - let cursor_display_id = if let Self::Main { init_target_mode } = self { - if init_target_mode.is_some() { - Display::get_containing_cursor() - .map(|d| d.id().to_string()) - .or_else(|| Some(Display::primary().id().to_string())) - } else { - None - } - } else { - None - }; - - if let Self::Main { - init_target_mode: Some(target_mode), - } = self - { - window.hide().ok(); - emit_app_event( - app, - RequestSetTargetMode { - target_mode: Some(*target_mode), - display_id: cursor_display_id, - }, - ); - } else { - let should_restore_main_window_inputs = matches!(self, Self::Main { .. }); - - if let Self::Onboarding = self { - let _ = window.set_ignore_cursor_events(false); - } - - if matches!(self, Self::Main { .. } | Self::Settings { .. }) { - recenter_window_if_offscreen(&window); - } - - window.show().ok(); - window.unminimize().ok(); - window.set_focus().ok(); - - if let Self::Settings { .. } = self { - ensure_settings_window_bounds(&window); - } - - if let Self::Main { init_target_mode } = self { - emit_app_event( - app, - RequestSetTargetMode { - target_mode: *init_target_mode, - display_id: cursor_display_id, - }, - ); - } - - if should_restore_main_window_inputs { - let app = app.clone(); - tokio::spawn(async move { - restore_main_window_inputs(&app).await; - }); - } - } - - #[cfg(target_os = "macos")] - if self.id(app).activates_dock() { - crate::permissions::sync_macos_dock_visibility(app); - } - - return Ok(window); - } - - let _id = self.id(app); - let cursor_monitor = CursorMonitorInfo::get(); - - let window = match self { - Self::Main { init_target_mode } => { - if !permissions::do_permissions_check(false).necessary_granted() { - return Box::pin(Self::Onboarding.show(app)).await; - } - - let title = CapWindowId::Main.title(); - let should_protect = should_protect_window(app, &title); - - #[cfg(target_os = "macos")] - let panel_activation_guard = permissions::prepare_macos_panel_window(app); - - let window = self - .window_builder(app, "/") - .resizable(false) - .maximized(false) - .maximizable(false) - .minimizable(false) - .always_on_top(true) - .visible_on_all_workspaces(true) - .content_protected(should_protect) - .transparent(true) - .visible(false) - .initialization_script(format!( - " - window.__CAP__ = window.__CAP__ ?? {{}}; - window.__CAP__.initialTargetMode = {} - ", - serde_json::to_string(init_target_mode) - .expect("Failed to serialize initial target mode") - )) - .build()?; - lock_window_text_scale(&window); - - let saved_position = GeneralSettingsStore::get(app) - .ok() - .flatten() - .and_then(|s| s.main_window_position) - .filter(|pos| is_position_on_any_screen(pos.x, pos.y)); - - let main_position = if let Some(pos) = saved_position { - match display_for_saved_position(pos.x, pos.y, pos.display_id.as_ref()) { - Some(display) => { - CursorMonitorInfo::from_display(&display).position(pos.x, pos.y) - } - None => tauri::Position::Logical(tauri::LogicalPosition::new(pos.x, pos.y)), - } - } else { - let (pos_x, pos_y) = cursor_monitor.center_position(330.0, 395.0); - cursor_monitor.position(pos_x, pos_y) - }; - - #[cfg(target_os = "macos")] - { - app.run_on_main_thread({ - let window = window.clone(); - let app = app.clone(); - let panel_activation_guard = panel_activation_guard; - move || { - let _panel_activation_guard = panel_activation_guard; - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; - use tauri_nspanel::panel_delegate; - use crate::panel_manager::try_to_panel; - - let delegate = panel_delegate!(MainPanelDelegate { - window_did_become_key, - window_did_resign_key - }); - - delegate.set_listener(Box::new(|_delegate_name: String| {})); - - let panel = match try_to_panel(&window) { - Ok(p) => p, - Err(e) => { - tracing::error!("Failed to convert main window to panel: {}", e); - crate::permissions::sync_macos_dock_visibility(&app); - return; - } - }; - - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenPrimary, - ); - - panel.set_delegate(delegate); - - panel.set_level(MAIN_PANEL_LEVEL); - - let _ = window.set_position(main_position); - - crate::platform::apply_squircle_corners(&window, 16.0); - - crate::permissions::schedule_macos_dock_visibility_sync(&app); - } - }) - .ok(); - } - - #[cfg(not(target_os = "macos"))] - { - let _ = window.set_position(main_position); - - #[cfg(windows)] - { - if let Err(e) = window.set_size(LogicalSize::new(330.0, 395.0)) { - warn!("Failed to set Main window size on Windows: {}", e); - } - if let Err(e) = window.set_position(main_position) { - warn!("Failed to position Main window on Windows: {}", e); - } - } - - window.show().ok(); - } - - window - } - Self::TargetSelectOverlay { - display_id, - target_mode, - } => { - let Some(display) = scap_targets::Display::from_id(display_id) else { - return Err(tauri::Error::WindowNotFound); - }; - let is_hovered_display = scap_targets::Display::get_containing_cursor() - .map(|d| d.id()) - == Some(display.id()); - - let title = CapWindowId::TargetSelectOverlay { - display_id: display_id.clone(), - } - .title(); - let should_protect = should_protect_window(app, &title); - - let target_mode_param = match target_mode { - Some(RecordingTargetMode::Display) => "&targetMode=display", - Some(RecordingTargetMode::Window) => "&targetMode=window", - Some(RecordingTargetMode::Area) => "&targetMode=area", - Some(RecordingTargetMode::Camera) => "&targetMode=camera", - None => "", - }; - - let camera_ws_port = { - let Some(state) = app.try_state::>() else { - warn!("App state unavailable during target select overlay creation"); - return Err(tauri::Error::WindowNotFound); - }; - let state = state.read().await; - state.camera_ws_port - }; - - #[cfg(target_os = "macos")] - let panel_activation_guard = permissions::prepare_macos_panel_window(app); - - let mut window_builder = self - .window_builder( - app, - format!("/target-select-overlay?displayId={display_id}&isHoveredDisplay={is_hovered_display}{target_mode_param}"), - ) - .maximized(false) - .resizable(false) - .fullscreen(false) - .shadow(false) - .content_protected(should_protect) - .always_on_top(true) - .visible_on_all_workspaces(true) - .skip_taskbar(true) - .transparent(true) - .visible(false) - .initialization_script(format!( - "window.__CAP__ = window.__CAP__ ?? {{}}; window.__CAP__.cameraWsPort = {camera_ws_port};" - )); - - #[cfg(target_os = "macos")] - { - let position = display.raw_handle().logical_position(); - let size = display.logical_size().unwrap(); - - window_builder = window_builder - .inner_size(size.width(), size.height()) - .position(position.x(), position.y()); - } - - #[cfg(windows)] - { - window_builder = window_builder.inner_size(100.0, 100.0).position(0.0, 0.0); - } - - #[cfg(target_os = "linux")] - { - let position = display.raw_handle().physical_position().unwrap(); - let size = display.physical_size().unwrap(); - window_builder = window_builder - .inner_size(size.width(), size.height()) - .position(position.x(), position.y()); - } - - let window = window_builder.build()?; - lock_window_text_scale(&window); - - #[cfg(target_os = "linux")] - { - use tauri::{LogicalSize, PhysicalPosition}; - let position = display.raw_handle().physical_position().unwrap(); - let size = display.physical_size().unwrap(); - let _ = window.set_position(PhysicalPosition::new(position.x(), position.y())); - let _ = window.set_size(LogicalSize::new(size.width(), size.height())); - } - - #[cfg(windows)] - { - let Some(position) = display.raw_handle().physical_position() else { - warn!(display_id = %display_id, "Missing display position for target select overlay"); - return Err(tauri::Error::WindowNotFound); - }; - let Some(logical_size) = display.logical_size() else { - warn!(display_id = %display_id, "Missing display logical size for target select overlay"); - return Err(tauri::Error::WindowNotFound); - }; - let Some(physical_size) = display.physical_size() else { - warn!(display_id = %display_id, "Missing display physical size for target select overlay"); - return Err(tauri::Error::WindowNotFound); - }; - use tauri::{LogicalSize, PhysicalPosition, PhysicalSize}; - let _ = window.set_size(LogicalSize::new( - logical_size.width(), - logical_size.height(), - )); - let _ = window.set_position(PhysicalPosition::new(position.x(), position.y())); - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - - match window.inner_size() { - Ok(actual_physical_size) - if physical_size.width() != actual_physical_size.width as f64 => - { - let _ = window.set_size(LogicalSize::new( - logical_size.width(), - logical_size.height(), - )); - } - Ok(_) => {} - Err(err) => { - warn!(%err, "Failed to read target select overlay inner size"); - } - } - } - - app.state::() - .spawn(display_id, window.clone()); - - #[cfg(target_os = "macos")] - { - app.run_on_main_thread({ - let window = window.clone(); - let app = app.clone(); - let panel_activation_guard = panel_activation_guard; - move || { - let _panel_activation_guard = panel_activation_guard; - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; - use tauri_nspanel::panel_delegate; - use tauri_nspanel::WebviewWindowExt as NSPanelWebviewWindowExt; - - #[link(name = "CoreGraphics", kind = "framework")] - unsafe extern "C" { - fn CGWindowLevelForKey(key: i32) -> i32; - } - - #[allow(non_upper_case_globals)] - const kCGMaximumWindowLevelKey: i32 = 10; - - let delegate = panel_delegate!(TargetSelectOverlayPanelDelegate { - window_did_become_key, - window_did_resign_key - }); - - delegate.set_listener(Box::new(|_delegate_name: String| {})); - - let panel = match window.to_panel() { - Ok(p) => p, - Err(e) => { - tracing::error!("Failed to convert target select overlay to panel: {:?}", e); - crate::permissions::sync_macos_dock_visibility(&app); - return; - } - }; - - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenPrimary, - ); - - panel.set_delegate(delegate); - - #[allow(non_upper_case_globals)] - const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7; - panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel); - - let max_level = unsafe { CGWindowLevelForKey(kCGMaximumWindowLevelKey) }; - panel.set_level(max_level - 1); - - panel.order_front_regardless(); - panel.show(); - - crate::permissions::schedule_macos_dock_visibility_sync(&app); - } - }) - .ok(); - } - - #[cfg(not(target_os = "macos"))] - { - window.show().ok(); - } - - window - } - Self::Settings { page } => { - let mut builder = self - .window_builder( - app, - format!("/settings/{}", page.clone().unwrap_or_default()), - ) - .inner_size(782.0, 775.0) - .min_inner_size(780.0, 560.0) - .resizable(true) - .maximized(false) - .focused(true); - - #[cfg(target_os = "macos")] - { - builder = builder.transparent(true); - } - - let window = builder.build()?; - lock_window_text_scale(&window); - - let (pos_x, pos_y) = cursor_monitor.center_position(782.0, 775.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - if let Err(e) = window.set_size(LogicalSize::new(782.0, 775.0)) { - warn!("Failed to set Settings window size on Windows: {}", e); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!("Failed to position Settings window on Windows: {}", e); - } - } - - ensure_settings_window_bounds(&window); - - window - } - Self::Editor { .. } => { - let open_started = std::time::Instant::now(); - hide_recording_windows(app, false); - release_camera_preview_if_idle(app); - - let window = match self - .window_builder(app, "/editor") - .maximizable(true) - .inner_size(1275.0, 800.0) - .min_inner_size(1275.0, 800.0) - .focused(true) - .build() - { - Ok(window) => window, - Err(error) => { - // Don't leave the prewarmed instance (decoders, frame - // websocket) orphaned if the window failed to appear. - let window_label = self.id(app).label(); - PendingEditorInstances::get(app) - .cancel_prewarm(&window_label) - .await; - return Err(error); - } - }; - lock_window_text_scale(&window); - - let (pos_x, pos_y) = cursor_monitor.center_position(1275.0, 800.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(1275.0, 800.0)) { - warn!("Failed to set Editor window size on Windows: {}", e); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!("Failed to position Editor window on Windows: {}", e); - } - } - - // Show immediately: the native background color is already - // themed, so the window can appear before the webview loads and - // the editor skeleton takes over. When window transparency is - // enabled we keep the old behaviour (the frontend reveals the - // window after applying the HudWindow effects) to avoid an - // opaque-to-transparent pop. - let transparency_enabled = GeneralSettingsStore::get(app) - .ok() - .flatten() - .map(|s| s.window_transparency) - .unwrap_or(false); - if !transparency_enabled { - window.show().ok(); - window.set_focus().ok(); - } - - info!( - window_built_and_shown_ms = open_started.elapsed().as_millis() as u64, - shown_from_rust = !transparency_enabled, - "Editor open: window ready" - ); - - window - } - Self::ScreenshotEditor { path } => { - hide_recording_windows(app, false); - release_camera_preview_if_idle(app); - - let window_label = self.id(app).label(); - let pending = PendingScreenshotEditorInstances::get(app); - PendingScreenshotEditorInstances::start_prewarm( - app, - window_label.clone(), - path.clone(), - ) - .await; - - let window = match self - .window_builder(app, "/screenshot-editor") - .maximizable(true) - .inner_size(1240.0, 800.0) - .min_inner_size(800.0, 600.0) - .focused(true) - .build() - { - Ok(window) => window, - Err(error) => { - pending.cancel_prewarm(&window_label).await; - return Err(error); - } - }; - lock_window_text_scale(&window); - - let (pos_x, pos_y) = cursor_monitor.center_position(1240.0, 800.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(1240.0, 800.0)) { - warn!( - "Failed to set ScreenshotEditor window size on Windows: {}", - e - ); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!( - "Failed to position ScreenshotEditor window on Windows: {}", - e - ); - } - } - - window.show().ok(); - window.set_focus().ok(); - - window - } - Self::Upgrade => { - if let Some(main) = CapWindowId::Main.get(app) { - let _ = main.hide(); - } - - let window = self - .window_builder(app, "/upgrade") - .inner_size(950.0, 850.0) - .min_inner_size(950.0, 850.0) - .resizable(false) - .focused(true) - .always_on_top(true) - .maximized(false) - .shadow(true) - .build()?; - lock_window_text_scale(&window); - - let (pos_x, pos_y) = cursor_monitor.center_position(950.0, 850.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(950.0, 850.0)) { - warn!("Failed to set Upgrade window size on Windows: {}", e); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!("Failed to position Upgrade window on Windows: {}", e); - } - } - - window.show().ok(); - window.set_focus().ok(); - - window - } - Self::ModeSelect => { - if let Some(main) = CapWindowId::Main.get(app) { - let _ = main.hide(); - } - - let window = self - .window_builder(app, "/mode-select") - .inner_size(580.0, 340.0) - .min_inner_size(580.0, 340.0) - .resizable(false) - .maximized(false) - .maximizable(false) - .focused(true) - .shadow(true) - .build()?; - lock_window_text_scale(&window); - - let (pos_x, pos_y) = cursor_monitor.center_position(580.0, 340.0); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(580.0, 340.0)) { - warn!("Failed to set ModeSelect window size on Windows: {}", e); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!("Failed to position ModeSelect window on Windows: {}", e); - } - } - - window.show().ok(); - window.set_focus().ok(); - - window - } - Self::Onboarding => { - if let Some(main) = CapWindowId::Main.get(app) { - let _ = main.hide(); - } - - let width = (cursor_monitor.width * 0.58).clamp(860.0, 1080.0); - let height = (width * 0.72).clamp(690.0, 780.0); - - let window = self - .window_builder(app, "/onboarding") - .inner_size(width, height) - .min_inner_size(860.0, 690.0) - .resizable(false) - .maximized(false) - .maximizable(false) - .transparent(true) - .focused(true) - .shadow(true) - .build()?; - lock_window_text_scale(&window); - - let (pos_x, pos_y) = cursor_monitor.center_position(width, height); - let _ = window.set_position(cursor_monitor.position(pos_x, pos_y)); - let _ = window.set_ignore_cursor_events(false); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(width, height)) { - warn!("Failed to set Onboarding window size on Windows: {}", e); - } - if let Err(e) = window.set_position(cursor_monitor.position(pos_x, pos_y)) { - warn!("Failed to position Onboarding window on Windows: {}", e); - } - } - - window.show().ok(); - window.set_focus().ok(); - - window - } - Self::Camera { centered } => { - const DEFAULT_WINDOW_SIZE: f64 = 230.0 * 2.0; - const CENTERED_WINDOW_SIZE: f64 = 400.0; - - #[cfg(target_os = "macos")] - let create_guard = { - let panel_manager = app.state::(); - panel_manager - .try_begin_create(PanelWindowType::Camera) - .await - }; - - #[cfg(target_os = "macos")] - let Some(mut create_guard) = create_guard else { - let panel_manager = app.state::(); - let state = panel_manager.get_state(PanelWindowType::Camera).await; - warn!("Camera window creation blocked, current state: {:?}", state); - if state == PanelState::Ready - && let Some(window) = CapWindowId::Camera.get(app) - { - if *centered { - center_camera_window(app, &window); - } - return Ok(window); - } - panel_manager - .wait_for_state( - PanelWindowType::Camera, - &[PanelState::Ready, PanelState::None], - std::time::Duration::from_millis(500), - ) - .await; - if let Some(window) = CapWindowId::Camera.get(app) { - if *centered { - center_camera_window(app, &window); - } - return Ok(window); - } - return Err(tauri::Error::WindowNotFound); - }; - - let enable_native_camera_preview = - GeneralSettingsStore::native_camera_preview_enabled(app); - - { - let Some(state) = app.try_state::>() else { - warn!("App state unavailable while creating camera window"); - return Err(tauri::Error::WindowNotFound); - }; - let mut state = state.write().await; - - let shutdown_preview = - if !enable_native_camera_preview && state.camera_preview.is_initialized() { - state.camera_preview.begin_shutdown() - } else { - None - }; - - if enable_native_camera_preview && state.camera_preview.is_initialized() { - warn!("Detected existing camera preview, will reuse it"); - } - - // Camera protection is applied per recording mode in `start_recording`; - // protecting at creation hides the live preview on virtual/mirrored displays. - let should_protect = false; - - #[cfg(target_os = "macos")] - let panel_activation_guard = permissions::prepare_macos_panel_window(app); - - let label = camera_window_label - .clone() - .unwrap_or_else(|| CapWindowId::Camera.label()); - let mut window_builder = self - .window_builder_with_label(app, "/camera", label) - .maximized(false) - .resizable(false) - .shadow(false) - .fullscreen(false) - .always_on_top(true) - .visible_on_all_workspaces(true) - .skip_taskbar(true) - .initialization_script(format!( - " - window.__CAP__ = window.__CAP__ ?? {{}}; - window.__CAP__.cameraWsPort = {}; - window.__CAP__.cameraOnlyMode = {}; - window.__CAP__.enableNativeCameraPreview = {}; - ", - state.camera_ws_port, centered, enable_native_camera_preview - )) - .content_protected(should_protect) - .transparent(true) - .visible(false); - - let window = match window_builder.build() { - Ok(w) => w, - Err(e) => { - let is_label_exists = e.to_string().contains("already exists"); - if is_label_exists { - warn!( - "Camera webview label already exists, cleaning up for next attempt" - ); - cleanup_camera_window(app, None, false, false).await; - } - - #[cfg(target_os = "macos")] - { - let panel_manager = app.state::(); - panel_manager.force_reset(PanelWindowType::Camera).await; - } - return Err(e); - } - }; - lock_window_text_scale(&window); - - #[cfg(target_os = "windows")] - log_window_content_protection( - &window, - should_protect, - &CapWindowId::Camera.title(), - ); - - let camera_monitor = CapWindowId::Main - .get(app) - .map(|w| CursorMonitorInfo::from_window(&w)) - .unwrap_or(cursor_monitor); - - let preferred_monitor_name = display_name_for_position( - camera_monitor.x + camera_monitor.width / 2.0, - camera_monitor.y + camera_monitor.height / 2.0, - ); - - let saved_position = - GeneralSettingsStore::get(app) - .ok() - .flatten() - .and_then(|settings| { - if let Some(monitor_name) = preferred_monitor_name.as_deref() { - settings - .camera_window_positions_by_monitor_name - .get(monitor_name) - .cloned() - .filter(|pos| { - is_position_on_monitor_name(monitor_name, pos.x, pos.y) - }) - .or_else(|| { - settings.camera_window_position.filter(|pos| { - is_position_on_monitor_name( - monitor_name, - pos.x, - pos.y, - ) - }) - }) - } else { - settings.camera_window_position.filter(|pos| { - if let Some(display_id) = &pos.display_id { - is_position_on_display(display_id, pos.x, pos.y) - } else { - is_position_on_any_screen(pos.x, pos.y) - } - }) - } - }); - - let camera_position = if let Some(pos) = saved_position { - match display_for_saved_position(pos.x, pos.y, pos.display_id.as_ref()) { - Some(display) => { - CursorMonitorInfo::from_display(&display).position(pos.x, pos.y) - } - None => { - tauri::Position::Logical(tauri::LogicalPosition::new(pos.x, pos.y)) - } - } - } else if *centered { - let aspect_ratio = crate::camera::WIDE_CAMERA_ASPECT_RATIO as f64; - let toolbar_height = 56.0; - let window_width = CENTERED_WINDOW_SIZE * aspect_ratio; - let window_height = CENTERED_WINDOW_SIZE + toolbar_height; - let (camera_pos_x, camera_pos_y) = - camera_monitor.center_position(window_width, window_height); - camera_monitor.position(camera_pos_x, camera_pos_y) - } else { - let camera_pos_x = - camera_monitor.x + camera_monitor.width - DEFAULT_WINDOW_SIZE - 100.0; - let camera_pos_y = - camera_monitor.y + camera_monitor.height - DEFAULT_WINDOW_SIZE - 100.0; - camera_monitor.position(camera_pos_x, camera_pos_y) - }; - - #[cfg(not(target_os = "macos"))] - { - if let Some(guard) = app.try_state::() { - guard.ignore_for(1000); - } - let _ = window.set_position(camera_position); - } - - ensure_camera_input_active(&mut state).await; - - #[cfg(target_os = "macos")] - { - let panel_manager = app.state::(); - let operation_id = create_guard.operation_id; - - let (panel_tx, panel_rx) = tokio::sync::oneshot::channel(); - app.run_on_main_thread({ - let window = window.clone(); - let app = app.clone(); - let panel_activation_guard = panel_activation_guard; - move || { - let _panel_activation_guard = panel_activation_guard; - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; - use tauri_nspanel::panel_delegate; - use crate::panel_manager::try_to_panel; - - #[link(name = "CoreGraphics", kind = "framework")] - unsafe extern "C" { - fn CGWindowLevelForKey(key: i32) -> i32; - } - - #[allow(non_upper_case_globals)] - const kCGMaximumWindowLevelKey: i32 = 10; - - let delegate = panel_delegate!(CameraPanelDelegate { - window_did_become_key, - window_did_resign_key - }); - - delegate.set_listener(Box::new(|_delegate_name: String| {})); - - let panel = match try_to_panel(&window) { - Ok(p) => p, - Err(e) => { - tracing::error!("Failed to convert camera to panel: {}", e); - crate::permissions::sync_macos_dock_visibility(&app); - let _ = panel_tx.send(false); - return; - } - }; - - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenPrimary, - ); - - panel.set_delegate(delegate); - - let max_level = - unsafe { CGWindowLevelForKey(kCGMaximumWindowLevelKey) }; - panel.set_level(max_level); - - if let Some(guard) = app.try_state::() { - guard.ignore_for(1000); - } - let _ = window.set_position(camera_position); - - panel.order_front_regardless(); - panel.show(); - crate::permissions::schedule_macos_dock_visibility_sync(&app); - let _ = panel_tx.send(true); - } - }) - .ok(); - - if panel_rx.await.unwrap_or(false) { - panel_manager - .mark_ready(PanelWindowType::Camera, operation_id) - .await; - create_guard.mark_completed(); - } else { - warn!("Camera panel creation failed"); - panel_manager.force_reset(PanelWindowType::Camera).await; - } - } - - if enable_native_camera_preview - && let Err(err) = - init_native_camera_preview(&mut state, window.clone()).await - { - error!( - "Error initializing camera preview, falling back to WebSocket preview: {err}" - ); - } - - #[cfg(not(target_os = "macos"))] - { - window.show().ok(); - } - - drop(state); - - if let Some(rx) = shutdown_preview { - let _ = tokio::time::timeout(Duration::from_millis(500), rx).await; - } - - window - } - } - Self::WindowCaptureOccluder { screen_id } => { - let Some(display) = Display::from_id(screen_id) else { - return Err(tauri::Error::WindowNotFound); - }; - - let title = CapWindowId::WindowCaptureOccluder { - screen_id: screen_id.clone(), - } - .title(); - let should_protect = should_protect_window(app, &title); - - let mut window_builder = self - .window_builder(app, "/window-capture-occluder") - .maximized(false) - .resizable(false) - .fullscreen(false) - .shadow(false) - .always_on_top(true) - .visible_on_all_workspaces(true) - .content_protected(should_protect) - .skip_taskbar(true) - .transparent(true); - - #[cfg(target_os = "macos")] - { - let position = display.raw_handle().logical_position(); - let Some(size) = display.logical_size() else { - warn!(screen_id = %screen_id, "Missing display logical size for window capture occluder"); - return Err(tauri::Error::WindowNotFound); - }; - - window_builder = window_builder - .inner_size(size.width(), size.height()) - .position(position.x(), position.y()); - } - - // On Windows a window's DPI scale isn't known until it's placed on a - // monitor, so sizing/positioning from display bounds at build time is - // unreliable across monitors with different DPIs. Build a placeholder - // and fix the geometry up after the window exists (below), mirroring - // the TargetSelectOverlay path. - #[cfg(windows)] - { - window_builder = window_builder.inner_size(100.0, 100.0).position(0.0, 0.0); - } - - #[cfg(target_os = "linux")] - { - let position = display.raw_handle().physical_position().unwrap(); - let Some(size) = display.physical_size() else { - warn!(screen_id = %screen_id, "Missing display size for window capture occluder"); - return Err(tauri::Error::WindowNotFound); - }; - window_builder = window_builder - .inner_size(size.width(), size.height()) - .position(position.x(), position.y()); - } - - let window = window_builder.build()?; - lock_window_text_scale(&window); - - #[cfg(target_os = "linux")] - { - use tauri::{LogicalSize, PhysicalPosition}; - let position = display.raw_handle().physical_position().unwrap(); - if let Some(size) = display.physical_size() { - let _ = - window.set_position(PhysicalPosition::new(position.x(), position.y())); - let _ = window.set_size(LogicalSize::new(size.width(), size.height())); - } - } - - // Fix up the occluder geometry now that the window exists and its real - // per-monitor DPI is known: position with physical coordinates (which - // are unambiguous across monitors), then set the logical size so the - // window covers the full display. Verify the resulting physical size - // matches the display and re-apply once if the initial placement raced - // the DPI change. - #[cfg(windows)] - { - let Some(position) = display.raw_handle().physical_position() else { - warn!(screen_id = %screen_id, "Missing display position for window capture occluder"); - return Err(tauri::Error::WindowNotFound); - }; - let Some(logical_size) = display.logical_size() else { - warn!(screen_id = %screen_id, "Missing display logical size for window capture occluder"); - return Err(tauri::Error::WindowNotFound); - }; - let Some(physical_size) = display.physical_size() else { - warn!(screen_id = %screen_id, "Missing display physical size for window capture occluder"); - return Err(tauri::Error::WindowNotFound); - }; - use tauri::{LogicalSize, PhysicalPosition}; - let _ = window.set_size(LogicalSize::new( - logical_size.width(), - logical_size.height(), - )); - let _ = window.set_position(PhysicalPosition::new(position.x(), position.y())); - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - - match window.inner_size() { - Ok(actual_physical_size) - if physical_size.width() != actual_physical_size.width as f64 => - { - let _ = window.set_size(LogicalSize::new( - logical_size.width(), - logical_size.height(), - )); - } - Ok(_) => {} - Err(err) => { - warn!(%err, "Failed to read window capture occluder inner size"); - } - } - } - - if let Err(err) = window.set_ignore_cursor_events(true) { - warn!(%err, "Failed to ignore cursor events for window capture occluder"); - } - - #[cfg(target_os = "macos")] - { - crate::platform::set_window_level(window.as_ref().window(), 900); - } - - window - } - Self::CaptureArea { screen_id } => { - let title = CapWindowId::CaptureArea.title(); - let should_protect = should_protect_window(app, &title); - - let mut window_builder = self - .window_builder(app, "/capture-area") - .maximized(false) - .fullscreen(false) - .shadow(false) - .resizable(false) - .always_on_top(true) - .content_protected(should_protect) - .skip_taskbar(true) - .closable(true) - .decorations(false) - .transparent(true); - - let Some(display) = Display::from_id(screen_id) else { - return Err(tauri::Error::WindowNotFound); - }; - - #[cfg(target_os = "macos")] - if let Some(bounds) = display.raw_handle().logical_bounds() { - window_builder = window_builder - .inner_size(bounds.size().width(), bounds.size().height()) - .position(bounds.position().x(), bounds.position().y()); - } - - // On Windows a window's DPI scale isn't known until it's placed on a - // monitor, so sizing/positioning from logical bounds at build time is - // unreliable across monitors with different DPIs — the overlay ends up - // sized for the wrong monitor and no longer covers the target display, - // which truncates area selections on HiDPI secondary monitors. Build a - // placeholder and fix the geometry up after the window exists (below), - // mirroring the TargetSelectOverlay path. - #[cfg(windows)] - { - window_builder = window_builder.inner_size(100.0, 100.0).position(0.0, 0.0); - } - - #[cfg(target_os = "linux")] - if let Some(bounds) = display.raw_handle().physical_bounds() { - window_builder = window_builder - .inner_size(bounds.size().width(), bounds.size().height()) - .position(bounds.position().x(), bounds.position().y()); - } - - let window = window_builder.build()?; - lock_window_text_scale(&window); - - // Fix up the overlay geometry now that the window exists and its real - // per-monitor DPI is known: position with physical coordinates (which are - // unambiguous across monitors), then set the logical size so the window - // covers the full display. Verify the resulting physical size matches the - // display and re-apply once if the initial placement raced the DPI change. - #[cfg(windows)] - { - let Some(position) = display.raw_handle().physical_position() else { - warn!(display_id = %screen_id, "Missing display position for capture area overlay"); - return Err(tauri::Error::WindowNotFound); - }; - let Some(logical_size) = display.logical_size() else { - warn!(display_id = %screen_id, "Missing display logical size for capture area overlay"); - return Err(tauri::Error::WindowNotFound); - }; - let Some(physical_size) = display.physical_size() else { - warn!(display_id = %screen_id, "Missing display physical size for capture area overlay"); - return Err(tauri::Error::WindowNotFound); - }; - use tauri::{LogicalSize, PhysicalPosition}; - let _ = window.set_size(LogicalSize::new( - logical_size.width(), - logical_size.height(), - )); - let _ = window.set_position(PhysicalPosition::new(position.x(), position.y())); - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - - match window.inner_size() { - Ok(actual_physical_size) - if physical_size.width() != actual_physical_size.width as f64 => - { - let _ = window.set_size(LogicalSize::new( - logical_size.width(), - logical_size.height(), - )); - } - Ok(_) => {} - Err(err) => { - warn!(%err, "Failed to read capture area overlay inner size"); - } - } - } - - #[cfg(target_os = "linux")] - if let Some(bounds) = display.raw_handle().physical_bounds() { - use tauri::{LogicalSize, PhysicalPosition}; - let _ = window.set_position(PhysicalPosition::new( - bounds.position().x(), - bounds.position().y(), - )); - let _ = window.set_size(LogicalSize::new( - bounds.size().width(), - bounds.size().height(), - )); - } - - #[cfg(target_os = "macos")] - crate::platform::set_window_level( - window.as_ref().window(), - objc2_app_kit::NSPopUpMenuWindowLevel, - ); - - // Hide the main window if the target monitor is the same - if let Some(main_window) = CapWindowId::Main.get(app) - && let (Ok(outer_pos), Ok(outer_size)) = - (main_window.outer_position(), main_window.outer_size()) - && let Ok(scale_factor) = main_window.scale_factor() - && display.intersects(outer_pos, outer_size, scale_factor) - { - let _ = main_window.minimize(); - }; - - window - } - Self::InProgressRecording { - countdown, - capture_target, - } => { - let width = 320.0; - let height = 150.0; - - let title = CapWindowId::RecordingControls.title(); - let should_protect = should_protect_window(app, &title); - - #[cfg(target_os = "macos")] - let panel_activation_guard = permissions::prepare_macos_panel_window(app); - - #[cfg(target_os = "macos")] - let window = { - self.window_builder(app, "/in-progress-recording") - .maximized(false) - .resizable(false) - .fullscreen(false) - .shadow(false) - .always_on_top(true) - .transparent(true) - .visible_on_all_workspaces(true) - .content_protected(should_protect) - .inner_size(width, height) - .skip_taskbar(true) - .visible(false) - .initialization_script(format!( - "window.COUNTDOWN = {};", - countdown.unwrap_or_default() - )) - .build()? - }; - - #[cfg(windows)] - let window = self - .window_builder(app, "/in-progress-recording") - .maximized(false) - .resizable(false) - .fullscreen(false) - .shadow(false) - .always_on_top(true) - .transparent(true) - .visible_on_all_workspaces(true) - .content_protected(should_protect) - .inner_size(width, height) - .skip_taskbar(false) - .initialization_script(format!( - "window.COUNTDOWN = {};", - countdown.unwrap_or_default() - )) - .build()?; - - #[cfg(target_os = "linux")] - let window = self - .window_builder(app, "/in-progress-recording") - .maximized(false) - .resizable(false) - .fullscreen(false) - .shadow(false) - .always_on_top(true) - .transparent(true) - .visible_on_all_workspaces(true) - .content_protected(should_protect) - .inner_size(width, height) - .skip_taskbar(false) - .initialization_script(format!( - "window.COUNTDOWN = {};", - countdown.unwrap_or_default() - )) - .build()?; - - lock_window_text_scale(&window); - - #[cfg(target_os = "windows")] - log_window_content_protection(&window, should_protect, &title); - - let (pos_x, pos_y) = capture_target - .as_ref() - .and_then(fake_window::calculate_recording_controls_position_for_target) - .unwrap_or_else(|| cursor_monitor.bottom_center_position(width, height, 120.0)); - let _ = window.set_position(logical_point_position(pos_x, pos_y)); - - debug!( - "InProgressRecording window: cursor_monitor=({}, {}, {}, {}), pos=({}, {})", - cursor_monitor.x, - cursor_monitor.y, - cursor_monitor.width, - cursor_monitor.height, - pos_x, - pos_y - ); - - debug!( - "InProgressRecording window created: label={}, inner_size={:?}, outer_position={:?}", - window.label(), - window.inner_size(), - window.outer_position() - ); - - #[cfg(target_os = "macos")] - { - app.run_on_main_thread({ - let window = window.clone(); - let app = app.clone(); - let panel_activation_guard = panel_activation_guard; - move || { - let _panel_activation_guard = panel_activation_guard; - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; - use tauri_nspanel::panel_delegate; - use tauri_nspanel::WebviewWindowExt as NSPanelWebviewWindowExt; - - #[link(name = "CoreGraphics", kind = "framework")] - unsafe extern "C" { - fn CGWindowLevelForKey(key: i32) -> i32; - } - - #[allow(non_upper_case_globals)] - const kCGMaximumWindowLevelKey: i32 = 10; - - let delegate = panel_delegate!(RecordingControlsPanelDelegate { - window_did_become_key, - window_did_resign_key - }); - - delegate.set_listener(Box::new(|_delegate_name: String| {})); - - let panel = match window.to_panel() { - Ok(p) => p, - Err(e) => { - tracing::error!("Failed to convert recording controls to panel: {:?}", e); - crate::permissions::sync_macos_dock_visibility(&app); - return; - } - }; - - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenPrimary, - ); - - panel.set_delegate(delegate); - - let max_level = unsafe { CGWindowLevelForKey(kCGMaximumWindowLevelKey) }; - panel.set_level(max_level); - - panel.order_front_regardless(); - panel.show(); - - crate::permissions::schedule_macos_dock_visibility_sync(&app); - } - }) - .ok(); - - fake_window::spawn_fake_window_listener(app.clone(), window.clone()); - } - - #[cfg(windows)] - { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let show_result = window.show(); - debug!( - "InProgressRecording window.show() result: {:?}", - show_result - ); - window.set_focus().ok(); - fake_window::spawn_fake_window_listener(app.clone(), window.clone()); - } - - window - } - Self::RecordingsOverlay => { - let title = CapWindowId::RecordingsOverlay.title(); - let should_protect = should_protect_window(app, &title); - - let window = self - .window_builder(app, "/recordings-overlay") - .maximized(false) - .resizable(false) - .fullscreen(false) - .shadow(false) - .always_on_top(true) - .visible_on_all_workspaces(true) - .accept_first_mouse(true) - .content_protected(should_protect) - .inner_size(cursor_monitor.width, cursor_monitor.height) - .skip_taskbar(true) - .transparent(true) - .build()?; - lock_window_text_scale(&window); - - let _ = window - .set_position(cursor_monitor.position(cursor_monitor.x, cursor_monitor.y)); - - // The build-time inner_size above was interpreted with the DPI of - // whatever monitor the window materialized on; now that it sits on the - // cursor monitor, re-apply the logical size so it converts with that - // monitor's scale, then verify against the expected physical size. - #[cfg(windows)] - { - let _ = window.set_size(LogicalSize::new( - cursor_monitor.width, - cursor_monitor.height, - )); - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - - let expected_physical_width = - (cursor_monitor.width * cursor_monitor.scale).round(); - match window.inner_size() { - Ok(actual_physical_size) - if expected_physical_width != actual_physical_size.width as f64 => - { - let _ = window.set_size(LogicalSize::new( - cursor_monitor.width, - cursor_monitor.height, - )); - } - Ok(_) => {} - Err(err) => { - warn!(%err, "Failed to read recordings overlay inner size"); - } - } - } - - #[cfg(target_os = "macos")] - { - app.run_on_main_thread({ - let window = window.clone(); - move || { - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; - use crate::panel_manager::try_to_panel; - - let panel = match try_to_panel(&window) { - Ok(p) => p, - Err(e) => { - tracing::error!("Failed to convert recordings overlay to panel: {}", e); - return; - } - }; - - panel.set_level(cocoa::appkit::NSMainMenuWindowLevel); - - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorTransient - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorIgnoresCycle, - ); - - #[allow(non_upper_case_globals)] - const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7; - panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel); - } - }) - .ok(); - } - - fake_window::spawn_fake_window_listener(app.clone(), window.clone()); - - window - } - }; - - // removing this for now as it causes windows to just stay hidden sometimes -_- - // window.hide().ok(); - - #[cfg(target_os = "macos")] - if let Some(position) = _id.traffic_lights_position() { - add_traffic_lights(&window, position); - } - - #[cfg(target_os = "macos")] - if _id.activates_dock() { - crate::permissions::sync_macos_dock_visibility(app); - } - - Ok(window) - } - - fn window_builder<'a>( - &'a self, - app: &'a AppHandle, - url: impl Into, - ) -> WebviewWindowBuilder<'a, Wry, AppHandle> { - let id = self.id(app); - self.window_builder_with_label(app, url, id.label()) - } - - fn window_builder_with_label<'a>( - &'a self, - app: &'a AppHandle, - url: impl Into, - label: impl Into, - ) -> WebviewWindowBuilder<'a, Wry, AppHandle> { - let id = self.id(app); - - let settings = GeneralSettingsStore::get(app).ok().flatten(); - let window_transparency_enabled = settings - .as_ref() - .map(|s| s.window_transparency) - .unwrap_or(false); - let theme = settings - .map(|s| match s.theme { - AppTheme::System => None, - AppTheme::Light => Some(tauri::Theme::Light), - AppTheme::Dark => Some(tauri::Theme::Dark), - }) - .unwrap_or(None); - - let mut builder = WebviewWindow::builder(app, label, WebviewUrl::App(url.into())) - .title(id.title()) - .visible(false) - .accept_first_mouse(true) - .shadow(true) - .theme(theme) - .devtools(cfg!(debug_assertions)); - - if !id.is_transparent() { - let is_dark = match theme { - Some(tauri::Theme::Dark) => true, - Some(tauri::Theme::Light) => false, - None | Some(_) => is_system_dark_mode(), - }; - - let bg_color = if is_dark { "#141414" } else { "#ffffff" }; - let init_script = format!( - r#"(function(){{var s=document.createElement('style');s.textContent='html,body{{background-color:{bg_color}}}';document.documentElement.appendChild(s);}})();"# - ); - builder = builder.initialization_script(&init_script); - - // Native backing color so the window is themed before the webview's - // first paint, allowing windows to be shown immediately without a - // white/black flash. Skipped when the user has window transparency - // enabled: an opaque native background would sit behind the - // translucent webview content and defeat the effect. - if !window_transparency_enabled { - let native_bg = if is_dark { - tauri::window::Color(0x14, 0x14, 0x14, 0xff) - } else { - tauri::window::Color(0xff, 0xff, 0xff, 0xff) - }; - builder = builder.background_color(native_bg); - } - } - - if let Some(min) = id.min_size() { - builder = builder - .inner_size(min.0, min.1) - .min_inner_size(min.0, min.1); - } - - #[cfg(target_os = "macos")] - { - if id.traffic_lights_position().is_some() { - builder = builder - .hidden_title(true) - .title_bar_style(tauri::TitleBarStyle::Overlay); - } else { - builder = builder.decorations(false) - } - } - - #[cfg(windows)] - { - let browser_args = windows_webview2_browser_args(); - let browser_args_json = serde_json::to_string(&browser_args) - .expect("Failed to serialize Windows WebView2 browser arguments"); - builder = builder - .decorations(false) - .zoom_hotkeys_enabled(false) - .additional_browser_args(&browser_args) - .initialization_script(format!( - "window.__CAP__ = window.__CAP__ ?? {{}}; window.__CAP__.windowsWebview2BrowserArgs = {browser_args_json};" - )); - } - - // Linux has no native macOS-style traffic lights, so we drop the window - // manager decorations and draw our own chrome (matching the macOS layout). - #[cfg(target_os = "linux")] - { - builder = builder.decorations(false); - } - - builder - } - - pub fn id(&self, app: &AppHandle) -> CapWindowId { - match self { - ShowCapWindow::Main { .. } => CapWindowId::Main, - ShowCapWindow::Settings { .. } => CapWindowId::Settings, - ShowCapWindow::Editor { project_path } => { - let state = app.state::(); - let s = state.ids.lock().unwrap(); - let id = s.iter().find(|(path, _)| path == project_path).unwrap().1; - CapWindowId::Editor { id } - } - ShowCapWindow::RecordingsOverlay => CapWindowId::RecordingsOverlay, - ShowCapWindow::TargetSelectOverlay { display_id, .. } => { - CapWindowId::TargetSelectOverlay { - display_id: display_id.clone(), - } - } - ShowCapWindow::WindowCaptureOccluder { screen_id } => { - CapWindowId::WindowCaptureOccluder { - screen_id: screen_id.clone(), - } - } - ShowCapWindow::CaptureArea { .. } => CapWindowId::CaptureArea, - ShowCapWindow::Camera { .. } => CapWindowId::Camera, - ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls, - ShowCapWindow::Upgrade => CapWindowId::Upgrade, - ShowCapWindow::ModeSelect => CapWindowId::ModeSelect, - ShowCapWindow::Onboarding => CapWindowId::Onboarding, - ShowCapWindow::ScreenshotEditor { path } => { - let state = app.state::(); - let s = state.ids.lock().unwrap(); - let id = s.iter().find(|(p, _)| p == path).unwrap().1; - CapWindowId::ScreenshotEditor { id } - } - } - } -} - -fn lock_window_text_scale(_window: &WebviewWindow) { - #[cfg(windows)] - { - let scale_factor = match _window.scale_factor() { - Ok(scale_factor) => scale_factor, - Err(e) => { - warn!("Failed to read window scale factor: {}", e); - return; - } - }; - - if let Err(e) = _window.with_webview(move |webview| unsafe { - use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Controller3; - use windows_core::Interface; - - let controller = webview.controller(); - - if let Err(e) = controller.SetZoomFactor(1.0) { - warn!("Failed to lock WebView zoom factor: {}", e); - } - - let Ok(controller3) = controller.cast::() else { - warn!("Failed to access WebView2 controller scale APIs"); - return; - }; - - if let Err(e) = controller3.SetShouldDetectMonitorScaleChanges(false) { - warn!("Failed to disable WebView scale detection: {}", e); - } - - if let Err(e) = controller3.SetRasterizationScale(scale_factor) { - warn!("Failed to lock WebView rasterization scale: {}", e); - } - }) { - warn!("Failed to access platform WebView: {}", e); - } - } -} - -/// `lock_window_text_scale` disables WebView2's own monitor-scale detection -/// (so the Windows text-size setting can't zoom the UI), which also stops it -/// following per-monitor DPI. The new scale factor must be forwarded here on -/// every `ScaleFactorChanged`, or a window dragged to a monitor with -/// different scaling keeps rasterizing and laying out at the old DPI. -pub fn update_window_rasterization_scale(_window: &WebviewWindow, _scale_factor: f64) { - #[cfg(windows)] - { - if let Err(e) = _window.with_webview(move |webview| unsafe { - use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Controller3; - use windows_core::Interface; - - let controller = webview.controller(); - - let Ok(controller3) = controller.cast::() else { - warn!("Failed to access WebView2 controller scale APIs"); - return; - }; - - if let Err(e) = controller3.SetRasterizationScale(_scale_factor) { - warn!("Failed to update WebView rasterization scale: {}", e); - } - }) { - warn!("Failed to access platform WebView: {}", e); - } - } -} - -#[cfg(target_os = "macos")] -fn add_traffic_lights(window: &WebviewWindow, controls_inset: Option>) { - use crate::platform::delegates; - - let target_window = window.clone(); - window - .run_on_main_thread(move || { - delegates::setup( - target_window.as_ref().window(), - controls_inset.unwrap_or(DEFAULT_TRAFFIC_LIGHTS_INSET), - ); - - let c_win = target_window.clone(); - target_window.on_window_event(move |event| match event { - tauri::WindowEvent::ThemeChanged(..) | tauri::WindowEvent::Focused(..) => { - position_traffic_lights_impl(&c_win.as_ref().window(), controls_inset); - } - _ => {} - }); - }) - .ok(); -} - -#[tauri::command] -#[specta::specta] -#[instrument(skip(window))] -pub fn set_theme(window: tauri::Window, theme: AppTheme) { - let _ = window.set_theme(match theme { - AppTheme::System => None, - AppTheme::Light => Some(tauri::Theme::Light), - AppTheme::Dark => Some(tauri::Theme::Dark), - }); - - #[cfg(target_os = "macos")] - match CapWindowId::from_str(window.label()) { - Ok(win) if win.traffic_lights_position().is_some() => position_traffic_lights(window, None), - Ok(_) | Err(_) => {} - } -} - -#[tauri::command] -#[specta::specta] -#[instrument(skip(_window))] -pub fn position_traffic_lights(_window: tauri::Window, _controls_inset: Option<(f64, f64)>) { - #[cfg(target_os = "macos")] - position_traffic_lights_impl( - &_window, - _controls_inset.map(LogicalPosition::from).or_else(|| { - // Attempt to get the default inset from the window's traffic lights position - CapWindowId::from_str(_window.label()) - .ok() - .and_then(|id| id.traffic_lights_position().flatten()) - }), - ); -} - -#[tauri::command] -#[specta::specta] -#[instrument(skip(_window))] -pub fn set_teleprompter_window_level(_window: tauri::Window, _always_on_top: bool) { - #[cfg(target_os = "macos")] - if _window.label() == CapWindowId::Teleprompter.to_string() { - let level = if _always_on_top { - TELEPROMPTER_PANEL_LEVEL - } else { - objc2_app_kit::NSNormalWindowLevel - }; - crate::platform::set_window_level(_window, level); - } - - #[cfg(not(target_os = "macos"))] - if _window.label() == CapWindowId::Teleprompter.to_string() - && let Err(error) = _window.set_always_on_top(_always_on_top) - { - warn!(?error, "Failed to update teleprompter window level"); - } -} - -#[tauri::command] -#[specta::specta] -#[instrument(skip(_window))] -pub fn set_teleprompter_window_opacity(_window: tauri::Window, _opacity: f64) { - #[cfg(target_os = "macos")] - if _window.label() == CapWindowId::Teleprompter.to_string() { - crate::platform::set_window_opacity(_window, _opacity); - } -} - -#[cfg(target_os = "macos")] -fn position_traffic_lights_impl( - window: &tauri::Window, - controls_inset: Option>, -) { - use crate::platform::delegates::{UnsafeWindowHandle, position_window_controls}; - let c_win = window.clone(); - window - .run_on_main_thread(move || { - let ns_window = match c_win.ns_window() { - Ok(handle) => handle, - Err(_) => return, - }; - position_window_controls( - UnsafeWindowHandle(ns_window), - &controls_inset.unwrap_or(DEFAULT_TRAFFIC_LIGHTS_INSET), - ); - }) - .ok(); -} - -// Capture exclusion (WDA_EXCLUDEFROMCAPTURE / NSWindowSharingType::None) also hides -// the window from "capture-based" displays such as virtual/indirect/dummy-HDMI or -// mirrored monitors, making it invisible and unreachable. We therefore only protect -// Cap's own windows while a recording is actually active, which is the only time the -// exclusion is meaningful. -// -// On desktops that are themselves delivered through a capture-based stream (Shadow -// and other cloud PCs, RDP, VMs), even recording-gated exclusion hides the recording -// controls from the user and trips DRM detectors (Shadow error S:102), so exclusion -// is skipped entirely there — Cap's windows then appear in recordings, which is the -// lesser evil. Overridable via the CAP_WINDOW_CAPTURE_EXCLUSION env var. -#[cfg(target_os = "windows")] -pub fn capture_exclusion_hides_ui() -> bool { - static LAST_LOGGED: std::sync::Mutex> = std::sync::Mutex::new(None); - - let reason = crate::platform::win::capture_streamed_display_reason(); - - if let Ok(mut last) = LAST_LOGGED.lock() - && *last != reason - { - match &reason { - Some(reason) => warn!( - %reason, - "Skipping window capture exclusion: this desktop is viewed through a \ - capture-based stream, so excluded windows would be invisible to the user. \ - Cap's windows will appear in recordings." - ), - None => info!("Window capture exclusion re-enabled"), - } - *last = reason.clone(); - } - - reason.is_some() -} - -#[cfg(not(target_os = "windows"))] -pub fn capture_exclusion_hides_ui() -> bool { - false -} - -fn content_protection_enabled(app: &AppHandle) -> bool { - app.try_state::>() - .and_then(|state| { - state - .try_read() - .ok() - .map(|app| app.is_recording_active_or_pending()) - }) - .unwrap_or(false) -} - -fn window_capture_excluded(app: &AppHandle, window_title: &str) -> bool { - if window_title == CapWindowId::Teleprompter.title() { - return true; - } - - let matches = |list: &[WindowExclusion]| { - list.iter() - .any(|entry| entry.matches(None, None, Some(window_title))) - }; - - GeneralSettingsStore::get(app) - .ok() - .flatten() - .map(|settings| matches(&settings.excluded_windows)) - .unwrap_or_else(|| matches(&general_settings::default_excluded_windows())) -} - -fn should_protect_window(app: &AppHandle, window_title: &str) -> bool { - content_protection_enabled(app) - && !capture_exclusion_hides_ui() - && window_capture_excluded(app, window_title) -} - -pub fn apply_content_protection(app: &AppHandle, enabled: bool) { - let enabled = enabled && !capture_exclusion_hides_ui(); - - for (label, window) in app.webview_windows() { - let Ok(id) = CapWindowId::from_str(&label) else { - continue; - }; - - // The camera window's protection depends on the recording mode (studio excludes - // the preview, instant keeps it) and is driven from `start_recording`. Only ever - // clear it here so it stays visible outside of recordings. - if matches!(id, CapWindowId::Camera) { - if !enabled { - let _ = window.set_content_protected(false); - } - continue; - } - - let title = id.title(); - let should_protect = enabled && window_capture_excluded(app, &title); - let _ = window.set_content_protected(should_protect); - - #[cfg(target_os = "windows")] - log_window_content_protection(&window, should_protect, &title); - } -} - -#[cfg(target_os = "windows")] -fn cached_windows_version() -> Option<&'static scap_direct3d::WindowsVersion> { - static VERSION: std::sync::OnceLock> = - std::sync::OnceLock::new(); - VERSION - .get_or_init(scap_direct3d::WindowsVersion::detect) - .as_ref() -} - -#[cfg(target_os = "windows")] -fn display_affinity_name(value: u32) -> &'static str { - match value { - 0 => "WDA_NONE", - 1 => "WDA_MONITOR", - 17 => "WDA_EXCLUDEFROMCAPTURE", - _ => "UNKNOWN", - } -} - -#[cfg(target_os = "windows")] -fn log_window_content_protection(window: &WebviewWindow, enabled: bool, window_title: &str) { - use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowDisplayAffinity, WDA_EXCLUDEFROMCAPTURE, WDA_NONE, - }; - - let expected = if enabled { - WDA_EXCLUDEFROMCAPTURE - } else { - WDA_NONE - }; - - if let Some(version) = cached_windows_version() - && enabled - && version.build < 19041 - { - warn!( - window = window.label(), - title = window_title, - version = %version.display_name(), - expected = display_affinity_name(expected.0), - "Window capture exclusion is not fully supported on this Windows build" - ); - } - - let hwnd = match window.hwnd() { - Ok(hwnd) => windows::Win32::Foundation::HWND(hwnd.0), - Err(error) => { - warn!( - window = window.label(), - title = window_title, - error = %error, - "Failed to get HWND for content protection diagnostics" - ); - return; - } - }; - - let mut applied = 0u32; - match unsafe { GetWindowDisplayAffinity(hwnd, &mut applied) } { - Ok(()) => { - if applied == expected.0 { - debug!( - window = window.label(), - title = window_title, - expected = display_affinity_name(expected.0), - applied = display_affinity_name(applied), - "Window content protection verified" - ); - } else { - warn!( - window = window.label(), - title = window_title, - expected = display_affinity_name(expected.0), - expected_raw = expected.0, - applied = display_affinity_name(applied), - applied_raw = applied, - "Window content protection mismatch" - ); - } - } - Err(error) => { - warn!( - window = window.label(), - title = window_title, - expected = display_affinity_name(expected.0), - error = %error, - "Failed to query window display affinity" - ); - } - } -} - -#[tauri::command] -#[specta::specta] -#[instrument(skip(app))] -pub fn refresh_window_content_protection(app: AppHandle) -> Result<(), String> { - let enabled = content_protection_enabled(&app); - apply_content_protection(&app, enabled); - Ok(()) -} - -// Credits: tauri-plugin-window-state -trait MonitorExt { - fn intersects( - &self, - position: PhysicalPosition, - size: PhysicalSize, - scale: f64, - ) -> bool; -} - -impl MonitorExt for Display { - fn intersects( - &self, - position: PhysicalPosition, - size: PhysicalSize, - _scale: f64, - ) -> bool { - #[cfg(target_os = "macos")] - { - let Some(bounds) = self.raw_handle().logical_bounds() else { - return false; - }; - - let left = (bounds.position().x() * _scale) as i32; - let right = left + (bounds.size().width() * _scale) as i32; - let top = (bounds.position().y() * _scale) as i32; - let bottom = top + (bounds.size().height() * _scale) as i32; - - [ - (position.x, position.y), - (position.x + size.width as i32, position.y), - (position.x, position.y + size.height as i32), - ( - position.x + size.width as i32, - position.y + size.height as i32, - ), - ] - .into_iter() - .any(|(x, y)| x >= left && x < right && y >= top && y < bottom) - } - - #[cfg(windows)] - { - let Some(bounds) = self.raw_handle().physical_bounds() else { - return false; - }; - - let left = bounds.position().x() as i32; - let right = left + bounds.size().width() as i32; - let top = bounds.position().y() as i32; - let bottom = top + bounds.size().height() as i32; - - [ - (position.x, position.y), - (position.x + size.width as i32, position.y), - (position.x, position.y + size.height as i32), - ( - position.x + size.width as i32, - position.y + size.height as i32, - ), - ] - .into_iter() - .any(|(x, y)| x >= left && x < right && y >= top && y < bottom) - } - - #[cfg(target_os = "linux")] - { - let Some(bounds) = self.raw_handle().physical_bounds() else { - return false; - }; - - let left = bounds.position().x() as i32; - let right = left + bounds.size().width() as i32; - let top = bounds.position().y() as i32; - let bottom = top + bounds.size().height() as i32; - - [ - (position.x, position.y), - (position.x + size.width as i32, position.y), - (position.x, position.y + size.height as i32), - ( - position.x + size.width as i32, - position.y + size.height as i32, - ), - ] - .into_iter() - .any(|(x, y)| x >= left && x < right && y >= top && y < bottom) - } - } -} - -#[specta::specta] -#[tauri::command(async)] -#[instrument(skip(_window))] -pub async fn apply_macos_liquid_glass_background( - _window: tauri::Window, - _enabled: bool, - _radius: f64, -) -> Result { - #[cfg(target_os = "macos")] - { - let window = _window.clone(); - let (tx, rx) = tokio::sync::oneshot::channel(); - - _window - .run_on_main_thread(move || { - let result = if window.label() == CapWindowId::Main.label() { - crate::platform::apply_main_window_liquid_glass_background( - &window, _enabled, _radius, - ) - } else { - crate::platform::apply_liquid_glass_background(&window, _enabled, _radius) - }; - let _ = tx.send(result); - }) - .map_err(|error| error.to_string())?; - - return rx - .await - .map_err(|_| "macOS Liquid Glass task was cancelled".to_string())?; - } - - #[cfg(not(target_os = "macos"))] - { - Ok(false) - } -} - -#[specta::specta] -#[tauri::command(async)] -#[instrument(skip(_window))] -pub fn set_window_transparent(_window: tauri::Window, _value: bool) { - #[cfg(target_os = "macos")] - { - let ns_win = _window - .ns_window() - .expect("Failed to get native window handle") - as *const objc2_app_kit::NSWindow; - - unsafe { - (*ns_win).setOpaque(!_value); - } - } -} - -#[derive(Default, Clone)] -pub struct EditorWindowIds { - pub ids: Arc>>, - pub counter: Arc, -} - -impl EditorWindowIds { - pub fn get(app: &AppHandle) -> Self { - app.state::().deref().clone() - } -} - -#[derive(Default, Clone)] -pub struct ScreenshotEditorWindowIds { - pub ids: Arc>>, - pub counter: Arc, -} - -impl ScreenshotEditorWindowIds { - pub fn get(app: &AppHandle) -> Self { - app.state::().deref().clone() - } -} - -#[derive(Default, Clone)] -pub struct EditorRecordingTarget(pub Arc>>); - -impl EditorRecordingTarget { - pub fn get(app: &AppHandle) -> Self { - app.state::().deref().clone() - } - - pub fn set(app: &AppHandle, path: Option) { - *Self::get(app).0.lock().unwrap() = path; - } - - pub fn current(app: &AppHandle) -> Option { - Self::get(app).0.lock().unwrap().clone() - } - - pub fn take(app: &AppHandle) -> Option { - Self::get(app).0.lock().unwrap().take() - } -} - -pub fn editor_window_for_path(app: &AppHandle, path: &std::path::Path) -> Option { - let ids = EditorWindowIds::get(app); - let id = { - let guard = ids.ids.lock().unwrap(); - guard.iter().find(|(p, _)| p == path).map(|(_, id)| *id)? - }; - CapWindowId::Editor { id }.get(app) -} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json deleted file mode 100644 index ff84112d8e1..00000000000 --- a/apps/desktop/src-tauri/tauri.conf.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Cap - Development", - "identifier": "so.cap.desktop.dev", - "mainBinaryName": "Cap - Development", - "build": { - "beforeDevCommand": "pnpm localdev", - "devUrl": "http://localhost:3002", - "beforeBuildCommand": "pnpm turbo build --filter @cap/desktop", - "frontendDist": "../.output/public" - }, - "app": { - "macOSPrivateApi": true, - "security": { - "csp": "default-src 'self' ws: ipc: http://ipc.localhost; connect-src 'self' ws: wss: ipc: http://ipc.localhost https://t.cap.so; img-src 'self' asset: http://asset.localhost blob: data:; media-src 'self' asset: http://asset.localhost; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'", - "assetProtocol": { - "enable": true, - "scope": [ - "$APPDATA/**", - "**/*.jpg", - "**/*.webp", - "**/*.mp3", - "**/*.wav", - "**/*.m4a", - "**/*.ogg", - "**/*.flac", - "**/*.aac", - "$RESOURCE/**", - "**/*.riv", - "$HOME/**" - ] - } - } - }, - "plugins": { - "updater": { "active": false, "pubkey": "" }, - "deep-link": { - "desktop": { - "schemes": ["cap-desktop"] - } - } - }, - "bundle": { - "active": true, - "createUpdaterArtifacts": true, - "targets": "all", - "externalBin": [ - "binaries/cap-muxer", - "binaries/cap-exporter", - "binaries/cap-cli" - ], - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/macos/icon.icns", - "icons/icon.ico" - ], - "resources": { - "assets/backgrounds/macOS/*": "assets/backgrounds/macOS/", - "assets/backgrounds/blue/*": "assets/backgrounds/blue/", - "assets/backgrounds/cities/*": "assets/backgrounds/cities/", - "assets/backgrounds/dark/*": "assets/backgrounds/dark/", - "assets/backgrounds/orange/*": "assets/backgrounds/orange/", - "assets/backgrounds/purple/*": "assets/backgrounds/purple/", - "../src/assets/rive/*.riv": "assets/rive/", - "../src/assets/music/*.mp3": "assets/music/" - }, - "macOS": { - "dmg": { - "background": "assets/dmg-background.png", - "appPosition": { - "x": 180, - "y": 140 - }, - "applicationFolderPosition": { - "x": 480, - "y": 140 - } - }, - "frameworks": ["../../../target/native-deps/Spacedrive.framework"] - }, - "windows": { - "nsis": { - "headerImage": "assets/nsis-header.bmp", - "sidebarImage": "assets/nsis-sidebar.bmp", - "installerIcon": "icons/icon.ico" - }, - "wix": { - "upgradeCode": "79f4309d-ca23-54df-b6f9-826a1d783676", - "bannerPath": "assets/wix-banner.bmp", - "dialogImagePath": "assets/wix-dialog.bmp" - } - }, - "linux": { - "deb": { - "depends": [ - "libwebkit2gtk-4.1-0", - "libgtk-3-0", - "libayatana-appindicator3-1", - "libva2", - "libva-drm2", - "libpipewire-0.3-0", - "libasound2t64 | libasound2" - ], - "files": { - "/usr/share/icons/hicolor/scalable/status/so.cap.desktop-tray-default-symbolic.svg": "icons/linux/so.cap.desktop-tray-default-symbolic.svg", - "/usr/share/icons/hicolor/scalable/status/so.cap.desktop-tray-instant-symbolic.svg": "icons/linux/so.cap.desktop-tray-instant-symbolic.svg", - "/usr/share/icons/hicolor/scalable/status/so.cap.desktop-tray-screenshot-symbolic.svg": "icons/linux/so.cap.desktop-tray-screenshot-symbolic.svg", - "/usr/share/icons/hicolor/scalable/status/so.cap.desktop-tray-studio-symbolic.svg": "icons/linux/so.cap.desktop-tray-studio-symbolic.svg", - "/usr/share/icons/hicolor/scalable/status/so.cap.desktop-tray-stop-symbolic.svg": "icons/linux/so.cap.desktop-tray-stop-symbolic.svg" - } - } - }, - "fileAssociations": [ - { - "ext": ["cap"], - "name": "Cap Project", - "mimeType": "application/x-cap-project", - "role": "Editor" - } - ] - } -} diff --git a/apps/desktop/src-tauri/tauri.prod.conf.json b/apps/desktop/src-tauri/tauri.prod.conf.json deleted file mode 100644 index 3297c552ab5..00000000000 --- a/apps/desktop/src-tauri/tauri.prod.conf.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Cap", - "mainBinaryName": "Cap", - "identifier": "so.cap.desktop", - "build": { - "beforeBundleCommand": "node scripts/prodBeforeBundle.js" - }, - "plugins": { - "updater": { - "active": true, - "endpoints": [ - "https://cdn.crabnebula.app/update/cap/cap/{{target}}/{{current_version}}" - ], - "dialog": true, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUyOTAzOTdFNzJFQkRFOTMKUldTVDN1dHlmam1RNHFXb1VYTXlrQk1iMFFkcjN0YitqZlA5WnZNY0ZtQ1dvM1dxK211M3VIYUQK" - } - }, - "bundle": { - "macOS": { - "entitlements": "Entitlements.plist" - }, - "windows": { - "wix": { - "upgradeCode": "a765d9de-0ecc-55d0-b8a0-61e9d3276664" - } - } - } -} diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index be56bd5ec9b..9624b4f79d2 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -1,10 +1,5 @@ import { Route, Router, useCurrentMatches } from "@solidjs/router"; import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; -import { - getCurrentWebviewWindow, - type WebviewWindow, -} from "@tauri-apps/api/webviewWindow"; -import { message } from "@tauri-apps/plugin-dialog"; import { createEffect, createSignal, @@ -13,20 +8,26 @@ import { onMount, Suspense, } from "solid-js"; -import { Toaster } from "solid-toast"; +import { message } from "~/electron/dialog"; +import { + getCurrentWebviewWindow, + type WebviewWindow, +} from "~/electron/webviewWindow"; import "@cap/ui-solid/main.css"; import "unfonts.css"; import "./styles/theme.css"; import { CapErrorBoundary } from "./components/CapErrorBoundary"; -import WindowChromeLayout from "./routes/(window-chrome)"; -import SettingsLayout from "./routes/(window-chrome)/settings"; import { authStore, generalSettingsStore } from "./store"; -import { identifyUser, initAnonymousUser } from "./utils/analytics"; import { type AppTheme, commands } from "./utils/tauri"; import titlebar from "./utils/titlebar-state"; +const WindowChromeLayout = lazy(() => import("./routes/(window-chrome)")); +const SettingsLayout = lazy(() => import("./routes/(window-chrome)/settings")); +const GlobalToaster = lazy(() => + import("solid-toast").then(({ Toaster }) => ({ default: Toaster })), +); const NewMainPage = lazy(() => import("./routes/(window-chrome)/new-main")); const SettingsGeneralPage = lazy( () => import("./routes/(window-chrome)/settings/general"), @@ -123,38 +124,39 @@ export default function App() { function Inner() { const currentWindow = getCurrentWebviewWindow(); - createThemeListener(currentWindow); + if (location.pathname !== "/camera") createThemeListener(currentWindow); onMount(() => { - initAnonymousUser(); - // OpenPanel keeps profileId in memory only (PostHog persisted it), so - // sign-in-time identify alone loses attribution after an app restart. - void authStore.get().then((auth) => { - if (auth?.user_id) identifyUser(auth.user_id); - }); + if (shouldInitializeAnalytics(currentWindow.label)) { + void initializeAnalytics(); + } prewarmFontCaches(); }); return ( <> - + {shouldRenderGlobalToaster(location.pathname) && ( + + + + )} { @@ -265,6 +267,44 @@ function Inner() { ); } +function shouldRenderGlobalToaster(pathname: string) { + switch (pathname) { + case "/camera": + case "/capture-area": + case "/debug": + case "/in-progress-recording": + case "/mode-select": + case "/notifications": + case "/recordings-overlay": + case "/teleprompter": + case "/window-capture-occluder": + return false; + default: + return true; + } +} + +function shouldInitializeAnalytics(label: string) { + return ( + label === "main" || label === "settings" || label.startsWith("editor-") + ); +} + +async function initializeAnalytics() { + try { + const { identifyUser, initAnonymousUser } = await import( + "./utils/analytics" + ); + initAnonymousUser(); + // OpenPanel keeps profileId in memory only (PostHog persisted it), so + // sign-in-time identify alone loses attribution after an app restart. + const auth = await authStore.get(); + if (auth?.user_id) identifyUser(auth.user_id); + } catch (error) { + console.error("Failed to initialize analytics:", error); + } +} + // WebKit resolves the emoji fallback chain lazily on first glyph paint, which // can jank the first list/text render containing emoji (e.g. recording // titles). Drawing once to an offscreen canvas at idle warms the per-process diff --git a/apps/desktop/src/components/CapErrorBoundary.tsx b/apps/desktop/src/components/CapErrorBoundary.tsx index 2dca68d1d47..21de4c17dc4 100644 --- a/apps/desktop/src/components/CapErrorBoundary.tsx +++ b/apps/desktop/src/components/CapErrorBoundary.tsx @@ -1,7 +1,7 @@ import { Button } from "@cap/ui-solid"; -import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { ErrorBoundary, type ParentProps } from "solid-js"; +import { writeText } from "~/electron/clipboard"; +import { getCurrentWebviewWindow } from "~/electron/webviewWindow"; export function CapErrorBoundary(props: ParentProps) { return ( diff --git a/apps/desktop/src/components/Cropper.tsx b/apps/desktop/src/components/Cropper.tsx index 57462ee48cc..a64846575d7 100644 --- a/apps/desktop/src/components/Cropper.tsx +++ b/apps/desktop/src/components/Cropper.tsx @@ -1,10 +1,5 @@ import { createEventListenerMap } from "@solid-primitives/event-listener"; import { createResizeObserver } from "@solid-primitives/resize-observer"; -import type { - CheckMenuItemOptions, - PredefinedMenuItemOptions, -} from "@tauri-apps/api/menu"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { type Accessor, children, @@ -21,6 +16,11 @@ import { } from "solid-js"; import { createStore } from "solid-js/store"; import { Transition } from "solid-transition-group"; +import type { + CheckMenuItemOptions, + PredefinedMenuItemOptions, +} from "~/electron/menu"; +import { type as ostype } from "~/electron/os"; import { createKeyDownSignal } from "~/utils/events"; import { commands } from "~/utils/tauri"; diff --git a/apps/desktop/src/components/Tooltip.tsx b/apps/desktop/src/components/Tooltip.tsx index 3b8897df7f5..b1ed58bebb2 100644 --- a/apps/desktop/src/components/Tooltip.tsx +++ b/apps/desktop/src/components/Tooltip.tsx @@ -1,7 +1,7 @@ import { Tooltip as KTooltip } from "@kobalte/core/tooltip"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import type { ComponentProps, JSX } from "solid-js"; +import { type as ostype } from "~/electron/os"; interface Props extends ComponentProps { content?: JSX.Element; diff --git a/apps/desktop/src/components/selection-hint.tsx b/apps/desktop/src/components/selection-hint.tsx index f27c72db54c..7899765992d 100644 --- a/apps/desktop/src/components/selection-hint.tsx +++ b/apps/desktop/src/components/selection-hint.tsx @@ -1,5 +1,5 @@ -import { type as ostype } from "@tauri-apps/plugin-os"; import { Match, Show, Switch } from "solid-js"; +import { type as ostype } from "~/electron/os"; export type SelectionHintProps = { show: boolean; diff --git a/apps/desktop/src/components/titlebar/Titlebar.tsx b/apps/desktop/src/components/titlebar/Titlebar.tsx index a6522d3c015..535c6ccaa1d 100644 --- a/apps/desktop/src/components/titlebar/Titlebar.tsx +++ b/apps/desktop/src/components/titlebar/Titlebar.tsx @@ -1,7 +1,8 @@ // Credits: tauri-controls -import { type } from "@tauri-apps/plugin-os"; + import { cx } from "cva"; import { type ComponentProps, Match, Switch, splitProps } from "solid-js"; +import { type } from "~/electron/os"; import titlebarState from "~/utils/titlebar-state"; import CaptionControlsWindows11 from "./controls/CaptionControlsWindows11"; diff --git a/apps/desktop/src/components/titlebar/controls/CaptionControlsMacOS.tsx b/apps/desktop/src/components/titlebar/controls/CaptionControlsMacOS.tsx index 77b00e588eb..09d1cda9b1d 100644 --- a/apps/desktop/src/components/titlebar/controls/CaptionControlsMacOS.tsx +++ b/apps/desktop/src/components/titlebar/controls/CaptionControlsMacOS.tsx @@ -1,4 +1,3 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; import { cx } from "cva"; import { type ComponentProps, @@ -8,6 +7,7 @@ import { Show, splitProps, } from "solid-js"; +import { getCurrentWindow } from "~/electron/window"; export default function CaptionControlsMacOS( props: ComponentProps<"div"> & { diff --git a/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx b/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx index d55b689bd17..f30500a1e2c 100644 --- a/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx +++ b/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx @@ -1,4 +1,3 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; import { cx } from "cva"; import { type ComponentProps, @@ -9,6 +8,7 @@ import { Show, splitProps, } from "solid-js"; +import { getCurrentWindow } from "~/electron/window"; import titlebarState from "~/utils/titlebar-state"; import { WindowControlButton as ControlButton } from "./WindowControlButton"; diff --git a/apps/desktop/src/electron/app.ts b/apps/desktop/src/electron/app.ts new file mode 100644 index 00000000000..ae949e9bb36 --- /dev/null +++ b/apps/desktop/src/electron/app.ts @@ -0,0 +1,8 @@ +import { native } from "./bridge"; +export const getName = () => native("app.name"); +export const getVersion = () => native("app.version"); +export const getTauriVersion = () => Promise.resolve("electron"); +export const getIdentifier = () => Promise.resolve("so.cap.desktop"); +export const show = () => native("window.action", { action: "show" }); +export const hide = () => native("window.action", { action: "hide" }); +export const defaultWindowIcon = () => Promise.resolve(null); diff --git a/apps/desktop/src/electron/bridge.ts b/apps/desktop/src/electron/bridge.ts new file mode 100644 index 00000000000..94cac8e86f7 --- /dev/null +++ b/apps/desktop/src/electron/bridge.ts @@ -0,0 +1,49 @@ +export interface CapElectronBridge { + windowLabel: string; + initialization: unknown; + os: { arch: string; platform: string; type: string; version: string }; + invoke(command: string, args?: Record): Promise; + native(operation: string, payload?: Record): Promise; + emit(event: string, payload?: unknown): void; + onEvent(event: string, listener: (message: BridgeEvent) => void): () => void; + onChannel(listener: (message: BridgeChannelMessage) => void): () => void; +} + +export interface BridgeEvent { + type: "event"; + event: string; + payload: unknown; + target?: string; +} + +export interface BridgeChannelMessage { + type: "channel"; + target: string; + channelId: number; + index: number; + message?: unknown; + end: boolean; +} + +declare global { + interface Window { + capElectron: CapElectronBridge; + } +} + +export function bridge() { + if (!window.capElectron) + throw new Error("Cap Electron preload bridge is unavailable"); + return window.capElectron; +} + +export function toIpcValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function native( + operation: string, + payload?: Record, +) { + return bridge().native(operation, toIpcValue(payload ?? {})); +} diff --git a/apps/desktop/src/electron/clipboard.ts b/apps/desktop/src/electron/clipboard.ts new file mode 100644 index 00000000000..f090864a6a7 --- /dev/null +++ b/apps/desktop/src/electron/clipboard.ts @@ -0,0 +1,9 @@ +import { native } from "./bridge"; +export const readText = () => native("clipboard.readText"); +export const writeText = (text: string) => + native("clipboard.writeText", { text }); +export const readImage = () => + Promise.reject(new Error("readImage is not implemented")); +export const writeImage = (_image: unknown) => + Promise.reject(new Error("writeImage is not implemented")); +export const clear = () => writeText(""); diff --git a/apps/desktop/src/electron/core.ts b/apps/desktop/src/electron/core.ts new file mode 100644 index 00000000000..383943b2a18 --- /dev/null +++ b/apps/desktop/src/electron/core.ts @@ -0,0 +1,54 @@ +import { bridge, toIpcValue } from "./bridge"; + +let nextChannelId = 1; +const channels = new Map>(); + +bridge().onChannel((message) => { + const channel = channels.get(message.channelId); + if (!channel) return; + if (message.end) { + channels.delete(message.channelId); + return; + } + try { + channel.onmessage?.(message.message); + } catch (error) { + channel.onerror?.(error); + } +}); + +export class Channel { + id: number; + onmessage?: (message: T) => void; + onerror?: (error: unknown) => void; + + constructor(onmessage?: (message: T) => void) { + this.id = nextChannelId++; + this.onmessage = onmessage; + channels.set(this.id, this as Channel); + } + + toJSON() { + return `__CHANNEL__:${this.id}`; + } +} + +export async function invoke( + command: string, + args?: Record, +): Promise { + return bridge().invoke(command, toIpcValue(args ?? {})); +} + +export function convertFileSrc(filePath: string, protocol = "asset") { + if (protocol !== "asset") + return `${protocol}://localhost/${encodeURIComponent(filePath)}`; + const bytes = new TextEncoder().encode(filePath); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return `cap-asset://asset/${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")}`; +} + +export function isTauri() { + return false; +} diff --git a/apps/desktop/src/electron/deep-link.ts b/apps/desktop/src/electron/deep-link.ts new file mode 100644 index 00000000000..19ce16e8d09 --- /dev/null +++ b/apps/desktop/src/electron/deep-link.ts @@ -0,0 +1,9 @@ +import { listen, type UnlistenFn } from "./event"; +export const getCurrent = () => Promise.resolve(null); +export const onOpenUrl = ( + handler: (urls: string[]) => void, +): Promise => + listen("deep-link://new-url", ({ payload }) => handler(payload)); +export const register = () => Promise.resolve(); +export const unregister = () => Promise.resolve(); +export const isRegistered = () => Promise.resolve(true); diff --git a/apps/desktop/src/electron/dialog.ts b/apps/desktop/src/electron/dialog.ts new file mode 100644 index 00000000000..88d7ae70b51 --- /dev/null +++ b/apps/desktop/src/electron/dialog.ts @@ -0,0 +1,63 @@ +import { native } from "./bridge"; + +export interface DialogFilter { + name: string; + extensions: string[]; +} +export interface OpenDialogOptions { + title?: string; + filters?: DialogFilter[]; + defaultPath?: string; + multiple?: boolean; + directory?: boolean; + recursive?: boolean; + canCreateDirectories?: boolean; +} +export interface SaveDialogOptions { + title?: string; + filters?: DialogFilter[]; + defaultPath?: string; +} +export interface MessageDialogOptions { + title?: string; + kind?: "info" | "warning" | "error"; + okLabel?: string; + buttons?: string[] | Record; +} +export interface ConfirmDialogOptions extends MessageDialogOptions { + cancelLabel?: string; +} + +export function open(options: OpenDialogOptions = {}) { + return native("dialog.open", { ...options }); +} +export function save(options: SaveDialogOptions = {}) { + return native("dialog.save", { ...options }); +} +export function message( + message_: string, + options: string | MessageDialogOptions = {}, +) { + return native("dialog.message", { + message: message_, + ...(typeof options === "string" ? { title: options } : options), + }); +} +export function ask( + message_: string, + options: string | ConfirmDialogOptions = {}, +) { + return native("dialog.ask", { + message: message_, + ...(typeof options === "string" ? { title: options } : options), + }); +} +export function confirm( + message_: string, + options: string | ConfirmDialogOptions = {}, +) { + return native("dialog.confirm", { + message: message_, + ...(typeof options === "string" ? { title: options } : options), + }); +} diff --git a/apps/desktop/src/electron/dpi.ts b/apps/desktop/src/electron/dpi.ts new file mode 100644 index 00000000000..a7caa029b2e --- /dev/null +++ b/apps/desktop/src/electron/dpi.ts @@ -0,0 +1,45 @@ +export class LogicalPosition { + constructor( + public x: T, + public y: T, + ) {} + toPhysical(scaleFactor: number) { + return new PhysicalPosition(this.x * scaleFactor, this.y * scaleFactor); + } +} + +export class PhysicalPosition { + constructor( + public x: T, + public y: T, + ) {} + toLogical(scaleFactor: number) { + return new LogicalPosition(this.x / scaleFactor, this.y / scaleFactor); + } +} + +export class LogicalSize { + constructor( + public width: T, + public height: T, + ) {} + toPhysical(scaleFactor: number) { + return new PhysicalSize( + this.width * scaleFactor, + this.height * scaleFactor, + ); + } +} + +export class PhysicalSize { + constructor( + public width: T, + public height: T, + ) {} + toLogical(scaleFactor: number) { + return new LogicalSize(this.width / scaleFactor, this.height / scaleFactor); + } +} + +export type Position = LogicalPosition | PhysicalPosition; +export type Size = LogicalSize | PhysicalSize; diff --git a/apps/desktop/src/electron/event.ts b/apps/desktop/src/electron/event.ts new file mode 100644 index 00000000000..c18a02e451c --- /dev/null +++ b/apps/desktop/src/electron/event.ts @@ -0,0 +1,47 @@ +import { bridge, toIpcValue } from "./bridge"; + +export type UnlistenFn = () => void; + +export interface Event { + event: string; + id: number; + payload: T; +} + +export type EventCallback = (event: Event) => void; + +let nextEventId = 1; + +export async function listen( + event: string, + handler: EventCallback, +): Promise { + const id = nextEventId++; + return bridge().onEvent(event, (message) => { + handler({ event, id, payload: message.payload as T }); + }); +} + +export async function once( + event: string, + handler: EventCallback, +): Promise { + let unlisten = () => {}; + unlisten = await listen(event, (payload) => { + unlisten(); + handler(payload); + }); + return unlisten; +} + +export async function emit(event: string, payload?: unknown): Promise { + bridge().emit(event, toIpcValue(payload ?? null)); +} + +export async function emitTo( + _target: string, + event: string, + payload?: unknown, +): Promise { + bridge().emit(event, toIpcValue(payload ?? null)); +} diff --git a/apps/desktop/src/electron/fs.ts b/apps/desktop/src/electron/fs.ts new file mode 100644 index 00000000000..a13613a63fc --- /dev/null +++ b/apps/desktop/src/electron/fs.ts @@ -0,0 +1,101 @@ +import { native } from "./bridge"; + +export enum BaseDirectory { + Audio = 1, + Cache = 2, + Config = 3, + Data = 4, + LocalData = 6, + Desktop = 7, + Document = 8, + Download = 9, + Home = 11, + Picture = 15, + Public = 18, + Resource = 20, + Temp = 21, + Template = 22, + Video = 24, + AppConfig = 25, + AppData = 26, + AppLocalData = 27, + AppCache = 28, + AppLog = 29, +} +export interface FsOptions { + baseDir?: BaseDirectory; +} +export interface RemoveOptions extends FsOptions { + recursive?: boolean; +} +export interface MkdirOptions extends FsOptions { + recursive?: boolean; +} +export interface DirEntry { + name: string; + isDirectory: boolean; + isFile: boolean; + isSymlink: boolean; +} +export interface FileInfo { + size: number; + isFile: boolean; + isDirectory: boolean; + isSymlink: boolean; + mtime: Date | null; + atime: Date | null; + birthtime: Date | null; +} + +const options = (path: string, value?: FsOptions) => ({ + path, + baseDir: value?.baseDir, +}); +export const exists = (path: string, value?: FsOptions) => + native("fs.exists", options(path, value)); +export const readFile = (path: string, value?: FsOptions) => + native("fs.readFile", options(path, value)).then( + (bytes) => new Uint8Array(bytes), + ); +export const readTextFile = (path: string, value?: FsOptions) => + native("fs.readTextFile", options(path, value)); +export const writeFile = (path: string, data: Uint8Array, value?: FsOptions) => + native("fs.writeFile", { + ...options(path, value), + data: Array.from(data), + }); +export const writeTextFile = ( + path: string, + contents: string, + value?: FsOptions, +) => native("fs.writeTextFile", { ...options(path, value), contents }); +export const remove = (path: string, value?: RemoveOptions) => + native("fs.remove", { + ...options(path, value), + recursive: value?.recursive, + }); +export const mkdir = (path: string, value?: MkdirOptions) => + native("fs.mkdir", { + ...options(path, value), + recursive: value?.recursive, + }); +export const readDir = (path: string, value?: FsOptions) => + native("fs.readDir", options(path, value)); +export const stat = (path: string, value?: FsOptions) => + native("fs.stat", options(path, value)); +export const lstat = stat; +export const copyFile = ( + _source: string, + _destination: string, + _options?: FsOptions, +) => Promise.reject(new Error("copyFile is not implemented")); +export const rename = ( + _oldPath: string, + _newPath: string, + _options?: FsOptions, +) => Promise.reject(new Error("rename is not implemented")); +export const truncate = ( + _path: string, + _length?: number, + _options?: FsOptions, +) => Promise.reject(new Error("truncate is not implemented")); diff --git a/apps/desktop/src/electron/http.ts b/apps/desktop/src/electron/http.ts new file mode 100644 index 00000000000..0a09b555305 --- /dev/null +++ b/apps/desktop/src/electron/http.ts @@ -0,0 +1 @@ +export const fetch = globalThis.fetch.bind(globalThis); diff --git a/apps/desktop/src/electron/menu.ts b/apps/desktop/src/electron/menu.ts new file mode 100644 index 00000000000..96bf5a51125 --- /dev/null +++ b/apps/desktop/src/electron/menu.ts @@ -0,0 +1,156 @@ +import { native } from "./bridge"; +import type { LogicalPosition, PhysicalPosition } from "./dpi"; +import { listen } from "./event"; + +let nextMenuId = 1; + +export interface MenuItemOptions { + id?: string; + text?: string; + enabled?: boolean; + accelerator?: string; + action?: (id: string) => unknown; + items?: MenuEntry[]; +} + +export interface CheckMenuItemOptions extends MenuItemOptions { + checked?: boolean; +} +export interface PredefinedMenuItemOptions { + item: string; + text?: string; +} + +export class MenuItem { + id: string; + text?: string; + enabled?: boolean; + accelerator?: string; + action?: (id: string) => unknown; + items?: MenuEntry[]; + + constructor(options: MenuItemOptions) { + this.id = options.id ?? `cap-menu-${nextMenuId++}`; + Object.assign(this, options); + } + + static async new(options: MenuItemOptions) { + return new MenuItem(options); + } + async setText(text: string) { + this.text = text; + } + async setEnabled(enabled: boolean) { + this.enabled = enabled; + } +} + +export class CheckMenuItem extends MenuItem { + checked: boolean; + constructor(options: CheckMenuItemOptions) { + super(options); + this.checked = options.checked ?? false; + } + static async new(options: CheckMenuItemOptions) { + return new CheckMenuItem(options); + } + async setChecked(checked: boolean) { + this.checked = checked; + } + async isChecked() { + return this.checked; + } +} + +export class PredefinedMenuItem extends MenuItem { + item: string; + constructor(options: PredefinedMenuItemOptions) { + super({ text: options.text }); + this.item = options.item; + } + static async new(options: PredefinedMenuItemOptions) { + return new PredefinedMenuItem(options); + } +} + +export class Submenu extends MenuItem { + static async new(options: MenuItemOptions) { + return new Submenu(options); + } +} + +export type MenuEntry = + | MenuItem + | CheckMenuItem + | PredefinedMenuItem + | Submenu + | MenuItemOptions; + +export class Menu { + constructor(public items: MenuEntry[]) {} + static async new(options: { id?: string; items?: MenuEntry[] } = {}) { + return new Menu(options.items ?? []); + } + + async popup(position?: LogicalPosition | PhysicalPosition) { + const unlisteners = await Promise.all( + this.items + .flatMap(flattenItems) + .filter((item) => item.action) + .map(async (item) => { + const id = item.id ?? `cap-menu-${nextMenuId++}`; + item.id = id; + return listen(`menu:${id}`, () => item.action?.(id)); + }), + ); + await native("menu.popup", { + items: this.items.map(serializeItem), + position: position ? { x: position.x, y: position.y } : undefined, + }); + setTimeout(() => { + unlisteners.forEach((unlisten) => { + unlisten(); + }); + }, 30000); + } + + async append(item: MenuEntry) { + this.items.push(item); + } + async prepend(item: MenuEntry) { + this.items.unshift(item); + } + async insert(item: MenuEntry, position: number) { + this.items.splice(position, 0, item); + } + async remove(item: MenuEntry) { + this.items = this.items.filter((entry) => entry !== item); + } + async removeAt(position: number) { + return this.items.splice(position, 1)[0] ?? null; + } + async itemsList() { + return this.items; + } + async close() {} +} + +function flattenItems(item: MenuEntry): MenuEntry[] { + return [item, ...(item.items?.flatMap(flattenItems) ?? [])]; +} + +function serializeItem(item: MenuEntry): Record { + if ( + item instanceof PredefinedMenuItem && + item.item.toLowerCase() === "separator" + ) + return { type: "separator" }; + return { + id: item.id, + text: item.text, + enabled: item.enabled, + accelerator: item.accelerator, + checked: item instanceof CheckMenuItem ? item.checked : undefined, + items: item.items?.map(serializeItem), + }; +} diff --git a/apps/desktop/src/electron/notification.ts b/apps/desktop/src/electron/notification.ts new file mode 100644 index 00000000000..a9a70f19081 --- /dev/null +++ b/apps/desktop/src/electron/notification.ts @@ -0,0 +1,28 @@ +import { native } from "./bridge"; +export type Permission = "granted" | "denied" | "default"; +export const isPermissionGranted = () => + native("notification.permission").then( + (value) => value === "granted", + ); +export const requestPermission = () => + native("notification.requestPermission"); +export const sendNotification = ( + options: string | { title: string; body?: string; icon?: string }, +) => + native( + "notification.show", + typeof options === "string" ? { title: options } : options, + ); +export const registerActionTypes = () => Promise.resolve(); +export const pending = () => Promise.resolve([]); +export const cancel = () => Promise.resolve(); +export const cancelAll = () => Promise.resolve(); +export const active = () => Promise.resolve([]); +export const removeActive = () => Promise.resolve(); +export const removeAllActive = () => Promise.resolve(); +export const createChannel = () => Promise.resolve(); +export const deleteChannel = () => Promise.resolve(); +export const listChannels = () => Promise.resolve([]); +export const channels = listChannels; +export const onAction = () => Promise.resolve(() => {}); +export const onNotificationReceived = () => Promise.resolve(() => {}); diff --git a/apps/desktop/src/electron/opener.ts b/apps/desktop/src/electron/opener.ts new file mode 100644 index 00000000000..52b2096fc7f --- /dev/null +++ b/apps/desktop/src/electron/opener.ts @@ -0,0 +1,7 @@ +import { native } from "./bridge"; +export const openPath = (path: string) => + native("opener.openPath", { path }); +export const openUrl = (url: string) => + native("shell.open", { path: url }); +export const revealItemInDir = (path: string) => + native("opener.revealItemInDir", { path }); diff --git a/apps/desktop/src/electron/os.ts b/apps/desktop/src/electron/os.ts new file mode 100644 index 00000000000..37113aa56ac --- /dev/null +++ b/apps/desktop/src/electron/os.ts @@ -0,0 +1,26 @@ +import { native } from "./bridge"; + +export type OsType = "linux" | "macos" | "ios" | "android" | "windows"; +export type Arch = + | "x86" + | "x86_64" + | "arm" + | "aarch64" + | "mips" + | "mips64" + | "powerpc" + | "powerpc64" + | "riscv64" + | "s390x" + | "sparc64"; + +import { bridge } from "./bridge"; +export const arch = () => bridge().os.arch as Arch; +export const exeExtension = () => (type() === "windows" ? ".exe" : ""); +export const family = () => (type() === "windows" ? "windows" : "unix"); +export const hostname = () => native("os.hostname"); +export const locale = () => native("os.locale"); +export const platform = () => bridge().os.platform; +export const type = () => bridge().os.type as OsType; +export const version = () => bridge().os.version; +export const eol = () => "\n"; diff --git a/apps/desktop/src/electron/path.test.ts b/apps/desktop/src/electron/path.test.ts new file mode 100644 index 00000000000..b3e86a89bfd --- /dev/null +++ b/apps/desktop/src/electron/path.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { basename } from "./path"; + +describe("Electron path basename", () => { + it("keeps the filename when no suffix is provided", async () => { + await expect(basename("/tmp/video.mp4")).resolves.toBe("video.mp4"); + await expect(basename("/tmp/video.mp4", "")).resolves.toBe("video.mp4"); + }); + + it("removes only a matching trailing suffix", async () => { + await expect(basename("/tmp/video.mp4", ".mp4")).resolves.toBe("video"); + await expect(basename("/tmp/.mp4-video", ".mp4")).resolves.toBe( + ".mp4-video", + ); + await expect(basename("C:\\tmp\\video.mp4", ".mov")).resolves.toBe( + "video.mp4", + ); + }); +}); diff --git a/apps/desktop/src/electron/path.ts b/apps/desktop/src/electron/path.ts new file mode 100644 index 00000000000..bff11bcd50d --- /dev/null +++ b/apps/desktop/src/electron/path.ts @@ -0,0 +1,52 @@ +import { native } from "./bridge"; + +export const appCacheDir = () => native("path.cacheDir"); +export const appConfigDir = () => native("path.configDir"); +export const appDataDir = () => native("path.appDataDir"); +export const appLocalDataDir = () => native("path.appLocalDataDir"); +export const appLogDir = () => + native("path.appDataDir").then((value) => join(value, "logs")); +export const audioDir = () => native("path.audioDir"); +export const cacheDir = () => native("path.cacheDir"); +export const configDir = () => native("path.configDir"); +export const dataDir = () => native("path.dataDir"); +export const desktopDir = () => native("path.desktopDir"); +export const documentDir = () => native("path.documentDir"); +export const downloadDir = () => native("path.downloadDir"); +export const executableDir = () => native("path.resourceDir"); +export const homeDir = () => native("path.homeDir"); +export const localDataDir = () => native("path.appLocalDataDir"); +export const pictureDir = () => native("path.pictureDir"); +export const publicDir = () => native("path.homeDir"); +export const resourceDir = () => native("path.resourceDir"); +export const runtimeDir = () => native("path.tempDir"); +export const templateDir = () => native("path.homeDir"); +export const videoDir = () => native("path.videoDir"); +export const resolveResource = (resourcePath: string) => + native("path.resolve", { paths: [resourcePath] }).then(async () => { + const root = await resourceDir(); + return join(root, resourcePath); + }); +export const tempDir = () => native("path.tempDir"); +export const join = (...paths: string[]) => + native("path.join", { paths }); +export const resolve = (...paths: string[]) => + native("path.resolve", { paths }); +export const normalize = (path: string) => + native("path.resolve", { paths: [path] }); +export const dirname = (path: string) => + native("path.resolve", { paths: [path, ".."] }); +export const extname = (path: string) => + Promise.resolve(path.match(/\.[^./\\]+$/)?.[0] ?? ""); +export const basename = (path: string, ext?: string) => { + const name = path.split(/[\\/]/).pop() ?? ""; + return Promise.resolve( + ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name, + ); +}; +export const isAbsolute = (path: string) => + Promise.resolve(/^(?:[a-zA-Z]:[\\/]|\/)/.test(path)); +export const sep = () => + native("os.type").then((value) => (value === "windows" ? "\\" : "/")); +export const delimiter = () => + native("os.type").then((value) => (value === "windows" ? ";" : ":")); diff --git a/apps/desktop/src/electron/process.ts b/apps/desktop/src/electron/process.ts new file mode 100644 index 00000000000..1e0a1319e09 --- /dev/null +++ b/apps/desktop/src/electron/process.ts @@ -0,0 +1,3 @@ +import { native } from "./bridge"; +export const exit = (exitCode = 0) => native("app.exit", { exitCode }); +export const relaunch = () => native("app.relaunch"); diff --git a/apps/desktop/src/electron/shell.ts b/apps/desktop/src/electron/shell.ts new file mode 100644 index 00000000000..368864afad4 --- /dev/null +++ b/apps/desktop/src/electron/shell.ts @@ -0,0 +1,52 @@ +import { native } from "./bridge"; + +export const open = (path: string, _openWith?: string) => + native("shell.open", { path }); + +export class Command { + constructor( + public program: string, + public args: string[] = [], + public options: Record = {}, + ) {} + static create( + program: string, + args: string[] = [], + options: Record = {}, + ) { + return new Command(program, args, options); + } + static sidecar( + program: string, + args: string[] = [], + options: Record = {}, + ) { + return new Command(program, args, options); + } + execute() { + return native("shell.execute", { + program: this.program, + args: this.args, + options: this.options, + }); + } + spawn() { + return native("shell.spawn", { + program: this.program, + args: this.args, + options: this.options, + }); + } +} + +export interface CommandOutput { + code: number; + signal: number | null; + stdout: string; + stderr: string; +} +export interface Child { + pid: number; + kill(): Promise; + write(data: string | Uint8Array): Promise; +} diff --git a/apps/desktop/src/electron/store.ts b/apps/desktop/src/electron/store.ts new file mode 100644 index 00000000000..15ee41cbacb --- /dev/null +++ b/apps/desktop/src/electron/store.ts @@ -0,0 +1,87 @@ +import { native } from "./bridge"; + +export class Store { + private data = new Map(); + private loaded: Promise; + + constructor( + public path: string, + _options?: { autoSave?: boolean }, + ) { + this.loaded = native>("store.load", { path }).then( + (values) => { + this.data = new Map(Object.entries(values)); + }, + ); + } + + static async load(path: string, options?: { autoSave?: boolean }) { + const store = new Store(path, options); + await store.loaded; + return store; + } + + async set(key: string, value: unknown) { + await this.loaded; + this.data.set(key, value); + } + async get(key: string): Promise { + await this.loaded; + return this.data.get(key) as T | undefined; + } + async has(key: string) { + await this.loaded; + return this.data.has(key); + } + async delete(key: string) { + await this.loaded; + return this.data.delete(key); + } + async clear() { + await this.loaded; + this.data.clear(); + } + async reset() { + return this.clear(); + } + async keys() { + await this.loaded; + return [...this.data.keys()]; + } + async values() { + await this.loaded; + return [...this.data.values()]; + } + async entries() { + await this.loaded; + return [...this.data.entries()]; + } + async length() { + await this.loaded; + return this.data.size; + } + async save() { + await this.loaded; + return native("store.save", { + path: this.path, + value: Object.fromEntries(this.data), + }); + } + async reload() { + const values = await native>("store.load", { + path: this.path, + }); + this.data = new Map(Object.entries(values)); + } + async onKeyChange( + _key: string, + _callback: (value: T | undefined) => void, + ) { + return () => {}; + } + async onChange(_callback: (key: string, value: T | undefined) => void) { + return () => {}; + } +} + +export const load = Store.load; diff --git a/apps/desktop/src/electron/updater.ts b/apps/desktop/src/electron/updater.ts new file mode 100644 index 00000000000..ddfd462b46e --- /dev/null +++ b/apps/desktop/src/electron/updater.ts @@ -0,0 +1,23 @@ +export interface CheckOptions { + headers?: Record; + timeout?: number; + target?: string; +} +export interface DownloadEvent { + event: "Started" | "Progress" | "Finished"; + data?: { contentLength?: number; chunkLength?: number }; +} +export interface Update { + version: string; + currentVersion: string; + date?: string; + body?: string; + rawJson: Record; + download(listener?: (event: DownloadEvent) => void): Promise; + install(): Promise; + downloadAndInstall(listener?: (event: DownloadEvent) => void): Promise; + close(): Promise; +} +export async function check(_options?: CheckOptions): Promise { + return null; +} diff --git a/apps/desktop/src/electron/webviewWindow.ts b/apps/desktop/src/electron/webviewWindow.ts new file mode 100644 index 00000000000..99409f38d73 --- /dev/null +++ b/apps/desktop/src/electron/webviewWindow.ts @@ -0,0 +1,65 @@ +import { bridge, native } from "./bridge"; +import { Window } from "./window"; + +export interface WebviewWindowOptions { + url?: string; + title?: string; + x?: number; + y?: number; + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + transparent?: boolean; + decorations?: boolean; + resizable?: boolean; + alwaysOnTop?: boolean; + visibleOnAllWorkspaces?: boolean; + skipTaskbar?: boolean; + contentProtected?: boolean; + focus?: boolean; + visible?: boolean; + initialization?: Record; + [key: string]: unknown; +} + +export class WebviewWindow extends Window { + constructor(label: string, options?: WebviewWindowOptions) { + super(label); + if (options) { + void native("window.create", { + label, + route: options.url ?? "/", + ...options, + initialization: options.initialization ?? null, + }); + } + } + + static async getByLabel(label: string) { + const labels = await native("window.all"); + return labels.includes(label) ? new WebviewWindow(label) : null; + } + + static async getAll() { + return (await native("window.all")).map( + (label) => new WebviewWindow(label), + ); + } + + static getCurrent() { + return new WebviewWindow(bridge().windowLabel); + } + + emitTo(_target: string, event: string, payload?: unknown) { + return this.emit(event, payload); + } +} + +export function getCurrentWebviewWindow() { + return new WebviewWindow(bridge().windowLabel); +} + +export function getAllWebviewWindows() { + return WebviewWindow.getAll(); +} diff --git a/apps/desktop/src/electron/window.ts b/apps/desktop/src/electron/window.ts new file mode 100644 index 00000000000..77b0d57b259 --- /dev/null +++ b/apps/desktop/src/electron/window.ts @@ -0,0 +1,466 @@ +import { bridge, native } from "./bridge"; +import { + LogicalPosition, + LogicalSize, + PhysicalPosition, + PhysicalSize, + type Position, + type Size, +} from "./dpi"; +import { type Event, emit, listen, once, type UnlistenFn } from "./event"; + +export enum UserAttentionType { + Critical = 1, + Informational = 2, +} + +export enum ProgressBarStatus { + None = "none", + Normal = "normal", + Indeterminate = "indeterminate", + Paused = "paused", + Error = "error", +} + +export enum Effect { + AppearanceBased = "appearanceBased", + Light = "light", + Dark = "dark", + MediumLight = "mediumLight", + UltraDark = "ultraDark", + Titlebar = "titlebar", + Selection = "selection", + Menu = "menu", + Popover = "popover", + Sidebar = "sidebar", + HeaderView = "headerView", + Sheet = "sheet", + WindowBackground = "windowBackground", + HudWindow = "hudWindow", + FullScreenUI = "fullScreenUI", + Tooltip = "tooltip", + ContentBackground = "contentBackground", + UnderWindowBackground = "underWindowBackground", + UnderPageBackground = "underPageBackground", + Mica = "mica", + MicaDark = "micaDark", + MicaLight = "micaLight", + Tabbed = "tabbed", + TabbedDark = "tabbedDark", + TabbedLight = "tabbedLight", +} + +export enum EffectState { + FollowsWindowActiveState = "followsWindowActiveState", + Active = "active", + Inactive = "inactive", +} + +export interface Effects { + effects: Effect[]; + state?: EffectState; + radius?: number; + color?: string; +} + +export type Theme = "light" | "dark"; + +interface WindowState { + x: number; + y: number; + width: number; + height: number; + visible: boolean; + focused: boolean; + minimized: boolean; + maximized: boolean; + fullscreen: boolean; + alwaysOnTop: boolean; + resizable?: boolean; + decorated?: boolean; + scaleFactor: number; +} + +export interface Monitor { + name: string | null; + scaleFactor: number; + position: PhysicalPosition; + size: PhysicalSize; + workArea: { position: PhysicalPosition; size: PhysicalSize }; +} + +interface SerializedMonitor { + name: string | null; + scaleFactor: number; + position: { x: number; y: number }; + size: { width: number; height: number }; + workArea: { + position: { x: number; y: number }; + size: { width: number; height: number }; + }; +} + +function hydrateMonitor(monitor: SerializedMonitor | null): Monitor | null { + if (!monitor) return null; + return { + ...monitor, + position: new PhysicalPosition(monitor.position.x, monitor.position.y), + size: new PhysicalSize(monitor.size.width, monitor.size.height), + workArea: { + position: new PhysicalPosition( + monitor.workArea.position.x, + monitor.workArea.position.y, + ), + size: new PhysicalSize( + monitor.workArea.size.width, + monitor.workArea.size.height, + ), + }, + }; +} + +export class CloseRequestedEvent { + event = "tauri://close-requested"; + id = 0; + payload = undefined; + preventDefault() {} +} + +export class Window { + constructor(public label: string) {} + + static async getByLabel(label: string) { + const labels = await native("window.all"); + return labels.includes(label) ? new Window(label) : null; + } + + static async getAll() { + return (await native("window.all")).map( + (label) => new Window(label), + ); + } + + private action(action: string, value?: Record) { + return native("window.action", { label: this.label, action, value }); + } + + private state() { + return this.action("state"); + } + + async scaleFactor() { + return (await this.state()).scaleFactor; + } + async innerPosition() { + const state = await this.state(); + return new PhysicalPosition(state.x, state.y); + } + async outerPosition() { + return this.innerPosition(); + } + async innerSize() { + const state = await this.state(); + return new PhysicalSize(state.width, state.height); + } + async outerSize() { + return this.innerSize(); + } + async isFullscreen() { + return (await this.state()).fullscreen; + } + async isMinimized() { + return (await this.state()).minimized; + } + async isMaximized() { + return (await this.state()).maximized; + } + async isFocused() { + return (await this.state()).focused; + } + async isDecorated() { + return (await this.state()).decorated ?? false; + } + async isResizable() { + return (await this.state()).resizable ?? true; + } + async isMaximizable() { + return true; + } + async isMinimizable() { + return true; + } + async isClosable() { + return true; + } + async isVisible() { + return (await this.state()).visible; + } + async title() { + return document.title; + } + async currentMonitor() { + return hydrateMonitor( + await native("window.currentMonitor"), + ); + } + async primaryMonitor() { + return hydrateMonitor( + await native("window.primaryMonitor"), + ); + } + async monitorFromPoint(x: number, y: number) { + return hydrateMonitor( + await native("window.monitorFromPoint", { + x, + y, + }), + ); + } + async availableMonitors() { + return (await native("window.availableMonitors")).map( + (monitor) => hydrateMonitor(monitor) as Monitor, + ); + } + async theme(): Promise { + return native("theme.get"); + } + + center() { + return this.action("center"); + } + requestUserAttention(requestType: UserAttentionType | null) { + return this.action("requestUserAttention", { + critical: requestType === UserAttentionType.Critical, + }); + } + setResizable(resizable: boolean) { + return this.action("setResizable", { resizable }); + } + setEnabled(_enabled: boolean) { + return Promise.resolve(); + } + setMaximizable(_maximizable: boolean) { + return Promise.resolve(); + } + setMinimizable(_minimizable: boolean) { + return Promise.resolve(); + } + setClosable(_closable: boolean) { + return Promise.resolve(); + } + setTitle(title: string) { + return this.action("setTitle", { title }); + } + maximize() { + return this.action("maximize"); + } + unmaximize() { + return this.action("unmaximize"); + } + toggleMaximize = async () => + (await this.isMaximized()) ? this.unmaximize() : this.maximize(); + minimize = () => this.action("minimize"); + unminimize() { + return this.action("unminimize"); + } + show() { + return this.action("show"); + } + hide() { + return this.action("hide"); + } + close = () => this.action("close"); + destroy() { + return this.action("destroy"); + } + setFocus() { + return this.action("focus"); + } + setAlwaysOnTop(alwaysOnTop: boolean) { + return this.action("setAlwaysOnTop", { alwaysOnTop }); + } + setContentProtected(enabled: boolean) { + return this.action("setContentProtected", { enabled }); + } + setSize(size: Size) { + return this.action("setSize", { + width: size.width, + height: size.height, + physical: size instanceof PhysicalSize, + }); + } + setMinSize(size: Size | null) { + return this.action("setMinimumSize", { + width: size?.width, + height: size?.height, + physical: size instanceof PhysicalSize, + }); + } + setMaxSize(_size: Size | null) { + return Promise.resolve(); + } + setPosition(position: Position) { + return this.action("setPosition", { + x: position.x, + y: position.y, + physical: position instanceof PhysicalPosition, + }); + } + setFullscreen(fullscreen: boolean) { + return this.action("setFullscreen", { fullscreen }); + } + setEffects(_effects: Effects) { + return Promise.resolve(); + } + setProgressBar(options: { progress?: number; status?: ProgressBarStatus }) { + const progress = + options.status === ProgressBarStatus.None + ? null + : options.progress === undefined + ? 2 + : options.progress / 100; + return this.action("setProgress", { progress }); + } + setIcon(_icon: string | Uint8Array | ArrayBuffer | number[]) { + return Promise.resolve(); + } + setSkipTaskbar(skip: boolean) { + return this.action("setSkipTaskbar", { skip }); + } + setCursorGrab(_grab: boolean) { + return Promise.resolve(); + } + setCursorVisible(_visible: boolean) { + return Promise.resolve(); + } + setCursorIcon(_icon: string) { + return Promise.resolve(); + } + setCursorPosition(position: Position) { + return this.action("setCursorPosition", { + x: position.x, + y: position.y, + }); + } + setIgnoreCursorEvents(ignore: boolean) { + return this.action("setIgnoreCursorEvents", { ignore }); + } + startDragging() { + return Promise.resolve(); + } + startResizeDragging(_direction: string) { + return Promise.resolve(); + } + setBadgeCount(_count?: number) { + return Promise.resolve(); + } + setBadgeLabel(_label?: string) { + return Promise.resolve(); + } + setOverlayIcon(_icon?: unknown) { + return Promise.resolve(); + } + setShadow(_enable: boolean) { + return Promise.resolve(); + } + setTheme(_theme: Theme | null) { + return Promise.resolve(); + } + + listen(event: string, handler: (event: Event) => void) { + return listen(event, handler); + } + once(event: string, handler: (event: Event) => void) { + return once(event, handler); + } + emit(event: string, payload?: unknown) { + return emit(event, payload); + } + onResized( + handler: (event: Event) => void, + ): Promise { + return listen<{ width: number; height: number }>( + "tauri://resize", + (event) => + handler({ + ...event, + payload: new PhysicalSize(event.payload.width, event.payload.height), + }), + ); + } + onMoved( + handler: (event: Event) => void, + ): Promise { + return listen<{ x: number; y: number }>("tauri://move", (event) => + handler({ + ...event, + payload: new PhysicalPosition(event.payload.x, event.payload.y), + }), + ); + } + onCloseRequested( + handler: (event: CloseRequestedEvent) => void, + ): Promise { + return listen("tauri://close-requested", handler as never); + } + onDestroyed(handler: (event: Event) => void): Promise { + return listen("tauri://destroyed", handler); + } + onFocusChanged( + handler: (event: Event) => void, + ): Promise { + return listen("tauri://focus", handler); + } + onScaleChanged( + handler: ( + event: Event<{ scaleFactor: number; size: PhysicalSize }>, + ) => void, + ): Promise { + return listen<{ + scaleFactor: number; + size: { width: number; height: number }; + }>("tauri://scale-change", (event) => + handler({ + ...event, + payload: { + scaleFactor: event.payload.scaleFactor, + size: new PhysicalSize( + event.payload.size.width, + event.payload.size.height, + ), + }, + }), + ); + } + onThemeChanged(handler: (event: Event) => void): Promise { + return listen("tauri://theme-changed", handler); + } + onDragDropEvent( + handler: (event: Event) => void, + ): Promise { + return listen("tauri://drag-drop", handler); + } +} + +export function getCurrentWindow() { + return new Window(bridge().windowLabel); +} + +export function getAllWindows() { + return Window.getAll(); +} + +export async function currentMonitor() { + return getCurrentWindow().currentMonitor(); +} +export async function primaryMonitor() { + return getCurrentWindow().primaryMonitor(); +} +export async function monitorFromPoint(x: number, y: number) { + return getCurrentWindow().monitorFromPoint(x, y); +} +export async function availableMonitors() { + return getCurrentWindow().availableMonitors(); +} + +export { LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize }; diff --git a/apps/desktop/src/entry-client.tsx b/apps/desktop/src/entry-client.tsx index 4891061a043..344c2df455c 100644 --- a/apps/desktop/src/entry-client.tsx +++ b/apps/desktop/src/entry-client.tsx @@ -2,7 +2,7 @@ import { mount, StartClient } from "@solidjs/start/client"; function initPlatformClass() { - import("@tauri-apps/plugin-os") + import("~/electron/os") .then(({ type }) => { const osType = type(); document.documentElement.classList.add(`platform-${osType}`); diff --git a/apps/desktop/src/routes/(window-chrome).tsx b/apps/desktop/src/routes/(window-chrome).tsx index 8bb12b3c179..526f2f3c3fe 100644 --- a/apps/desktop/src/routes/(window-chrome).tsx +++ b/apps/desktop/src/routes/(window-chrome).tsx @@ -1,7 +1,4 @@ import { type RouteSectionProps, useLocation } from "@solidjs/router"; -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, @@ -10,10 +7,12 @@ import { type ParentProps, Suspense, } from "solid-js"; - import { AbsoluteInsetLoader } from "~/components/Loader"; import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import type { UnlistenFn } from "~/electron/event"; +import { type as ostype } from "~/electron/os"; +import { getCurrentWindow } from "~/electron/window"; import { applyMacOSWindowMaterial } from "~/utils/macos-window-material"; import { initializeTitlebar } from "~/utils/titlebar-state"; import { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx index 4321547bb6d..b2322282cad 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -1,5 +1,4 @@ import { createTimer } from "@solid-primitives/timer"; -import { CheckMenuItem, Menu, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { cx } from "cva"; import { type Component, @@ -8,6 +7,7 @@ import { createSignal, Show, } from "solid-js"; +import { CheckMenuItem, Menu, PredefinedMenuItem } from "~/electron/menu"; import { trackEvent } from "~/utils/analytics"; import { createCurrentRecordingQuery } from "~/utils/queries"; import { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx index 132b271d45e..26939ef2b95 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/ChangeLogButton.tsx @@ -1,9 +1,9 @@ import { makePersisted } from "@solid-primitives/storage"; -import { getVersion } from "@tauri-apps/api/app"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { createEffect, createResource } from "solid-js"; import { createStore } from "solid-js/store"; import Tooltip from "~/components/Tooltip"; +import { getVersion } from "~/electron/app"; +import { getCurrentWindow } from "~/electron/window"; import { commands } from "~/utils/tauri"; import { apiClient } from "~/utils/web-api"; import IconLucideBell from "~icons/lucide/bell"; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx index d7365f9e338..d866c473656 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx @@ -1,4 +1,3 @@ -import { CheckMenuItem, Menu, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { cx } from "cva"; import { type Component, @@ -6,6 +5,7 @@ import { createSignal, Show, } from "solid-js"; +import { CheckMenuItem, Menu, PredefinedMenuItem } from "~/electron/menu"; import { trackEvent } from "~/utils/analytics"; import { createTauriEventListener } from "~/utils/createEventListener"; import { createCurrentRecordingQuery } from "~/utils/queries"; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/Recents.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/Recents.tsx index 6d7fe4d83bf..268456c6ec5 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/Recents.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/Recents.tsx @@ -1,4 +1,3 @@ -import { convertFileSrc } from "@tauri-apps/api/core"; import { cx } from "cva"; import { createEffect, @@ -10,6 +9,7 @@ import { onMount, Show, } from "solid-js"; +import { convertFileSrc } from "~/electron/core"; import IconLucideClapperboard from "~icons/lucide/clapperboard"; import IconLucideHistory from "~icons/lucide/history"; import IconLucideImage from "~icons/lucide/image"; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx index c01e68f7593..8290b2a1eb5 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx @@ -1,13 +1,13 @@ import { ProgressCircle } from "@cap/ui-solid"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { ask, save } from "@tauri-apps/plugin-dialog"; -import { remove } from "@tauri-apps/plugin-fs"; -import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import type { ComponentProps } from "solid-js"; import { createMemo, createSignal, Show, splitProps } from "solid-js"; import toast from "solid-toast"; import Tooltip from "~/components/Tooltip"; +import { convertFileSrc } from "~/electron/core"; +import { ask, save } from "~/electron/dialog"; +import { remove } from "~/electron/fs"; +import * as shell from "~/electron/shell"; import { createScreenshotShareLinkFromProjectPath, type ScreenshotExportStatus, diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 3beeea4d6c4..7b4f14431f8 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -6,21 +6,6 @@ import { useQuery, useQueryClient, } from "@tanstack/solid-query"; -import { Channel } from "@tauri-apps/api/core"; -import { emit, listen } from "@tauri-apps/api/event"; -import { - getAllWebviewWindows, - WebviewWindow, -} from "@tauri-apps/api/webviewWindow"; -import { - currentMonitor, - getCurrentWindow, - LogicalSize, - PhysicalPosition, -} from "@tauri-apps/api/window"; -import * as dialog from "@tauri-apps/plugin-dialog"; -import { relaunch } from "@tauri-apps/plugin-process"; -import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { createEffect, @@ -40,6 +25,18 @@ import { Transition } from "solid-transition-group"; import Mode from "~/components/Mode"; import { RecoveryToast } from "~/components/RecoveryToast"; import Tooltip from "~/components/Tooltip"; +import { Channel } from "~/electron/core"; +import * as dialog from "~/electron/dialog"; +import { emit, listen } from "~/electron/event"; +import { relaunch } from "~/electron/process"; +import * as shell from "~/electron/shell"; +import { getAllWebviewWindows, WebviewWindow } from "~/electron/webviewWindow"; +import { + currentMonitor, + getCurrentWindow, + LogicalSize, + PhysicalPosition, +} from "~/electron/window"; import { Input } from "~/routes/editor/ui"; import { authStore, diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts b/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts index cc448962f78..19842e3f23a 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts +++ b/apps/desktop/src/routes/(window-chrome)/new-main/useRequestPermission.ts @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/solid-query"; -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { getCurrentWindow } from "~/electron/window"; import { devicesSnapshot } from "~/utils/devices"; import { requestAndVerifyPermission } from "~/utils/os-permissions"; import { commands, type OSPermissionStatus } from "~/utils/tauri"; diff --git a/apps/desktop/src/routes/(window-chrome)/onboarding.tsx b/apps/desktop/src/routes/(window-chrome)/onboarding.tsx index b6383987a8f..7d9c7eef5bb 100644 --- a/apps/desktop/src/routes/(window-chrome)/onboarding.tsx +++ b/apps/desktop/src/routes/(window-chrome)/onboarding.tsx @@ -1,10 +1,5 @@ import { Button } from "@cap/ui-solid"; import { makePersisted } from "@solid-primitives/storage"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { ask } from "@tauri-apps/plugin-dialog"; -import { type as ostype } from "@tauri-apps/plugin-os"; -import { relaunch } from "@tauri-apps/plugin-process"; -import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { createEffect, @@ -17,6 +12,11 @@ import { Show, } from "solid-js"; import { createStore } from "solid-js/store"; +import { ask } from "~/electron/dialog"; +import { type as ostype } from "~/electron/os"; +import { relaunch } from "~/electron/process"; +import * as shell from "~/electron/shell"; +import { getCurrentWindow } from "~/electron/window"; import { generalSettingsStore } from "~/store"; import { isPermissionGranted as isPermitted, diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index ea3edf9ddea..d0f4bd02023 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -1,11 +1,6 @@ import { Button } from "@cap/ui-solid"; import { A, type RouteSectionProps, useNavigate } from "@solidjs/router"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; -import { getVersion } from "@tauri-apps/api/app"; -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; -import * as dialog from "@tauri-apps/plugin-dialog"; -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; -import * as shell from "@tauri-apps/plugin-shell"; import { createEffect, createMemo, @@ -20,6 +15,11 @@ import { import toast from "solid-toast"; import { CapErrorBoundary } from "~/components/CapErrorBoundary"; import { SignInButton } from "~/components/SignInButton"; +import { getVersion } from "~/electron/app"; +import { writeText } from "~/electron/clipboard"; +import * as dialog from "~/electron/dialog"; +import { fetch as tauriFetch } from "~/electron/http"; +import * as shell from "~/electron/shell"; import { authStore, userProfileStore } from "~/store"; import { resetUser, trackEvent } from "~/utils/analytics"; diff --git a/apps/desktop/src/routes/(window-chrome)/settings/automations.tsx b/apps/desktop/src/routes/(window-chrome)/settings/automations.tsx index ff1a8555740..ea220e269ed 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/automations.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/automations.tsx @@ -1,6 +1,4 @@ import { Button } from "@cap/ui-solid"; -import { CheckMenuItem, Menu } from "@tauri-apps/api/menu"; -import { open } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { type Component, @@ -15,6 +13,8 @@ import { createStore, produce } from "solid-js/store"; import { Dynamic } from "solid-js/web"; import toast from "solid-toast"; import { Toggle } from "~/components/Toggle"; +import { open } from "~/electron/dialog"; +import { CheckMenuItem, Menu } from "~/electron/menu"; import { presetsStore } from "~/store"; import { ACTION_LABELS, diff --git a/apps/desktop/src/routes/(window-chrome)/settings/cli.tsx b/apps/desktop/src/routes/(window-chrome)/settings/cli.tsx index 715bf935db0..7f88369ec82 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/cli.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/cli.tsx @@ -1,8 +1,8 @@ import { Button } from "@cap/ui-solid"; -import { invoke } from "@tauri-apps/api/core"; -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { createResource, createSignal, Show } from "solid-js"; import toast from "solid-toast"; +import { writeText } from "~/electron/clipboard"; +import { invoke } from "~/electron/core"; import { Section, SectionCard, SettingsPageContent } from "./Setting"; type CliInstallStatus = { diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 2e4356f9d0d..f561aaf9d0f 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -1,7 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; -import { type } from "@tauri-apps/plugin-os"; import { createResource, Show } from "solid-js"; import { createStore } from "solid-js/store"; +import { invoke } from "~/electron/core"; +import { type } from "~/electron/os"; import { generalSettingsStore } from "~/store"; import { diff --git a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx index 7a8d83b6d76..239990c17d6 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx @@ -1,10 +1,10 @@ import { Button } from "@cap/ui-solid"; import { action, useAction, useSubmission } from "@solidjs/router"; -import { getVersion } from "@tauri-apps/api/app"; -import { type OsType, type as ostype } from "@tauri-apps/plugin-os"; -import * as shell from "@tauri-apps/plugin-shell"; import { createResource, createSignal, For, Show } from "solid-js"; import toast from "solid-toast"; +import { getVersion } from "~/electron/app"; +import { type OsType, type as ostype } from "~/electron/os"; +import * as shell from "~/electron/shell"; import { commands, type SystemDiagnostics } from "~/utils/tauri"; import { apiClient, protectedHeaders } from "~/utils/web-api"; diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 46e7b205259..36f14cf2755 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -3,12 +3,10 @@ import { createWritableMemo } from "@solid-primitives/memo"; import { isPermissionGranted, requestPermission, -} from "@tauri-apps/plugin-notification"; -import { type OsType, type } from "@tauri-apps/plugin-os"; +} from "~/electron/notification"; +import { type OsType, type } from "~/electron/os"; import "@total-typescript/ts-reset/filter-boolean"; import { Collapsible } from "@kobalte/core/collapsible"; -import { CheckMenuItem, Menu, MenuItem } from "@tauri-apps/api/menu"; -import { confirm } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { createEffect, @@ -25,6 +23,8 @@ import toast from "solid-toast"; import themePreviewAuto from "~/assets/theme-previews/auto.jpg"; import themePreviewDark from "~/assets/theme-previews/dark.jpg"; import themePreviewLight from "~/assets/theme-previews/light.jpg"; +import { confirm } from "~/electron/dialog"; +import { CheckMenuItem, Menu, MenuItem } from "~/electron/menu"; import { Input } from "~/routes/editor/ui"; import { authStore, diff --git a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx index 055d131fa29..44c2b0652d1 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx @@ -1,5 +1,4 @@ import { createEventListener } from "@solid-primitives/event-listener"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { batch, createEffect, @@ -12,6 +11,7 @@ import { Switch, } from "solid-js"; import { createStore } from "solid-js/store"; +import { type as ostype } from "~/electron/os"; import { hotkeysStore } from "~/store"; import { diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 461b804557c..be8ee769088 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -6,10 +6,6 @@ import { queryOptions, useQueryClient, } from "@tanstack/solid-query"; -import { Channel, convertFileSrc } from "@tauri-apps/api/core"; -import { ask, confirm } from "@tauri-apps/plugin-dialog"; -import { remove } from "@tauri-apps/plugin-fs"; -import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { createEffect, @@ -22,6 +18,10 @@ import { } from "solid-js"; import { createStore, produce } from "solid-js/store"; import CapTooltip from "~/components/Tooltip"; +import { Channel, convertFileSrc } from "~/electron/core"; +import { ask, confirm } from "~/electron/dialog"; +import { remove } from "~/electron/fs"; +import * as shell from "~/electron/shell"; import { Input } from "~/routes/editor/ui"; import { trackEvent } from "~/utils/analytics"; import { createTauriEventListener } from "~/utils/createEventListener"; diff --git a/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx b/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx index 98363de3859..ddc16a8bd20 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx @@ -5,9 +5,6 @@ import { queryOptions, useQueryClient, } from "@tanstack/solid-query"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { ask } from "@tauri-apps/plugin-dialog"; -import { remove } from "@tauri-apps/plugin-fs"; import { createEffect, createMemo, @@ -17,6 +14,9 @@ import { Show, } from "solid-js"; import toast from "solid-toast"; +import { convertFileSrc } from "~/electron/core"; +import { ask } from "~/electron/dialog"; +import { remove } from "~/electron/fs"; import { Input } from "~/routes/editor/ui"; import { createScreenshotShareLinkFromProjectPath, diff --git a/apps/desktop/src/routes/(window-chrome)/update.tsx b/apps/desktop/src/routes/(window-chrome)/update.tsx index ca9cb960f44..fb64c22a09f 100644 --- a/apps/desktop/src/routes/(window-chrome)/update.tsx +++ b/apps/desktop/src/routes/(window-chrome)/update.tsx @@ -1,7 +1,5 @@ import { Button } from "@cap/ui-solid"; import { useNavigate } from "@solidjs/router"; -import { getCurrentWindow, UserAttentionType } from "@tauri-apps/api/window"; -import { relaunch } from "@tauri-apps/plugin-process"; import { createResource, createSignal, @@ -10,6 +8,8 @@ import { Show, Switch, } from "solid-js"; +import { relaunch } from "~/electron/process"; +import { getCurrentWindow, UserAttentionType } from "~/electron/window"; import { commands, events } from "~/utils/tauri"; export default function () { diff --git a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx index c9fd22ef7c8..3fff8018a39 100644 --- a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx +++ b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx @@ -1,7 +1,7 @@ import { Button } from "@cap/ui-solid"; import { createMutation, useQueryClient } from "@tanstack/solid-query"; -import { getCurrentWindow, Window } from "@tauri-apps/api/window"; import { type Accessor, createSignal, Show } from "solid-js"; +import { getCurrentWindow, Window } from "~/electron/window"; import { generalSettingsStore } from "~/store"; import { getProPlanId } from "~/utils/plans"; import { createLicenseQuery } from "~/utils/queries"; diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index f8e88825f45..fd0082073a9 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -1,13 +1,4 @@ import { makePersisted } from "@solid-primitives/storage"; -import { listen } from "@tauri-apps/api/event"; -import { - availableMonitors, - currentMonitor, - getCurrentWindow, - LogicalPosition, - LogicalSize, -} from "@tauri-apps/api/window"; -import { type } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { type Accessor, @@ -37,6 +28,15 @@ import { getDefaultCameraWindowState, normalizeBackgroundBlurMode, } from "~/components/CameraPreviewChrome"; +import { listen } from "~/electron/event"; +import { type } from "~/electron/os"; +import { + availableMonitors, + currentMonitor, + getCurrentWindow, + LogicalPosition, + LogicalSize, +} from "~/electron/window"; import { generalSettingsStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; import { diff --git a/apps/desktop/src/routes/capture-area.tsx b/apps/desktop/src/routes/capture-area.tsx index 0a2a3feeb17..ccf68228457 100644 --- a/apps/desktop/src/routes/capture-area.tsx +++ b/apps/desktop/src/routes/capture-area.tsx @@ -1,13 +1,6 @@ import { createEventListener } from "@solid-primitives/event-listener"; import { createScheduled, debounce } from "@solid-primitives/scheduled"; import { makePersisted } from "@solid-primitives/storage"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { Menu } from "@tauri-apps/api/menu"; -import { - getCurrentWebviewWindow, - WebviewWindow, -} from "@tauri-apps/api/webviewWindow"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { Transition } from "solid-transition-group"; @@ -20,6 +13,13 @@ import { type Ratio, } from "~/components/Cropper"; import SelectionHint from "~/components/selection-hint"; +import { LogicalPosition } from "~/electron/dpi"; +import { Menu } from "~/electron/menu"; +import { type as ostype } from "~/electron/os"; +import { + getCurrentWebviewWindow, + WebviewWindow, +} from "~/electron/webviewWindow"; import { createOptionsQuery } from "~/utils/queries"; import type { DisplayId } from "~/utils/tauri"; import { emitTo } from "~/utils/tauriSpectaHack"; diff --git a/apps/desktop/src/routes/debug.tsx b/apps/desktop/src/routes/debug.tsx index dcb1c4a2bce..539445889cd 100644 --- a/apps/desktop/src/routes/debug.tsx +++ b/apps/desktop/src/routes/debug.tsx @@ -1,9 +1,9 @@ import { useNavigate } from "@solidjs/router"; import { createQuery } from "@tanstack/solid-query"; -import { getVersion } from "@tauri-apps/api/app"; -import * as dialog from "@tauri-apps/plugin-dialog"; -import { check } from "@tauri-apps/plugin-updater"; import { createSignal, createUniqueId, For, onMount } from "solid-js"; +import { getVersion } from "~/electron/app"; +import * as dialog from "~/electron/dialog"; +import { check } from "~/electron/updater"; import { commands } from "~/utils/tauri"; export default function Debug() { diff --git a/apps/desktop/src/routes/editor/AudioLibrary.tsx b/apps/desktop/src/routes/editor/AudioLibrary.tsx index 550dacd6ecd..7b1d0b30445 100644 --- a/apps/desktop/src/routes/editor/AudioLibrary.tsx +++ b/apps/desktop/src/routes/editor/AudioLibrary.tsx @@ -1,4 +1,3 @@ -import { open } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { createEffect, @@ -11,6 +10,7 @@ import { Show, } from "solid-js"; import toast from "solid-toast"; +import { open } from "~/electron/dialog"; import { commands, type ImportedAudioTrack } from "~/utils/tauri"; import { AUDIO_IMPORT_EXTENSIONS } from "./audio"; diff --git a/apps/desktop/src/routes/editor/CaptionsTab.tsx b/apps/desktop/src/routes/editor/CaptionsTab.tsx index cb78dd7b6ed..a107c8425a0 100644 --- a/apps/desktop/src/routes/editor/CaptionsTab.tsx +++ b/apps/desktop/src/routes/editor/CaptionsTab.tsx @@ -1,7 +1,5 @@ import { Button } from "@cap/ui-solid"; import { Select as KSelect } from "@kobalte/core/select"; -import { appLocalDataDir, join } from "@tauri-apps/api/path"; -import { exists } from "@tauri-apps/plugin-fs"; import { cx } from "cva"; import { createEffect, @@ -17,6 +15,8 @@ import { produce } from "solid-js/store"; import toast from "solid-toast"; import { Toggle } from "~/components/Toggle"; import Tooltip from "~/components/Tooltip"; +import { exists } from "~/electron/fs"; +import { appLocalDataDir, join } from "~/electron/path"; import { CAPTION_STYLE_PRESETS, type CaptionAnimation, diff --git a/apps/desktop/src/routes/editor/ClipsSidebar.tsx b/apps/desktop/src/routes/editor/ClipsSidebar.tsx index 74b5e361178..7d73e5176b1 100644 --- a/apps/desktop/src/routes/editor/ClipsSidebar.tsx +++ b/apps/desktop/src/routes/editor/ClipsSidebar.tsx @@ -1,13 +1,6 @@ import { Button } from "@cap/ui-solid"; import { createEventListener } from "@solid-primitives/event-listener"; import { useQuery } from "@tanstack/solid-query"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { Menu, MenuItem } from "@tauri-apps/api/menu"; -import { appDataDir, join } from "@tauri-apps/api/path"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { open } from "@tauri-apps/plugin-dialog"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { type Component, @@ -26,6 +19,13 @@ import { import { produce, reconcile } from "solid-js/store"; import { Portal } from "solid-js/web"; import toast from "solid-toast"; +import { convertFileSrc } from "~/electron/core"; +import { open } from "~/electron/dialog"; +import { LogicalPosition } from "~/electron/dpi"; +import { Menu, MenuItem } from "~/electron/menu"; +import { type as ostype } from "~/electron/os"; +import { appDataDir, join } from "~/electron/path"; +import { getCurrentWindow } from "~/electron/window"; import { createDevicesQuery } from "~/utils/devices"; import { createCameraMutation, diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 67774872432..c5766c1a45a 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -13,15 +13,6 @@ import { Tabs as KTabs } from "@kobalte/core/tabs"; import { createElementBounds } from "@solid-primitives/bounds"; import { createEventListenerMap } from "@solid-primitives/event-listener"; import { createWritableMemo } from "@solid-primitives/memo"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { appDataDir, resolveResource } from "@tauri-apps/api/path"; -import { - BaseDirectory, - exists, - readDir, - writeFile, -} from "@tauri-apps/plugin-fs"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { batch, @@ -49,6 +40,10 @@ import gradientBg from "~/assets/illustrations/gradient.webp"; import imageBg from "~/assets/illustrations/image.webp"; import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; +import { convertFileSrc } from "~/electron/core"; +import { BaseDirectory, exists, readDir, writeFile } from "~/electron/fs"; +import { type as ostype } from "~/electron/os"; +import { appDataDir, resolveResource } from "~/electron/path"; import { generalSettingsStore } from "~/store"; import { normalizeOpaqueHexColor } from "~/utils/hex-color"; import { diff --git a/apps/desktop/src/routes/editor/Editor.tsx b/apps/desktop/src/routes/editor/Editor.tsx index 52c839204d4..4664ee299b8 100644 --- a/apps/desktop/src/routes/editor/Editor.tsx +++ b/apps/desktop/src/routes/editor/Editor.tsx @@ -5,11 +5,6 @@ import { trackDeep } from "@solid-primitives/deep"; import { debounce, throttle } from "@solid-primitives/scheduled"; import { makePersisted } from "@solid-primitives/storage"; import { createMutation, createQuery, skipToken } from "@tanstack/solid-query"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { Menu } from "@tauri-apps/api/menu"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { ask } from "@tauri-apps/plugin-dialog"; import { createEffect, createMemo, @@ -38,6 +33,11 @@ import { type Ratio, } from "~/components/Cropper"; import { Toggle } from "~/components/Toggle"; +import { convertFileSrc } from "~/electron/core"; +import { ask } from "~/electron/dialog"; +import { LogicalPosition } from "~/electron/dpi"; +import { Menu } from "~/electron/menu"; +import { getCurrentWindow } from "~/electron/window"; import { composeEventHandlers } from "~/utils/composeEventHandlers"; import { createTauriEventListener } from "~/utils/createEventListener"; import { commands, events } from "~/utils/tauri"; diff --git a/apps/desktop/src/routes/editor/EditorErrorScreen.tsx b/apps/desktop/src/routes/editor/EditorErrorScreen.tsx index 197891c257d..aca4ad6363e 100644 --- a/apps/desktop/src/routes/editor/EditorErrorScreen.tsx +++ b/apps/desktop/src/routes/editor/EditorErrorScreen.tsx @@ -1,9 +1,9 @@ import { Button } from "@cap/ui-solid"; import { createMutation } from "@tanstack/solid-query"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { Show } from "solid-js"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { revealItemInDir } from "~/electron/opener"; +import { type as ostype } from "~/electron/os"; import { commands } from "~/utils/tauri"; import IconAlertTriangle from "~icons/lucide/alert-triangle"; import IconFolder from "~icons/lucide/folder"; diff --git a/apps/desktop/src/routes/editor/ExportPage.tsx b/apps/desktop/src/routes/editor/ExportPage.tsx index 370a891c062..cf6f2549c2f 100644 --- a/apps/desktop/src/routes/editor/ExportPage.tsx +++ b/apps/desktop/src/routes/editor/ExportPage.tsx @@ -2,11 +2,6 @@ import { Button } from "@cap/ui-solid"; import { debounce } from "@solid-primitives/scheduled"; import { makePersisted } from "@solid-primitives/storage"; import { createMutation } from "@tanstack/solid-query"; -import { Channel } from "@tauri-apps/api/core"; -import { CheckMenuItem, Menu } from "@tauri-apps/api/menu"; -import { ask } from "@tauri-apps/plugin-dialog"; -import { remove } from "@tauri-apps/plugin-fs"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, @@ -25,6 +20,11 @@ import toast from "solid-toast"; import { SignInButton } from "~/components/SignInButton"; import Tooltip from "~/components/Tooltip"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { Channel } from "~/electron/core"; +import { ask } from "~/electron/dialog"; +import { remove } from "~/electron/fs"; +import { CheckMenuItem, Menu } from "~/electron/menu"; +import { type as ostype } from "~/electron/os"; import { authStore } from "~/store"; import { trackEvent } from "~/utils/analytics"; import { createSignInMutation } from "~/utils/auth"; diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 549e512af9e..ceb57a55b20 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -1,8 +1,4 @@ import { Button } from "@cap/ui-solid"; -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { ask } from "@tauri-apps/plugin-dialog"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { type ComponentProps, @@ -16,6 +12,10 @@ import { import Tooltip from "~/components/Tooltip"; import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { ask } from "~/electron/dialog"; +import type { UnlistenFn } from "~/electron/event"; +import { revealItemInDir } from "~/electron/opener"; +import { type as ostype } from "~/electron/os"; import { trackEvent } from "~/utils/analytics"; import { commands } from "~/utils/tauri"; import { initializeTitlebar } from "~/utils/titlebar-state"; diff --git a/apps/desktop/src/routes/editor/ImportProgress.tsx b/apps/desktop/src/routes/editor/ImportProgress.tsx index d69ef337873..f4ae3b2ba6b 100644 --- a/apps/desktop/src/routes/editor/ImportProgress.tsx +++ b/apps/desktop/src/routes/editor/ImportProgress.tsx @@ -1,6 +1,6 @@ import { Button } from "@cap/ui-solid"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { createSignal, Match, onCleanup, onMount, Switch } from "solid-js"; +import { getCurrentWindow } from "~/electron/window"; import { events, type VideoImportProgress as VideoImportProgressEvent, diff --git a/apps/desktop/src/routes/editor/PerformanceOverlay.tsx b/apps/desktop/src/routes/editor/PerformanceOverlay.tsx index 2a5f1438376..0ecc4252866 100644 --- a/apps/desktop/src/routes/editor/PerformanceOverlay.tsx +++ b/apps/desktop/src/routes/editor/PerformanceOverlay.tsx @@ -1,4 +1,3 @@ -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { createEffect, createMemo, @@ -8,6 +7,7 @@ import { Show, } from "solid-js"; import toast from "solid-toast"; +import { writeText } from "~/electron/clipboard"; import { useEditorContext } from "./context"; type PerformanceOverlayProps = { diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index 1da2c7e40f4..535086045a0 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -2,12 +2,11 @@ import { Select as KSelect } from "@kobalte/core/select"; import { ToggleButton as KToggleButton } from "@kobalte/core/toggle-button"; import { createElementBounds } from "@solid-primitives/bounds"; import { debounce } from "@solid-primitives/scheduled"; -import { Menu } from "@tauri-apps/api/menu"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, createSignal, onMount, Show } from "solid-js"; - import Tooltip from "~/components/Tooltip"; +import { Menu } from "~/electron/menu"; +import { type as ostype } from "~/electron/os"; import { captionsStore } from "~/store/captions"; import { commands } from "~/utils/tauri"; import AspectRatioSelect from "./AspectRatioSelect"; diff --git a/apps/desktop/src/routes/editor/ShareButton.tsx b/apps/desktop/src/routes/editor/ShareButton.tsx index a53fc08a6a7..50ce24b77d0 100644 --- a/apps/desktop/src/routes/editor/ShareButton.tsx +++ b/apps/desktop/src/routes/editor/ShareButton.tsx @@ -1,10 +1,10 @@ import { Button } from "@cap/ui-solid"; import { Select as KSelect } from "@kobalte/core/select"; import { createMutation } from "@tanstack/solid-query"; -import { Channel } from "@tauri-apps/api/core"; import { createSignal, Show } from "solid-js"; import { createStore, produce, reconcile } from "solid-js/store"; import Tooltip from "~/components/Tooltip"; +import { Channel } from "~/electron/core"; import { createProgressBar } from "~/routes/editor/utils"; import { authStore } from "~/store"; import { exportVideo } from "~/utils/export"; diff --git a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx index 7f815c0dc53..7e22bb74e93 100644 --- a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx @@ -1,6 +1,5 @@ import { Button } from "@cap/ui-solid"; import { createEventListenerMap } from "@solid-primitives/event-listener"; -import { Menu } from "@tauri-apps/api/menu"; import { cx } from "cva"; import { Array, Option } from "effect"; import { @@ -15,6 +14,7 @@ import { Switch, } from "solid-js"; import { produce } from "solid-js/store"; +import { Menu } from "~/electron/menu"; import { commands } from "~/utils/tauri"; import { useEditorContext } from "../context"; import { diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index 4cd4af15822..afb0b0fec4f 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -1,8 +1,5 @@ import { createElementBounds } from "@solid-primitives/bounds"; import { createEventListener } from "@solid-primitives/event-listener"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { Menu, MenuItem } from "@tauri-apps/api/menu"; -import { platform } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { batch, @@ -19,6 +16,9 @@ import { } from "solid-js"; import { produce } from "solid-js/store"; import toast from "solid-toast"; +import { LogicalPosition } from "~/electron/dpi"; +import { Menu, MenuItem } from "~/electron/menu"; +import { platform } from "~/electron/os"; import "./styles.css"; diff --git a/apps/desktop/src/routes/editor/TranscriptPage.tsx b/apps/desktop/src/routes/editor/TranscriptPage.tsx index dd3b7710e73..3e4234e84ed 100644 --- a/apps/desktop/src/routes/editor/TranscriptPage.tsx +++ b/apps/desktop/src/routes/editor/TranscriptPage.tsx @@ -1,7 +1,5 @@ import { createEventListener } from "@solid-primitives/event-listener"; import { makePersisted } from "@solid-primitives/storage"; -import { save } from "@tauri-apps/plugin-dialog"; -import { writeTextFile } from "@tauri-apps/plugin-fs"; import { cx } from "cva"; import { batch, @@ -14,6 +12,8 @@ import { } from "solid-js"; import { produce } from "solid-js/store"; import toast from "solid-toast"; +import { save } from "~/electron/dialog"; +import { writeTextFile } from "~/electron/fs"; import { defaultCaptionSettings } from "~/store/captions"; import { commands } from "~/utils/tauri"; import { diff --git a/apps/desktop/src/routes/editor/captions.ts b/apps/desktop/src/routes/editor/captions.ts index c500e407950..c6ad62c5dfb 100644 --- a/apps/desktop/src/routes/editor/captions.ts +++ b/apps/desktop/src/routes/editor/captions.ts @@ -1,5 +1,5 @@ -import { appLocalDataDir, join } from "@tauri-apps/api/path"; -import { arch, type as osType } from "@tauri-apps/plugin-os"; +import { arch, type as osType } from "~/electron/os"; +import { appLocalDataDir, join } from "~/electron/path"; import { defaultCaptionSettings } from "~/store/captions"; import { diff --git a/apps/desktop/src/routes/editor/editor-skeleton.tsx b/apps/desktop/src/routes/editor/editor-skeleton.tsx index 94b57eaacc6..dbbf643b1b8 100644 --- a/apps/desktop/src/routes/editor/editor-skeleton.tsx +++ b/apps/desktop/src/routes/editor/editor-skeleton.tsx @@ -1,6 +1,6 @@ -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { type as ostype } from "~/electron/os"; const DEFAULT_TIMELINE_HEIGHT = 260; const MIN_PLAYER_HEIGHT = 336; diff --git a/apps/desktop/src/routes/editor/existing-recording-picker.ts b/apps/desktop/src/routes/editor/existing-recording-picker.ts index 5c443399d43..cccb87131cc 100644 --- a/apps/desktop/src/routes/editor/existing-recording-picker.ts +++ b/apps/desktop/src/routes/editor/existing-recording-picker.ts @@ -1,5 +1,5 @@ -import type { OpenDialogOptions } from "@tauri-apps/plugin-dialog"; -import type { OsType } from "@tauri-apps/plugin-os"; +import type { OpenDialogOptions } from "~/electron/dialog"; +import type { OsType } from "~/electron/os"; export const getExistingRecordingPickerOptions = ( platform: OsType, diff --git a/apps/desktop/src/routes/editor/index.tsx b/apps/desktop/src/routes/editor/index.tsx index b4cabd2d1e8..7b88cb6ab92 100644 --- a/apps/desktop/src/routes/editor/index.tsx +++ b/apps/desktop/src/routes/editor/index.tsx @@ -1,7 +1,7 @@ -import { Effect, getCurrentWindow } from "@tauri-apps/api/window"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, onMount, Suspense } from "solid-js"; +import { type as ostype } from "~/electron/os"; +import { Effect, getCurrentWindow } from "~/electron/window"; import { generalSettingsStore } from "~/store"; import { commands } from "~/utils/tauri"; import { Editor } from "./Editor"; diff --git a/apps/desktop/src/routes/editor/utils.ts b/apps/desktop/src/routes/editor/utils.ts index 17b265a9cc2..b2dfd9fdd11 100644 --- a/apps/desktop/src/routes/editor/utils.ts +++ b/apps/desktop/src/routes/editor/utils.ts @@ -12,8 +12,8 @@ export function formatTime(secs: number, fps?: number) { return str; } -import { getCurrentWindow, ProgressBarStatus } from "@tauri-apps/api/window"; import { createEffect } from "solid-js"; +import { getCurrentWindow, ProgressBarStatus } from "~/electron/window"; export function createProgressBar(progress: () => number | undefined) { const currentWindow = getCurrentWindow(); diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 7afd88628d8..cc72bc99b9b 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -1,15 +1,5 @@ import { createTimer } from "@solid-primitives/timer"; import { createMutation } from "@tanstack/solid-query"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { - CheckMenuItem, - Menu, - MenuItem, - PredefinedMenuItem, -} from "@tauri-apps/api/menu"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import * as dialog from "@tauri-apps/plugin-dialog"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { type ComponentProps, @@ -23,6 +13,16 @@ import { } from "solid-js"; import { createStore, produce } from "solid-js/store"; import { TransitionGroup } from "solid-transition-group"; +import * as dialog from "~/electron/dialog"; +import { LogicalPosition } from "~/electron/dpi"; +import { + CheckMenuItem, + Menu, + MenuItem, + PredefinedMenuItem, +} from "~/electron/menu"; +import { type as ostype } from "~/electron/os"; +import { getCurrentWindow } from "~/electron/window"; import { authStore } from "~/store"; import { getCameraWindow } from "~/utils/camera-window"; import { createTauriEventListener } from "~/utils/createEventListener"; diff --git a/apps/desktop/src/routes/mode-select.tsx b/apps/desktop/src/routes/mode-select.tsx index 669cb414dba..19c8e3fed68 100644 --- a/apps/desktop/src/routes/mode-select.tsx +++ b/apps/desktop/src/routes/mode-select.tsx @@ -1,9 +1,9 @@ -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { onCleanup, onMount } from "solid-js"; import ModeSelect from "~/components/ModeSelect"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import type { UnlistenFn } from "~/electron/event"; +import { type as ostype } from "~/electron/os"; +import { getCurrentWindow, LogicalSize } from "~/electron/window"; import { initializeTitlebar } from "~/utils/titlebar-state"; const ModeSelectWindow = () => { diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index a9848daeaf3..7bff5879c2f 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -3,7 +3,6 @@ import Tooltip from "@corvu/tooltip"; import { createElementBounds } from "@solid-primitives/bounds"; import { makePersisted } from "@solid-primitives/storage"; import { createMutation, createQuery } from "@tanstack/solid-query"; -import { Channel, convertFileSrc } from "@tauri-apps/api/core"; import { cx } from "cva"; import { type Accessor, @@ -23,6 +22,7 @@ import { } from "solid-js"; import { createStore, produce, type SetStoreFunction } from "solid-js/store"; import { TransitionGroup } from "solid-transition-group"; +import { Channel, convertFileSrc } from "~/electron/core"; import { authStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; import { createExportToFileTask, exportVideo } from "~/utils/export"; diff --git a/apps/desktop/src/routes/screenshot-editor/Editor.tsx b/apps/desktop/src/routes/screenshot-editor/Editor.tsx index 53cf42cb508..d5da07ed2b4 100644 --- a/apps/desktop/src/routes/screenshot-editor/Editor.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Editor.tsx @@ -1,9 +1,6 @@ import { Button } from "@cap/ui-solid"; import { NumberField } from "@kobalte/core/number-field"; import { makePersisted } from "@solid-primitives/storage"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import { Menu } from "@tauri-apps/api/menu"; import { createEffect, createSignal, @@ -23,6 +20,9 @@ import { createCropOptionsMenuItems, type Ratio, } from "~/components/Cropper"; +import { convertFileSrc } from "~/electron/core"; +import { LogicalPosition } from "~/electron/dpi"; +import { Menu } from "~/electron/menu"; import { composeEventHandlers } from "~/utils/composeEventHandlers"; import IconCapCircleX from "~icons/cap/circle-x"; import IconLucideMaximize from "~icons/lucide/maximize"; diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index a41171e16a5..ff5e124a1f0 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -1,13 +1,13 @@ import { DropdownMenu } from "@kobalte/core/dropdown-menu"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { ask } from "@tauri-apps/plugin-dialog"; -import { remove } from "@tauri-apps/plugin-fs"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, onCleanup, Suspense } from "solid-js"; import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { ask } from "~/electron/dialog"; +import { remove } from "~/electron/fs"; +import { revealItemInDir } from "~/electron/opener"; +import { type as ostype } from "~/electron/os"; +import { getCurrentWindow } from "~/electron/window"; import IconCapCrop from "~icons/cap/crop"; import IconCapTrash from "~icons/cap/trash"; import IconLucideCopy from "~icons/lucide/copy"; diff --git a/apps/desktop/src/routes/screenshot-editor/OcrSelectionOverlay.tsx b/apps/desktop/src/routes/screenshot-editor/OcrSelectionOverlay.tsx index 3e2a7844c5c..73cb0160cd8 100644 --- a/apps/desktop/src/routes/screenshot-editor/OcrSelectionOverlay.tsx +++ b/apps/desktop/src/routes/screenshot-editor/OcrSelectionOverlay.tsx @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import { createEffect, createMemo, createSignal, For } from "solid-js"; +import { invoke } from "~/electron/core"; import { type ScreenshotProject, useScreenshotEditorContext } from "./context"; type Rect = { diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx index b7e5bcef3e1..8335d605a16 100644 --- a/apps/desktop/src/routes/screenshot-editor/context.tsx +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -2,7 +2,6 @@ import { createContextProvider } from "@solid-primitives/context"; import { trackStore } from "@solid-primitives/deep"; import { debounce, throttle } from "@solid-primitives/scheduled"; import { makePersisted } from "@solid-primitives/storage"; -import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { createEffect, createResource, @@ -11,6 +10,7 @@ import { onCleanup, } from "solid-js"; import { createStore, reconcile, unwrap } from "solid-js/store"; +import { convertFileSrc, invoke } from "~/electron/core"; import { createLazySignal, type FrameData } from "~/utils/socket"; import { type Annotation, diff --git a/apps/desktop/src/routes/screenshot-editor/index.tsx b/apps/desktop/src/routes/screenshot-editor/index.tsx index f1d993c6033..4707fedff18 100644 --- a/apps/desktop/src/routes/screenshot-editor/index.tsx +++ b/apps/desktop/src/routes/screenshot-editor/index.tsx @@ -1,7 +1,7 @@ -import { Effect, getCurrentWindow } from "@tauri-apps/api/window"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect } from "solid-js"; +import { type as ostype } from "~/electron/os"; +import { Effect, getCurrentWindow } from "~/electron/window"; import { generalSettingsStore } from "~/store"; import { commands } from "~/utils/tauri"; import { ScreenshotEditorProvider } from "./context"; diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx index 4023be1224e..7791563adcf 100644 --- a/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx @@ -1,9 +1,6 @@ import { Popover } from "@kobalte/core/popover"; import { RadioGroup as KRadioGroup } from "@kobalte/core/radio-group"; import { Tabs as KTabs } from "@kobalte/core/tabs"; -import { convertFileSrc, invoke } from "@tauri-apps/api/core"; -import { appDataDir, resolveResource } from "@tauri-apps/api/path"; -import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; import { batch, createMemo, @@ -12,6 +9,9 @@ import { For, Show, } from "solid-js"; +import { convertFileSrc, invoke } from "~/electron/core"; +import { BaseDirectory, writeFile } from "~/electron/fs"; +import { appDataDir, resolveResource } from "~/electron/path"; import type { BackgroundSource } from "~/utils/tauri"; import IconCapBgBlur from "~icons/cap/bg-blur"; import IconCapCircleX from "~icons/cap/circle-x"; diff --git a/apps/desktop/src/routes/screenshot-editor/screenshot-editor-skeleton.tsx b/apps/desktop/src/routes/screenshot-editor/screenshot-editor-skeleton.tsx index 28257ed849c..0085054991d 100644 --- a/apps/desktop/src/routes/screenshot-editor/screenshot-editor-skeleton.tsx +++ b/apps/desktop/src/routes/screenshot-editor/screenshot-editor-skeleton.tsx @@ -1,5 +1,5 @@ -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; +import { type as ostype } from "~/electron/os"; import IconCapLogo from "~icons/cap/logo"; function SkeletonPulse(props: { class?: string }) { diff --git a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts index dad98445232..500c54104bb 100644 --- a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts +++ b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts @@ -1,8 +1,8 @@ -import { save } from "@tauri-apps/plugin-dialog"; -import { writeFile } from "@tauri-apps/plugin-fs"; import { createSignal } from "solid-js"; import { unwrap } from "solid-js/store"; import toast from "solid-toast"; +import { save } from "~/electron/dialog"; +import { writeFile } from "~/electron/fs"; import { commands } from "~/utils/tauri"; import { useScreenshotEditorContext } from "./context"; import { diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 772fc3f0051..5d191974672 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -5,20 +5,6 @@ import { createElementSize } from "@solid-primitives/resize-observer"; import { makePersisted } from "@solid-primitives/storage"; import { useSearchParams } from "@solidjs/router"; import { createMutation, useQuery } from "@tanstack/solid-query"; -import { - LogicalPosition, - type PhysicalPosition, - type PhysicalSize, -} from "@tauri-apps/api/dpi"; -import { emit } from "@tauri-apps/api/event"; -import { - CheckMenuItem, - Menu, - MenuItem, - PredefinedMenuItem, -} from "@tauri-apps/api/menu"; -import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { createEffect, createMemo, @@ -58,6 +44,20 @@ import { } from "~/components/Cropper"; import ModeSelect from "~/components/ModeSelect"; import SelectionHint from "~/components/selection-hint"; +import { + LogicalPosition, + type PhysicalPosition, + type PhysicalSize, +} from "~/electron/dpi"; +import { emit } from "~/electron/event"; +import { + CheckMenuItem, + Menu, + MenuItem, + PredefinedMenuItem, +} from "~/electron/menu"; +import { type as ostype } from "~/electron/os"; +import { WebviewWindow } from "~/electron/webviewWindow"; import { authStore, generalSettingsStore, diff --git a/apps/desktop/src/routes/teleprompter.tsx b/apps/desktop/src/routes/teleprompter.tsx index 2bc2bf2806b..41c29bd4d40 100644 --- a/apps/desktop/src/routes/teleprompter.tsx +++ b/apps/desktop/src/routes/teleprompter.tsx @@ -1,6 +1,3 @@ -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, @@ -11,10 +8,12 @@ import { onMount, Show, } from "solid-js"; - import { Toggle } from "~/components/Toggle"; import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import type { UnlistenFn } from "~/electron/event"; +import { type as ostype } from "~/electron/os"; +import { getCurrentWebviewWindow } from "~/electron/webviewWindow"; import { type TeleprompterStore, teleprompterDefaults, diff --git a/apps/desktop/src/routes/window-capture-occluder.tsx b/apps/desktop/src/routes/window-capture-occluder.tsx index a67f95202b9..c638746a157 100644 --- a/apps/desktop/src/routes/window-capture-occluder.tsx +++ b/apps/desktop/src/routes/window-capture-occluder.tsx @@ -1,5 +1,5 @@ -import { getAllWindows } from "@tauri-apps/api/window"; import { Show, Suspense } from "solid-js"; +import { getAllWindows } from "~/electron/window"; import { isCameraWindowLabel } from "~/utils/camera-window"; import { createCurrentRecordingQuery } from "~/utils/queries"; diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index c2b30eb6c56..2c05c7b5842 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -1,6 +1,6 @@ import { createQuery } from "@tanstack/solid-query"; -import { Store } from "@tauri-apps/plugin-store"; import { onCleanup } from "solid-js"; +import { Store } from "~/electron/store"; import type { AutomationsStore } from "~/utils/automations"; import { type GeneralSettingsStore, diff --git a/apps/desktop/src/styles/theme.css b/apps/desktop/src/styles/theme.css index 330b4fd1284..546370cb629 100644 --- a/apps/desktop/src/styles/theme.css +++ b/apps/desktop/src/styles/theme.css @@ -19,6 +19,24 @@ @tailwind utilities; +/* Tauri handled this attribute internally. Electron frameless windows use + * Chromium's app-region CSS contract for the same behavior. */ +[data-tauri-drag-region]:not([data-tauri-drag-region="false"]) { + -webkit-app-region: drag; + app-region: drag; +} + +[data-tauri-drag-region="false"], +[data-tauri-drag-region] button, +[data-tauri-drag-region] a, +[data-tauri-drag-region] input, +[data-tauri-drag-region] select, +[data-tauri-drag-region] textarea, +[data-tauri-drag-region] [role="button"] { + -webkit-app-region: no-drag; + app-region: no-drag; +} + /* Single source of truth for timeline track colours. Both the timeline * segments and the "Add track" picker read these, so they always match. */ :root { diff --git a/apps/desktop/src/utils/analytics.ts b/apps/desktop/src/utils/analytics.ts index fb213508316..e99806f95bf 100644 --- a/apps/desktop/src/utils/analytics.ts +++ b/apps/desktop/src/utils/analytics.ts @@ -1,5 +1,5 @@ import { OpenPanel } from "@openpanel/web"; -import { Store } from "@tauri-apps/plugin-store"; +import { Store } from "~/electron/store"; const clientId = import.meta.env.VITE_OPENPANEL_CLIENT_ID as string | undefined; const apiUrl = import.meta.env.VITE_OPENPANEL_API_URL as string | undefined; @@ -130,5 +130,3 @@ export function trackEvent( } }); } - -void isTelemetryEnabled(); diff --git a/apps/desktop/src/utils/auth.ts b/apps/desktop/src/utils/auth.ts index 60c55d0b8d3..43eb48acc8c 100644 --- a/apps/desktop/src/utils/auth.ts +++ b/apps/desktop/src/utils/auth.ts @@ -1,11 +1,11 @@ import { createMutation } from "@tanstack/solid-query"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; -import * as shell from "@tauri-apps/plugin-shell"; import { z } from "zod"; import callbackTemplate from "~/components/callback.template"; +import { invoke } from "~/electron/core"; +import { onOpenUrl } from "~/electron/deep-link"; +import { listen } from "~/electron/event"; +import * as shell from "~/electron/shell"; +import { getCurrentWindow } from "~/electron/window"; import { authStore, generalSettingsStore } from "~/store"; import { identifyUser, trackEvent } from "./analytics"; import { clientEnv } from "./env"; diff --git a/apps/desktop/src/utils/camera-window.ts b/apps/desktop/src/utils/camera-window.ts index 7e5d08dafcd..b25179dc059 100644 --- a/apps/desktop/src/utils/camera-window.ts +++ b/apps/desktop/src/utils/camera-window.ts @@ -1,4 +1,4 @@ -import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { WebviewWindow } from "~/electron/webviewWindow"; const CAMERA_WINDOW_PREFIX = "camera-"; diff --git a/apps/desktop/src/utils/export.ts b/apps/desktop/src/utils/export.ts index 85be0b9cb3c..9645320168a 100644 --- a/apps/desktop/src/utils/export.ts +++ b/apps/desktop/src/utils/export.ts @@ -1,4 +1,4 @@ -import { Channel } from "@tauri-apps/api/core"; +import { Channel } from "~/electron/core"; import { commands, type ExportSettings, type FramesRendered } from "./tauri"; export async function beginExportSessionGuard() { diff --git a/apps/desktop/src/utils/importMedia.ts b/apps/desktop/src/utils/importMedia.ts index 59401c42234..75b06778e42 100644 --- a/apps/desktop/src/utils/importMedia.ts +++ b/apps/desktop/src/utils/importMedia.ts @@ -1,6 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import * as dialog from "@tauri-apps/plugin-dialog"; +import { invoke } from "~/electron/core"; +import * as dialog from "~/electron/dialog"; +import { getCurrentWindow } from "~/electron/window"; import { commands } from "~/utils/tauri"; const videoExtensions = [ diff --git a/apps/desktop/src/utils/macos-window-material.ts b/apps/desktop/src/utils/macos-window-material.ts index 2a9993d036e..36d78b1618d 100644 --- a/apps/desktop/src/utils/macos-window-material.ts +++ b/apps/desktop/src/utils/macos-window-material.ts @@ -1,12 +1,12 @@ -import { invoke } from "@tauri-apps/api/core"; -import type { UnlistenFn } from "@tauri-apps/api/event"; +import { invoke } from "~/electron/core"; +import type { UnlistenFn } from "~/electron/event"; +import { type as osType, version as osVersion } from "~/electron/os"; import { Effect, EffectState, type Effects, getCurrentWindow, -} from "@tauri-apps/api/window"; -import { type as osType, version as osVersion } from "@tauri-apps/plugin-os"; +} from "~/electron/window"; type MacOSWindowMaterial = "panel" | "settings" | "teleprompter"; diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index 4118d0cb9f6..915cd59c13d 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -6,7 +6,7 @@ import { useMutation, useQuery, } from "@tanstack/solid-query"; -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { getCurrentWindow } from "~/electron/window"; import { batch, createEffect, createMemo, onCleanup } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { useRecordingOptions } from "~/routes/(window-chrome)/OptionsContext"; diff --git a/apps/desktop/src/utils/recording.ts b/apps/desktop/src/utils/recording.ts index ce1ef9f06f1..5f929c0bede 100644 --- a/apps/desktop/src/utils/recording.ts +++ b/apps/desktop/src/utils/recording.ts @@ -1,6 +1,6 @@ -import { emit } from "@tauri-apps/api/event"; -import * as dialog from "@tauri-apps/plugin-dialog"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import * as dialog from "~/electron/dialog"; +import { emit } from "~/electron/event"; +import { revealItemInDir } from "~/electron/opener"; import type { createOptionsQuery } from "./queries"; import { commands, type RecordingAction, type RecordingMode } from "./tauri"; diff --git a/apps/desktop/src/utils/socket.ts b/apps/desktop/src/utils/socket.ts index 1ce9ac75c81..98bc1c5375f 100644 --- a/apps/desktop/src/utils/socket.ts +++ b/apps/desktop/src/utils/socket.ts @@ -323,6 +323,7 @@ export function createImageDataWS( let mainThreadWebGPU: WebGPURenderer | null = null; let mainThreadWebGPUInitializing = false; + let nv12WebGPUAvailable = false; let pendingNv12Frame: { buffer: ArrayBuffer; receivedAt: number } | null = null; let pendingRgbaFrame: { buffer: ArrayBuffer; receivedAt: number } | null = @@ -449,6 +450,13 @@ export function createImageDataWS( setIsConnected(false); } + function sendOutputCapability() { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send( + nv12WebGPUAvailable ? "cap-preview:nv12-webgpu" : "cap-preview:rgba", + ); + } + function renderPendingNv12Frame() { if (!pendingNv12Frame || !mainThreadWebGPU || !directCanvas) return; @@ -806,6 +814,8 @@ export function createImageDataWS( const isNewCanvas = directCanvas !== canvas; if (isNewCanvas && directCanvas) { + nv12WebGPUAvailable = false; + sendOutputCapability(); if (mainThreadWebGPU) { disposeWebGPU(mainThreadWebGPU); mainThreadWebGPU = null; @@ -838,7 +848,9 @@ export function createImageDataWS( } mainThreadWebGPU = renderer; + nv12WebGPUAvailable = true; mainThreadWebGPUInitializing = false; + sendOutputCapability(); if (pendingNv12Frame && directCanvas) { renderPendingNv12Frame(); } @@ -854,6 +866,8 @@ export function createImageDataWS( } mainThreadWebGPUInitializing = false; + nv12WebGPUAvailable = false; + sendOutputCapability(); console.error("[Socket] Main thread WebGPU init failed:", e); directCtx = directCanvas?.getContext("2d", { alpha: false }) ?? null; @@ -867,6 +881,8 @@ export function createImageDataWS( }); } else { mainThreadWebGPUInitializing = false; + nv12WebGPUAvailable = false; + sendOutputCapability(); directCtx = directCanvas?.getContext("2d", { alpha: false }) ?? null; if (pendingNv12Frame && directCanvas && directCtx) { renderPendingFrameCanvas2D(); @@ -1082,6 +1098,7 @@ export function createImageDataWS( ws.addEventListener("open", () => { setIsConnected(true); + sendOutputCapability(); }); ws.addEventListener("close", () => { diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 62f92eace7c..20522d91015 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -1164,9 +1164,9 @@ export type ZoomSegment = { start: number; end: number; amount: number; mode: Zo import { invoke as TAURI_INVOKE, Channel as TAURI_CHANNEL, -} from "@tauri-apps/api/core"; -import * as TAURI_API_EVENT from "@tauri-apps/api/event"; -import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; +} from "~/electron/core"; +import * as TAURI_API_EVENT from "~/electron/event"; +import { type WebviewWindow as __WebviewWindow__ } from "~/electron/webviewWindow"; type __EventObj__ = { listen: ( diff --git a/apps/desktop/src/utils/tauriSpectaHack.ts b/apps/desktop/src/utils/tauriSpectaHack.ts index 51cb29ed644..6d8394cc3e9 100644 --- a/apps/desktop/src/utils/tauriSpectaHack.ts +++ b/apps/desktop/src/utils/tauriSpectaHack.ts @@ -1,7 +1,7 @@ // Workaround for: https://github.com/specta-rs/tauri-specta/issues/187 -import type * as TAURI_API_EVENT from "@tauri-apps/api/event"; -import type { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import type * as TAURI_API_EVENT from "~/electron/event"; +import type { WebviewWindow } from "~/electron/webviewWindow"; import type { events } from "./tauri"; type __EventObj__ = { diff --git a/apps/desktop/src/utils/teleprompter-window-options.ts b/apps/desktop/src/utils/teleprompter-window-options.ts index 0a78e32dca7..d8dad23d6c5 100644 --- a/apps/desktop/src/utils/teleprompter-window-options.ts +++ b/apps/desktop/src/utils/teleprompter-window-options.ts @@ -1,6 +1,6 @@ -import { LogicalPosition } from "@tauri-apps/api/dpi"; -import type { WebviewWindow } from "@tauri-apps/api/webviewWindow"; -import type { OsType } from "@tauri-apps/plugin-os"; +import { LogicalPosition } from "~/electron/dpi"; +import type { OsType } from "~/electron/os"; +import type { WebviewWindow } from "~/electron/webviewWindow"; export type TeleprompterWindowOptions = NonNullable< ConstructorParameters[1] diff --git a/apps/desktop/src/utils/teleprompter.test.ts b/apps/desktop/src/utils/teleprompter.test.ts index a5e0df72bf1..0f4a6cada83 100644 --- a/apps/desktop/src/utils/teleprompter.test.ts +++ b/apps/desktop/src/utils/teleprompter.test.ts @@ -51,7 +51,7 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock("@tauri-apps/api/webviewWindow", () => ({ +vi.mock("~/electron/webviewWindow", () => ({ WebviewWindow: class { static getCurrent = mocks.getCurrent; static getByLabel = vi.fn(async () => mocks.state.existingWindow); @@ -73,7 +73,7 @@ vi.mock("@tauri-apps/api/webviewWindow", () => ({ }, })); -vi.mock("@tauri-apps/plugin-os", () => ({ type: mocks.osType })); +vi.mock("~/electron/os", () => ({ type: mocks.osType })); vi.mock("./tauri", () => ({ commands: { diff --git a/apps/desktop/src/utils/teleprompter.ts b/apps/desktop/src/utils/teleprompter.ts index 39487c0683d..a15164e3c10 100644 --- a/apps/desktop/src/utils/teleprompter.ts +++ b/apps/desktop/src/utils/teleprompter.ts @@ -1,5 +1,5 @@ -import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { type as ostype } from "@tauri-apps/plugin-os"; +import { type as ostype } from "~/electron/os"; +import { WebviewWindow } from "~/electron/webviewWindow"; import { commands } from "./tauri"; import { getTeleprompterWindowOptions } from "./teleprompter-window-options"; diff --git a/apps/desktop/src/utils/titlebar-state.ts b/apps/desktop/src/utils/titlebar-state.ts index 80fc8beb2c3..ce4425b9266 100644 --- a/apps/desktop/src/utils/titlebar-state.ts +++ b/apps/desktop/src/utils/titlebar-state.ts @@ -1,8 +1,8 @@ -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { type as ostype } from "@tauri-apps/plugin-os"; import type { JSX } from "solid-js"; import { createStore } from "solid-js/store"; +import type { UnlistenFn } from "~/electron/event"; +import { type as ostype } from "~/electron/os"; +import { getCurrentWindow } from "~/electron/window"; export interface TitlebarState { height: string; diff --git a/apps/desktop/src/utils/updater.ts b/apps/desktop/src/utils/updater.ts index cf0f2a75aad..2377b66925d 100644 --- a/apps/desktop/src/utils/updater.ts +++ b/apps/desktop/src/utils/updater.ts @@ -1,5 +1,5 @@ -import { arch, type as ostype } from "@tauri-apps/plugin-os"; -import type { CheckOptions } from "@tauri-apps/plugin-updater"; +import { arch, type as ostype } from "~/electron/os"; +import type { CheckOptions } from "~/electron/updater"; function updaterArch() { const currentArch = arch(); diff --git a/apps/desktop/src/utils/web-api.ts b/apps/desktop/src/utils/web-api.ts index 2bb70f47f03..cdd354ae090 100644 --- a/apps/desktop/src/utils/web-api.ts +++ b/apps/desktop/src/utils/web-api.ts @@ -3,8 +3,8 @@ import { licenseContract, orgCustomDomainContract, } from "@cap/web-api-contract"; -import { fetch } from "@tauri-apps/plugin-http"; import { type ApiFetcher, initClient } from "@ts-rest/core"; +import { fetch } from "~/electron/http"; import { authStore, generalSettingsStore } from "~/store"; import { clientEnv } from "./env"; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index faa3ffe3aec..52f6d6811dc 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -28,5 +28,5 @@ "~/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src", "electron"] } diff --git a/apps/web/__tests__/unit/loom-url.test.ts b/apps/web/__tests__/unit/loom-url.test.ts new file mode 100644 index 00000000000..5052377113e --- /dev/null +++ b/apps/web/__tests__/unit/loom-url.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + extractLoomVideoId, + isValidLoomVideoId, + normalizeLoomMediaUrl, +} from "@/lib/loom-url"; + +describe("Loom URL security", () => { + it("accepts HTTPS Loom URLs and IDs", () => { + expect( + extractLoomVideoId( + "https://www.loom.com/share/05f424bf8781404091f365c9f5231d86?sid=abc", + ), + ).toBe("05f424bf8781404091f365c9f5231d86"); + expect(extractLoomVideoId("https://loom.com/embed/loom-abc1234567")).toBe( + "loom-abc1234567", + ); + expect(isValidLoomVideoId("loom-abc1234567")).toBe(true); + }); + + it("rejects lookalike hosts and path-injection IDs", () => { + expect( + extractLoomVideoId( + "https://www.loom.com.evil.test/share/loom-abc1234567", + ), + ).toBeNull(); + expect( + extractLoomVideoId("https://evil-loom.com/share/loom-abc1234567"), + ).toBeNull(); + expect( + extractLoomVideoId("http://www.loom.com/share/loom-abc1234567"), + ).toBeNull(); + expect( + extractLoomVideoId("https://www.loom.com/share/loom%2Fevil12345"), + ).toBeNull(); + }); + + it("accepts only HTTPS Loom media URLs", () => { + expect(normalizeLoomMediaUrl("https://cdn.loom.com/video.mp4")).toBe( + "https://cdn.loom.com/video.mp4", + ); + expect( + normalizeLoomMediaUrl( + "https://luna.loom.com/id/video/playlist.mpd?Policy=abc", + ), + ).toBe("https://luna.loom.com/id/video/playlist.mpd?Policy=abc"); + expect( + normalizeLoomMediaUrl("https://cdn.loom.com.evil.test/video.mp4"), + ).toBeNull(); + expect(normalizeLoomMediaUrl("https://127.0.0.1/video.mp4")).toBeNull(); + }); +}); diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index ce2c4af5e7f..83188e408f9 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -34,6 +34,12 @@ import { getOrganizationAccess, requireOrganizationAccess, } from "@/actions/organization/authorization"; +import { + extractLoomVideoId, + isValidLoomVideoId, + LOOM_ORIGIN, + normalizeLoomMediaUrl, +} from "@/lib/loom-url"; import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; import { canManageOrganizationSettings } from "@/lib/permissions/roles"; import { runPromise } from "@/lib/server"; @@ -44,6 +50,7 @@ interface LoomUrlResponse { } type LoomDownloadMode = "direct-download" | "browser-conversion"; +type LoomEndpoint = "raw-url" | "transcoded-url"; interface LoomDownloadResult { success: boolean; @@ -127,33 +134,15 @@ async function createLoomImportRateLimitCheck(userId: User.UserId) { }; } -function extractLoomVideoId(url: string): string | null { - try { - const parsed = new URL(url); - if (!parsed.hostname.includes("loom.com")) { - return null; - } - - const pathParts = parsed.pathname.split("/").filter(Boolean); - const id = pathParts[pathParts.length - 1] ?? null; - - if (!id || id.length < 10) { - return null; - } - - return id.split("?")[0] ?? null; - } catch { - return null; - } -} - async function fetchLoomEndpoint( videoId: string, - endpoint: string, + endpoint: LoomEndpoint, includeBody = true, ): Promise { try { - const options: RequestInit = { method: "POST" }; + if (!isValidLoomVideoId(videoId)) return null; + + const options: RequestInit = { method: "POST", redirect: "error" }; if (includeBody) { options.headers = { "Content-Type": "application/json", @@ -167,8 +156,13 @@ async function fetchLoomEndpoint( }); } + const encodedVideoId = encodeURIComponent(videoId); + const endpointPath = endpoint === "raw-url" ? "raw-url" : "transcoded-url"; const response = await fetch( - `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`, + new URL( + `/api/campaigns/sessions/${encodedVideoId}/${endpointPath}`, + LOOM_ORIGIN, + ), options, ); @@ -182,7 +176,7 @@ async function fetchLoomEndpoint( } const data: LoomUrlResponse = JSON.parse(text); - return data.url ?? null; + return data.url ? normalizeLoomMediaUrl(data.url) : null; } catch { return null; } @@ -230,7 +224,10 @@ function isDirectMp4Url(url: string): boolean { } async function getLoomDownloadUrl(loomVideoId: string): Promise { - const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [ + const requestVariants: Array<{ + endpoint: LoomEndpoint; + includeBody: boolean; + }> = [ { endpoint: "transcoded-url", includeBody: true }, { endpoint: "raw-url", includeBody: true }, { endpoint: "transcoded-url", includeBody: false }, @@ -255,10 +252,16 @@ async function fetchLoomOEmbed( loomVideoId: string, ): Promise<{ duration?: number; width?: number; height?: number } | null> { try { - const response = await fetch( - `https://www.loom.com/v1/oembed?url=https://www.loom.com/share/${loomVideoId}`, - { headers: { Accept: "application/json" } }, + const shareUrl = new URL( + `/share/${encodeURIComponent(loomVideoId)}`, + LOOM_ORIGIN, ); + const oembedUrl = new URL("/v1/oembed", LOOM_ORIGIN); + oembedUrl.searchParams.set("url", shareUrl.toString()); + const response = await fetch(oembedUrl, { + redirect: "error", + headers: { Accept: "application/json" }, + }); if (!response.ok) return null; const data = await response.json(); return { @@ -405,10 +408,7 @@ async function importLoomVideoForOwner({ fetchLoomOEmbed(loomVideoId), ]); - const writableResult = await Storage.getWritableAccessForUser( - ownerId, - orgId, - ) + const writableResult = await Storage.getWritableAccessForUser(ownerId, orgId) .pipe(runPromise) .then( (value) => ({ ok: true as const, value }), @@ -417,7 +417,7 @@ async function importLoomVideoForOwner({ if (!writableResult.ok) { console.error( - `Loom import: failed to resolve storage access for user ${ownerId} in org ${orgId}:`, + "Loom import: failed to resolve storage access", writableResult.error, ); return { diff --git a/apps/web/lib/loom-browser-download.ts b/apps/web/lib/loom-browser-download.ts index 4a5eab7c998..973188949ba 100644 --- a/apps/web/lib/loom-browser-download.ts +++ b/apps/web/lib/loom-browser-download.ts @@ -1,8 +1,16 @@ +import { + extractLoomVideoId, + isValidLoomVideoId, + LOOM_ORIGIN, + normalizeLoomMediaUrl, +} from "@/lib/loom-url"; + type LoomUrlResponse = { url?: string; }; type LoomDownloadMode = "direct-download" | "browser-conversion"; +type LoomEndpoint = "raw-url" | "transcoded-url"; export type LoomBrowserDownloadResult = { success: boolean; @@ -22,27 +30,7 @@ type LoomOEmbedMetadata = { height?: number; }; -const LOOM_API_ROOT = "https://www.loom.com"; - -export function extractLoomVideoId(url: string): string | null { - try { - const parsed = new URL(url); - if (!parsed.hostname.includes("loom.com")) { - return null; - } - - const pathParts = parsed.pathname.split("/").filter(Boolean); - const id = pathParts[pathParts.length - 1] ?? null; - - if (!id || id.length < 10) { - return null; - } - - return id.split("?")[0] ?? null; - } catch { - return null; - } -} +export { extractLoomVideoId } from "@/lib/loom-url"; function createAnonId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { @@ -64,14 +52,17 @@ function isDirectMp4Url(url: string): boolean { async function fetchLoomEndpoint( videoId: string, - endpoint: string, + endpoint: LoomEndpoint, includeBody = true, ): Promise { try { + if (!isValidLoomVideoId(videoId)) return null; + const options: RequestInit = { method: "POST", mode: "cors", referrerPolicy: "no-referrer", + redirect: "error", }; if (includeBody) { options.headers = { @@ -86,8 +77,13 @@ async function fetchLoomEndpoint( }); } + const encodedVideoId = encodeURIComponent(videoId); + const endpointPath = endpoint === "raw-url" ? "raw-url" : "transcoded-url"; const response = await fetch( - `${LOOM_API_ROOT}/api/campaigns/sessions/${videoId}/${endpoint}`, + new URL( + `/api/campaigns/sessions/${encodedVideoId}/${endpointPath}`, + LOOM_ORIGIN, + ), options, ); @@ -101,7 +97,7 @@ async function fetchLoomEndpoint( } const data = JSON.parse(text) as LoomUrlResponse; - return data.url ?? null; + return data.url ? normalizeLoomMediaUrl(data.url) : null; } catch { return null; } @@ -109,7 +105,7 @@ async function fetchLoomEndpoint( async function fetchVideoName(videoId: string): Promise { try { - const response = await fetch(`${LOOM_API_ROOT}/graphql`, { + const response = await fetch(`${LOOM_ORIGIN}/graphql`, { method: "POST", mode: "cors", referrerPolicy: "no-referrer", @@ -144,14 +140,18 @@ async function fetchLoomOEmbed( videoId: string, ): Promise { try { - const response = await fetch( - `${LOOM_API_ROOT}/v1/oembed?url=https://www.loom.com/share/${videoId}`, - { - mode: "cors", - referrerPolicy: "no-referrer", - headers: { Accept: "application/json" }, - }, + const shareUrl = new URL( + `/share/${encodeURIComponent(videoId)}`, + LOOM_ORIGIN, ); + const oembedUrl = new URL("/v1/oembed", LOOM_ORIGIN); + oembedUrl.searchParams.set("url", shareUrl.toString()); + const response = await fetch(oembedUrl, { + mode: "cors", + referrerPolicy: "no-referrer", + redirect: "error", + headers: { Accept: "application/json" }, + }); if (!response.ok) return null; const data = await response.json(); return { @@ -165,7 +165,10 @@ async function fetchLoomOEmbed( } async function getLoomDownloadUrl(loomVideoId: string): Promise { - const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [ + const requestVariants: Array<{ + endpoint: LoomEndpoint; + includeBody: boolean; + }> = [ { endpoint: "transcoded-url", includeBody: true }, { endpoint: "raw-url", includeBody: true }, { endpoint: "transcoded-url", includeBody: false }, diff --git a/apps/web/lib/loom-url.ts b/apps/web/lib/loom-url.ts new file mode 100644 index 00000000000..34065f7fa66 --- /dev/null +++ b/apps/web/lib/loom-url.ts @@ -0,0 +1,46 @@ +const LOOM_HOSTNAME = "loom.com"; +const LOOM_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{10,128}$/; + +export const LOOM_ORIGIN = "https://www.loom.com"; + +export function isValidLoomVideoId(value: string): boolean { + return LOOM_VIDEO_ID_PATTERN.test(value); +} + +function isLoomHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + return ( + normalized === LOOM_HOSTNAME || normalized.endsWith(`.${LOOM_HOSTNAME}`) + ); +} + +function isTrustedLoomUrl(url: URL): boolean { + return ( + url.protocol === "https:" && + (url.port === "" || url.port === "443") && + url.username === "" && + url.password === "" && + isLoomHostname(url.hostname) + ); +} + +export function extractLoomVideoId(value: string): string | null { + try { + const url = new URL(value); + if (!isTrustedLoomUrl(url)) return null; + + const id = url.pathname.split("/").filter(Boolean).at(-1); + return id && isValidLoomVideoId(id) ? id : null; + } catch { + return null; + } +} + +export function normalizeLoomMediaUrl(value: string): string | null { + try { + const url = new URL(value); + return isTrustedLoomUrl(url) ? url.toString() : null; + } catch { + return null; + } +} diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index f68417e8a5d..def06d489a5 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -7,6 +7,11 @@ import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect } from "effect"; import { FatalError } from "workflow"; +import { + isValidLoomVideoId, + LOOM_ORIGIN, + normalizeLoomMediaUrl, +} from "@/lib/loom-url"; import { runWorkflowPromise } from "@/lib/workflow-runtime"; interface ImportLoomPayload { @@ -46,11 +51,13 @@ function isGoogleDriveResumableUrl(url: string): boolean { async function fetchLoomCdnUrl( videoId: string, - endpoint: string, + endpoint: "raw-url" | "transcoded-url", includeBody: boolean, ): Promise { try { - const options: RequestInit = { method: "POST" }; + if (!isValidLoomVideoId(videoId)) return null; + + const options: RequestInit = { method: "POST", redirect: "error" }; if (includeBody) { options.headers = { "Content-Type": "application/json", @@ -64,8 +71,13 @@ async function fetchLoomCdnUrl( }); } + const encodedVideoId = encodeURIComponent(videoId); + const endpointPath = endpoint === "raw-url" ? "raw-url" : "transcoded-url"; const response = await fetch( - `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`, + new URL( + `/api/campaigns/sessions/${encodedVideoId}/${endpointPath}`, + LOOM_ORIGIN, + ), options, ); @@ -75,14 +87,17 @@ async function fetchLoomCdnUrl( if (!text.trim()) return null; const data = JSON.parse(text) as { url?: string }; - return data.url ?? null; + return data.url ? normalizeLoomMediaUrl(data.url) : null; } catch { return null; } } async function fetchFreshLoomDownloadUrl(loomVideoId: string): Promise { - const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [ + const requestVariants: Array<{ + endpoint: "raw-url" | "transcoded-url"; + includeBody: boolean; + }> = [ { endpoint: "transcoded-url", includeBody: true }, { endpoint: "raw-url", includeBody: true }, { endpoint: "transcoded-url", includeBody: false }, diff --git a/crates/desktop-runtime-macros/Cargo.toml b/crates/desktop-runtime-macros/Cargo.toml new file mode 100644 index 00000000000..e46cbcadf91 --- /dev/null +++ b/crates/desktop-runtime-macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cap-desktop-runtime-macros" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + +[lints] +workspace = true diff --git a/crates/desktop-runtime-macros/src/lib.rs b/crates/desktop-runtime-macros/src/lib.rs new file mode 100644 index 00000000000..d7424798cb1 --- /dev/null +++ b/crates/desktop-runtime-macros/src/lib.rs @@ -0,0 +1,237 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + DeriveInput, FnArg, GenericArgument, ItemFn, Pat, PathArguments, ReturnType, Type, + parse_macro_input, +}; + +#[proc_macro_attribute] +pub fn command(_attribute: TokenStream, item: TokenStream) -> TokenStream { + let function = parse_macro_input!(item as ItemFn); + expand_command(function).into() +} + +#[proc_macro_derive(Event)] +pub fn derive_event(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as DeriveInput); + let name = input.ident; + let event_name = pascal_to_kebab(&name.to_string()); + + quote! { + impl ::cap_desktop_runtime::Event for #name { + const NAME: &'static str = #event_name; + } + } + .into() +} + +fn expand_command(function: ItemFn) -> proc_macro2::TokenStream { + let function_name = &function.sig.ident; + let command_name = function_name.to_string(); + let handler_name = format_ident!("__cap_desktop_command_{function_name}"); + let mut setup = Vec::new(); + let mut arguments = Vec::new(); + + for input in &function.sig.inputs { + let FnArg::Typed(argument) = input else { + return syn::Error::new_spanned(input, "desktop commands cannot have a receiver") + .to_compile_error(); + }; + let Pat::Ident(pattern) = argument.pat.as_ref() else { + return syn::Error::new_spanned( + &argument.pat, + "desktop command arguments must be identifiers", + ) + .to_compile_error(); + }; + let variable = &pattern.ident; + let type_name = last_type_name(&argument.ty); + + match type_name.as_deref() { + Some("AppHandle") => { + setup.push(quote! { let #variable = context.app().clone(); }); + arguments.push(quote! { #variable }); + } + Some("Window") | Some("WebviewWindow") => { + setup.push(quote! { let #variable = context.window(); }); + arguments.push(quote! { #variable }); + } + Some("State") | Some("MutableState") => { + let Some(inner) = first_type_argument(&argument.ty) else { + return syn::Error::new_spanned( + &argument.ty, + "state arguments must include their managed type", + ) + .to_compile_error(); + }; + if type_name.as_deref() == Some("MutableState") { + setup.push(quote! { + let #variable = context + .app() + .state::<::std::sync::Arc<::tokio::sync::RwLock<#inner>>>(); + }); + } else { + setup.push(quote! { let #variable = context.app().state::<#inner>(); }); + } + arguments.push(quote! { #variable }); + } + Some("Channel") => { + let Some(inner) = first_type_argument(&argument.ty) else { + return syn::Error::new_spanned( + &argument.ty, + "channel arguments must include their message type", + ) + .to_compile_error(); + }; + let key = snake_to_camel(&variable.to_string()); + setup.push(quote! { + let #variable = ::cap_desktop_runtime::Channel::<#inner>::from_value( + context.channel_sender(), + ::cap_desktop_runtime::take_argument(&mut arguments, #key)?, + context.window_label(), + )?; + }); + arguments.push(quote! { #variable }); + } + Some( + "WindowEditorInstance" + | "OptionalWindowEditorInstance" + | "WindowScreenshotEditorInstance", + ) => { + let ty = &argument.ty; + setup.push(quote! { + let #variable = <#ty as ::cap_desktop_runtime::CommandArg>::from_command(&context)?; + }); + arguments.push(quote! { #variable }); + } + _ => { + let key = snake_to_camel(&variable.to_string()); + if let Type::Reference(reference) = argument.ty.as_ref() { + let inner = reference.elem.as_ref(); + if last_type_name(inner).as_deref() == Some("str") { + setup.push(quote! { + let #variable: ::std::string::String = ::cap_desktop_runtime::deserialize_argument( + ::cap_desktop_runtime::take_argument(&mut arguments, #key)?, + #key, + )?; + }); + arguments.push(quote! { #variable.as_str() }); + } else { + setup.push(quote! { + let #variable: #inner = ::cap_desktop_runtime::deserialize_argument( + ::cap_desktop_runtime::take_argument(&mut arguments, #key)?, + #key, + )?; + }); + arguments.push(quote! { &#variable }); + } + } else { + let ty = &argument.ty; + setup.push(quote! { + let #variable: #ty = ::cap_desktop_runtime::deserialize_argument( + ::cap_desktop_runtime::take_argument(&mut arguments, #key)?, + #key, + )?; + }); + arguments.push(quote! { #variable }); + } + } + } + } + + let call = quote! { #function_name(#(#arguments),*) }; + let call = if function.sig.asyncness.is_some() { + quote! { #call.await } + } else { + call + }; + let serialize = match &function.sig.output { + ReturnType::Type(_, ty) if is_result_type(ty) => quote! { + let value = #call.map_err(|error| format!("{error:?}"))?; + ::cap_desktop_runtime::serialize_command_result(value) + }, + _ => quote! { + let value = #call; + ::cap_desktop_runtime::serialize_command_result(value) + }, + }; + + quote! { + #function + + fn #handler_name( + context: ::cap_desktop_runtime::CommandContext, + arguments: ::serde_json::Value, + ) -> ::cap_desktop_runtime::CommandFuture { + Box::pin(async move { + let mut arguments = ::cap_desktop_runtime::argument_object(arguments)?; + #(#setup)* + #serialize + }) + } + + ::cap_desktop_runtime::inventory::submit! { + ::cap_desktop_runtime::CommandRegistration { + name: #command_name, + handler: #handler_name, + } + } + } +} + +fn last_type_name(ty: &Type) -> Option { + match ty { + Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()), + Type::Reference(reference) => last_type_name(&reference.elem), + _ => None, + } +} + +fn first_type_argument(ty: &Type) -> Option { + let Type::Path(path) = ty else { + return None; + }; + let segment = path.path.segments.last()?; + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return None; + }; + arguments.args.iter().find_map(|argument| match argument { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) +} + +fn is_result_type(ty: &Type) -> bool { + last_type_name(ty).as_deref() == Some("Result") +} + +fn snake_to_camel(value: &str) -> String { + let mut output = String::new(); + let mut uppercase = false; + for character in value.trim_start_matches('_').chars() { + if character == '_' { + uppercase = true; + } else if uppercase { + output.extend(character.to_uppercase()); + uppercase = false; + } else { + output.push(character); + } + } + output +} + +fn pascal_to_kebab(value: &str) -> String { + let mut output = String::new(); + for (index, character) in value.chars().enumerate() { + if character.is_uppercase() && index > 0 { + output.push('-'); + } + output.extend(character.to_lowercase()); + } + output +} diff --git a/crates/desktop-runtime/Cargo.toml b/crates/desktop-runtime/Cargo.toml new file mode 100644 index 00000000000..9011f433a43 --- /dev/null +++ b/crates/desktop-runtime/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cap-desktop-runtime" +version = "0.1.0" +edition = "2024" + +[dependencies] +cap-desktop-runtime-macros = { path = "../desktop-runtime-macros" } +dirs = "6" +futures = { workspace = true } +inventory = "0.3.19" +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["io-util", "net"] } +tracing = { workspace = true } +url = "2" + +[lints] +workspace = true diff --git a/crates/desktop-runtime/src/lib.rs b/crates/desktop-runtime/src/lib.rs new file mode 100644 index 00000000000..b028bde45a4 --- /dev/null +++ b/crates/desktop-runtime/src/lib.rs @@ -0,0 +1,1372 @@ +mod protocol; +mod transport; + +use std::{ + any::{Any, TypeId}, + collections::{HashMap, hash_map::Entry}, + future::Future, + marker::PhantomData, + ops::Deref, + path::PathBuf, + pin::Pin, + sync::{ + Arc, Mutex as StdMutex, OnceLock, RwLock, + atomic::{AtomicU64, Ordering}, + }, +}; + +extern crate self as cap_desktop_runtime; + +pub use cap_desktop_runtime_macros::{Event, command}; +pub use inventory; +pub use protocol::{ + BackendMessage, ChannelMessage, CommandResponse, DesktopOperation, PROTOCOL_VERSION, + ShellMessage, WindowEvent, WindowOptions, WindowState, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Map, Value}; +use tokio::sync::mpsc; +pub use transport::{BackendOptions, run_backend}; +pub use url::Url; + +pub type Error = String; +pub type Result = std::result::Result; +pub type CommandFuture = Pin> + Send + 'static>>; +pub type EventId = u64; + +pub trait Runtime: Send + Sync + 'static {} + +#[derive(Clone, Copy, Debug)] +pub struct ElectronRuntime; + +impl Runtime for ElectronRuntime {} + +pub type Wry = ElectronRuntime; + +pub trait Manager { + fn app_handle(&self) -> &AppHandle; + + fn state(&self) -> State<'_, T> { + self.app_handle().state() + } + + fn try_state(&self) -> Option> { + self.app_handle().try_state() + } +} +pub trait Emitter {} +pub trait Listener {} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub struct LogicalPosition { + pub x: T, + pub y: T, +} + +impl LogicalPosition { + pub const fn new(x: T, y: T) -> Self { + Self { x, y } + } +} + +impl From> for Position { + fn from(value: LogicalPosition) -> Self { + Self::Logical(LogicalPosition::new(value.x as f64, value.y as f64)) + } +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub struct PhysicalPosition { + pub x: T, + pub y: T, +} + +impl PhysicalPosition { + pub const fn new(x: T, y: T) -> Self { + Self { x, y } + } +} + +impl PhysicalPosition { + pub fn to_logical>(self, scale_factor: f64) -> LogicalPosition { + LogicalPosition::new( + U::from(self.x as f64 / scale_factor), + U::from(self.y as f64 / scale_factor), + ) + } +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub struct LogicalSize { + pub width: T, + pub height: T, +} + +impl LogicalSize { + pub const fn new(width: T, height: T) -> Self { + Self { width, height } + } +} + +impl From> for Size { + fn from(value: LogicalSize) -> Self { + Self::Logical(LogicalSize::new(value.width as f64, value.height as f64)) + } +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub struct PhysicalSize { + pub width: T, + pub height: T, +} + +impl PhysicalSize { + pub const fn new(width: T, height: T) -> Self { + Self { width, height } + } +} + +impl PhysicalSize { + pub fn to_logical>(self, scale_factor: f64) -> LogicalSize { + LogicalSize::new( + U::from(self.width as f64 / scale_factor), + U::from(self.height as f64 / scale_factor), + ) + } +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub enum Position { + Logical(LogicalPosition), + Physical(PhysicalPosition), +} + +impl From> for Position { + fn from(value: LogicalPosition) -> Self { + Self::Logical(value) + } +} + +impl From> for Position { + fn from(value: PhysicalPosition) -> Self { + Self::Physical(value) + } +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub enum Size { + Logical(LogicalSize), + Physical(PhysicalSize), +} + +impl From> for Size { + fn from(value: LogicalSize) -> Self { + Self::Logical(value) + } +} + +impl From> for Size { + fn from(value: PhysicalSize) -> Self { + Self::Physical(value) + } +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct Monitor { + pub name: Option, + pub position: PhysicalPosition, + pub size: PhysicalSize, + pub work_area: MonitorWorkArea, + pub scale_factor: f64, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct MonitorWorkArea { + pub position: PhysicalPosition, + pub size: PhysicalSize, +} + +impl Monitor { + pub fn position(&self) -> &PhysicalPosition { + &self.position + } + + pub fn size(&self) -> &PhysicalSize { + &self.size + } + + pub fn scale_factor(&self) -> f64 { + self.scale_factor + } +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Theme { + Light, + Dark, +} + +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ActivationPolicy { + Regular, + Accessory, + Prohibited, +} + +pub mod path { + #[derive(Clone, Copy, Debug)] + pub enum BaseDirectory { + Audio, + Cache, + Config, + Data, + LocalData, + Desktop, + Document, + Download, + Home, + Picture, + Public, + Resource, + Temp, + Template, + Video, + AppConfig, + AppData, + AppLocalData, + AppCache, + AppLog, + } +} + +pub mod ipc { + pub use crate::{Channel, CommandArg}; + pub type InvokeError = String; +} + +pub struct CommandRegistration { + pub name: &'static str, + pub handler: fn(CommandContext, Value) -> CommandFuture, +} + +inventory::collect!(CommandRegistration); + +#[derive(Clone)] +pub struct CommandContext { + app: AppHandle, + window_label: String, + channel_sender: mpsc::UnboundedSender, +} + +impl CommandContext { + pub fn new( + app: AppHandle, + window_label: impl Into, + channel_sender: mpsc::UnboundedSender, + ) -> Self { + Self { + app, + window_label: window_label.into(), + channel_sender, + } + } + + pub fn app(&self) -> &AppHandle { + &self.app + } + + pub fn window_label(&self) -> &str { + &self.window_label + } + + pub fn window(&self) -> Window { + Window::new(self.app.clone(), self.window_label.clone()) + } + + pub fn channel_sender(&self) -> mpsc::UnboundedSender { + self.channel_sender.clone() + } +} + +pub trait CommandArg: Sized { + fn from_command(context: &CommandContext) -> Result; +} + +pub fn argument_object(value: Value) -> Result> { + match value { + Value::Null => Ok(Map::new()), + Value::Object(arguments) => Ok(arguments), + _ => Err("desktop command arguments must be an object".to_string()), + } +} + +pub fn take_argument(arguments: &mut Map, name: &str) -> Result { + arguments + .remove(name) + .ok_or_else(|| format!("missing desktop command argument '{name}'")) +} + +pub fn deserialize_argument(value: Value, name: &str) -> Result { + serde_json::from_value(value) + .map_err(|error| format!("invalid desktop command argument '{name}': {error}")) +} + +pub fn serialize_command_result(value: T) -> Result { + serde_json::to_value(value) + .map_err(|error| format!("failed to serialize command result: {error}")) +} + +pub async fn dispatch_command( + context: CommandContext, + command: &str, + arguments: Value, +) -> Result { + let registration = inventory::iter:: + .into_iter() + .find(|registration| registration.name == command) + .ok_or_else(|| format!("unknown desktop command '{command}'"))?; + (registration.handler)(context, arguments).await +} + +type ManagedState = HashMap>; +type EventHandler = Arc; +type WindowEventHandler = Arc; +type GlobalWindowEventHandler = Arc; + +pub struct AppHandle { + inner: Arc, + runtime: PhantomData, +} + +struct AppHandleInner { + outbound: mpsc::UnboundedSender, + state: RwLock, + windows: RwLock>, + listeners: RwLock>>, + window_listeners: RwLock>>, + global_window_listeners: RwLock>, + next_event_id: AtomicU64, + paths: AppPaths, + stores: RwLock>>, + native_requests: StdMutex>>>, + next_native_request_id: AtomicU64, + cursor_position: RwLock>, +} + +impl Clone for AppHandle { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + runtime: PhantomData, + } + } +} + +impl std::fmt::Debug for AppHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("AppHandle").finish_non_exhaustive() + } +} + +impl Manager for AppHandle { + fn app_handle(&self) -> &AppHandle { + self + } +} +impl Emitter for AppHandle {} +impl Listener for AppHandle {} + +impl AppHandle { + pub fn new(outbound: mpsc::UnboundedSender, paths: AppPaths) -> Self { + Self { + inner: Arc::new(AppHandleInner { + outbound, + state: RwLock::new(HashMap::new()), + windows: RwLock::new(HashMap::new()), + listeners: RwLock::new(HashMap::new()), + window_listeners: RwLock::new(HashMap::new()), + global_window_listeners: RwLock::new(HashMap::new()), + next_event_id: AtomicU64::new(1), + paths, + stores: RwLock::new(HashMap::new()), + native_requests: StdMutex::new(HashMap::new()), + next_native_request_id: AtomicU64::new(1), + cursor_position: RwLock::new(PhysicalPosition::new(0.0, 0.0)), + }), + runtime: PhantomData, + } + } + + pub fn manage(&self, value: T) -> bool { + let mut state = self + .inner + .state + .write() + .expect("desktop state lock poisoned"); + match state.entry(TypeId::of::()) { + Entry::Vacant(entry) => { + entry.insert(Arc::new(value)); + true + } + Entry::Occupied(_) => false, + } + } + + pub fn state(&self) -> State<'_, T> { + self.try_state::().unwrap_or_else(|| { + panic!( + "desktop state '{}' is not managed", + std::any::type_name::() + ) + }) + } + + pub fn try_state(&self) -> Option> { + let value = self + .inner + .state + .read() + .expect("desktop state lock poisoned") + .get(&TypeId::of::())? + .clone() + .downcast::() + .ok()?; + Some(State { + value, + lifetime: PhantomData, + }) + } + + pub fn emit(&self, event: &str, payload: S) -> Result<()> { + let payload = serde_json::to_value(payload) + .map_err(|error| format!("failed to serialize event '{event}': {error}"))?; + self.emit_value(event, payload) + } + + pub fn emit_value(&self, event: &str, payload: Value) -> Result<()> { + self.receive_event(event, payload.clone()); + self.send(BackendMessage::Event { + event: event.to_string(), + payload, + target: None, + }) + } + + pub fn receive_event(&self, event: &str, payload: Value) { + if let Some(handlers) = self + .inner + .listeners + .read() + .expect("desktop event lock poisoned") + .get(event) + { + for handler in handlers.values() { + handler(payload.clone()); + } + } + } + + pub fn receive_window_event(&self, label: String, event: WindowEvent) { + if matches!(event, WindowEvent::Destroyed) { + self.remove_window(&label); + } + if let Some(handlers) = self + .inner + .window_listeners + .read() + .expect("desktop window event lock poisoned") + .get(&label) + { + for handler in handlers.values() { + handler(event.clone()); + } + } + for handler in self + .inner + .global_window_listeners + .read() + .expect("desktop global window event lock poisoned") + .values() + { + handler(label.clone(), event.clone()); + } + } + + fn listen_value( + &self, + event: impl Into, + handler: impl Fn(Value) + Send + Sync + 'static, + ) -> EventId { + let id = self.inner.next_event_id.fetch_add(1, Ordering::Relaxed); + self.inner + .listeners + .write() + .expect("desktop event lock poisoned") + .entry(event.into()) + .or_default() + .insert(id, Arc::new(handler)); + id + } + + pub fn listen( + &self, + event: impl Into, + handler: impl Fn(RuntimeEvent) + Send + Sync + 'static, + ) -> EventId { + self.listen_value(event, move |payload| { + handler(RuntimeEvent { + payload: serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string()), + }); + }) + } + + pub fn listen_any( + &self, + event: impl Into, + handler: impl Fn(RuntimeEvent) + Send + Sync + 'static, + ) -> EventId { + self.listen(event, handler) + } + + pub fn unlisten(&self, id: EventId) { + for handlers in self + .inner + .listeners + .write() + .expect("desktop event lock poisoned") + .values_mut() + { + handlers.remove(&id); + } + } + + fn listen_window( + &self, + label: String, + handler: impl Fn(WindowEvent) + Send + Sync + 'static, + ) -> EventId { + let id = self.inner.next_event_id.fetch_add(1, Ordering::Relaxed); + self.inner + .window_listeners + .write() + .expect("desktop window event lock poisoned") + .entry(label) + .or_default() + .insert(id, Arc::new(handler)); + id + } + + pub fn on_window_event( + &self, + handler: impl Fn(Window, WindowEvent) + Send + Sync + 'static, + ) -> EventId { + let id = self.inner.next_event_id.fetch_add(1, Ordering::Relaxed); + let app = self.clone(); + self.inner + .global_window_listeners + .write() + .expect("desktop global window event lock poisoned") + .insert( + id, + Arc::new(move |label, event| { + handler(Window::new(app.clone(), label), event); + }), + ); + id + } + + pub fn get_webview_window(&self, label: &str) -> Option> { + self.inner + .windows + .read() + .expect("desktop window lock poisoned") + .contains_key(label) + .then(|| Window::new(self.clone(), label)) + } + + pub fn webview_windows(&self) -> HashMap> { + self.inner + .windows + .read() + .expect("desktop window lock poisoned") + .keys() + .map(|label| (label.clone(), Window::new(self.clone(), label))) + .collect() + } + + pub fn update_window_state(&self, label: String, state: WindowState) { + self.inner + .windows + .write() + .expect("desktop window lock poisoned") + .insert(label, state); + } + + pub fn remove_window(&self, label: &str) { + self.inner + .windows + .write() + .expect("desktop window lock poisoned") + .remove(label); + } + + pub fn path(&self) -> &AppPaths { + &self.inner.paths + } + + pub fn store(&self, name: &str) -> Result> { + if let Some(store) = self + .inner + .stores + .read() + .expect("desktop stores lock poisoned") + .get(name) + { + return Ok(store.clone()); + } + let path = self.inner.paths.app_data_dir.join(format!("{name}.json")); + let store = Arc::new(Store::load(path)?); + self.inner + .stores + .write() + .expect("desktop stores lock poisoned") + .insert(name.to_string(), store.clone()); + Ok(store) + } + + pub fn run_on_main_thread(&self, task: impl FnOnce() + Send + 'static) -> Result<()> { + task(); + Ok(()) + } + + pub fn exit(&self, code: i32) { + let _ = self.send(BackendMessage::NativeOperation { + operation: "app.exit".to_string(), + payload: serde_json::json!({ "exitCode": code }), + }); + } + + pub fn set_dock_visibility(&self, visible: bool) -> Result<()> { + self.native_operation( + "app.dockVisibility", + serde_json::json!({ "visible": visible }), + ) + } + + pub fn set_activation_policy(&self, policy: ActivationPolicy) -> Result<()> { + self.native_operation( + "app.activationPolicy", + serde_json::json!({ "policy": policy }), + ) + } + + pub fn native_operation(&self, operation: &str, payload: S) -> Result<()> { + let payload = serde_json::to_value(payload) + .map_err(|error| format!("failed to serialize native operation: {error}"))?; + self.send(BackendMessage::NativeOperation { + operation: operation.to_string(), + payload, + }) + } + + pub async fn native_request( + &self, + operation: &str, + payload: S, + ) -> Result { + let id = self + .inner + .next_native_request_id + .fetch_add(1, Ordering::Relaxed); + let payload = serde_json::to_value(payload) + .map_err(|error| format!("failed to serialize native request: {error}"))?; + let (sender, receiver) = tokio::sync::oneshot::channel(); + self.inner + .native_requests + .lock() + .expect("desktop native request lock poisoned") + .insert(id, sender); + if let Err(error) = self.send(BackendMessage::NativeRequest { + id, + operation: operation.to_string(), + payload, + }) { + self.inner + .native_requests + .lock() + .expect("desktop native request lock poisoned") + .remove(&id); + return Err(error); + } + let value = receiver + .await + .map_err(|_| "Electron native request was cancelled".to_string())??; + serde_json::from_value(value) + .map_err(|error| format!("invalid Electron native response: {error}")) + } + + pub fn receive_native_result(&self, id: u64, result: Result) { + if let Some(sender) = self + .inner + .native_requests + .lock() + .expect("desktop native request lock poisoned") + .remove(&id) + { + let _ = sender.send(result); + } + } + + pub fn receive_cursor_position(&self, x: f64, y: f64) { + *self + .inner + .cursor_position + .write() + .expect("desktop cursor position lock poisoned") = PhysicalPosition::new(x, y); + } + + pub fn send(&self, message: BackendMessage) -> Result<()> { + self.inner + .outbound + .send(message) + .map_err(|_| "Electron desktop connection is closed".to_string()) + } +} + +pub struct State<'a, T> { + value: Arc, + lifetime: PhantomData<&'a T>, +} + +pub type MutableState<'a, T> = State<'a, T>; + +impl Clone for State<'_, T> { + fn clone(&self) -> Self { + Self { + value: self.value.clone(), + lifetime: PhantomData, + } + } +} + +impl Deref for State<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl State<'_, T> { + pub fn inner(&self) -> &T { + &self.value + } +} + +impl std::fmt::Debug for State<'_, T> { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(formatter) + } +} + +pub struct Window { + app: AppHandle, + label: String, +} + +pub type WebviewWindow = Window; + +impl Clone for Window { + fn clone(&self) -> Self { + Self { + app: self.app.clone(), + label: self.label.clone(), + } + } +} + +impl std::fmt::Debug for Window { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Window") + .field("label", &self.label) + .finish() + } +} + +impl Manager for Window { + fn app_handle(&self) -> &AppHandle { + &self.app + } +} +impl Emitter for Window {} +impl Listener for Window {} + +impl Window { + pub fn new(app: AppHandle, label: impl Into) -> Self { + Self { + app, + label: label.into(), + } + } + + pub fn label(&self) -> &str { + &self.label + } + + pub fn app_handle(&self) -> &AppHandle { + &self.app + } + + pub fn run_on_main_thread(&self, task: impl FnOnce() + Send + 'static) -> Result<()> { + self.app.run_on_main_thread(task) + } + + pub fn state(&self) -> State<'_, T> { + self.app.state() + } + + pub fn try_state(&self) -> Option> { + self.app.try_state() + } + + pub fn manage(&self, value: T) -> bool { + self.app.manage(value) + } + + pub fn show(&self) -> Result<()> { + self.operation(DesktopOperation::Show) + } + + pub fn hide(&self) -> Result<()> { + self.operation(DesktopOperation::Hide) + } + + pub fn close(&self) -> Result<()> { + self.operation(DesktopOperation::Close) + } + + pub fn destroy(&self) -> Result<()> { + self.operation(DesktopOperation::Destroy) + } + + pub fn set_focus(&self) -> Result<()> { + self.operation(DesktopOperation::Focus) + } + + pub fn is_visible(&self) -> Result { + Ok(self.state_snapshot()?.visible) + } + + pub fn is_focused(&self) -> Result { + Ok(self.state_snapshot()?.focused) + } + + pub fn is_fullscreen(&self) -> Result { + Ok(self.state_snapshot()?.fullscreen) + } + + pub fn native_window_id(&self) -> Result> { + Ok(self.state_snapshot()?.native_window_id) + } + + pub fn set_content_protected(&self, enabled: bool) -> Result<()> { + self.operation(DesktopOperation::SetContentProtected { enabled }) + } + + pub fn minimize(&self) -> Result<()> { + self.operation(DesktopOperation::Minimize) + } + + pub fn unminimize(&self) -> Result<()> { + self.operation(DesktopOperation::Unminimize) + } + + pub fn maximize(&self) -> Result<()> { + self.operation(DesktopOperation::Maximize) + } + + pub fn unmaximize(&self) -> Result<()> { + self.operation(DesktopOperation::Unmaximize) + } + + pub fn set_fullscreen(&self, fullscreen: bool) -> Result<()> { + self.operation(DesktopOperation::SetFullscreen { fullscreen }) + } + + pub fn set_always_on_top(&self, always_on_top: bool) -> Result<()> { + self.operation(DesktopOperation::SetAlwaysOnTop { always_on_top }) + } + + pub fn set_ignore_cursor_events(&self, ignore: bool) -> Result<()> { + self.operation(DesktopOperation::SetIgnoreCursorEvents { ignore }) + } + + pub fn set_position(&self, position: impl Into) -> Result<()> { + let (x, y, physical) = match position.into() { + Position::Logical(position) => (position.x, position.y, false), + Position::Physical(position) => (position.x as f64, position.y as f64, true), + }; + self.operation(DesktopOperation::SetPosition { x, y, physical }) + } + + pub fn set_size(&self, size: impl Into) -> Result<()> { + let (width, height, physical) = match size.into() { + Size::Logical(size) => (size.width, size.height, false), + Size::Physical(size) => (size.width as f64, size.height as f64, true), + }; + self.operation(DesktopOperation::SetSize { + width, + height, + physical, + }) + } + + pub fn set_min_size(&self, size: Option) -> Result<()> { + let (width, height, physical) = match size { + Some(Size::Logical(size)) => (Some(size.width), Some(size.height), false), + Some(Size::Physical(size)) => (Some(size.width as f64), Some(size.height as f64), true), + None => (None, None, false), + }; + self.operation(DesktopOperation::SetMinSize { + width, + height, + physical, + }) + } + + pub fn set_title(&self, title: &str) -> Result<()> { + self.operation(DesktopOperation::SetTitle { + title: title.to_string(), + }) + } + + pub fn set_resizable(&self, resizable: bool) -> Result<()> { + self.operation(DesktopOperation::SetResizable { resizable }) + } + + pub fn set_opacity(&self, opacity: f64) -> Result<()> { + self.operation(DesktopOperation::SetOpacity { opacity }) + } + + pub fn outer_position(&self) -> Result> { + let state = self.state_snapshot()?; + Ok(PhysicalPosition::new(state.x as i32, state.y as i32)) + } + + pub fn outer_size(&self) -> Result> { + let state = self.state_snapshot()?; + Ok(PhysicalSize::new(state.width as u32, state.height as u32)) + } + + pub fn inner_size(&self) -> Result> { + self.outer_size() + } + + pub fn scale_factor(&self) -> Result { + Ok(self.state_snapshot()?.scale_factor) + } + + pub fn current_monitor(&self) -> Result> { + let state = self.state_snapshot()?; + Ok(state.monitor.map(|monitor| Monitor { + name: monitor.name, + position: PhysicalPosition::new(monitor.x, monitor.y), + size: PhysicalSize::new(monitor.width, monitor.height), + work_area: MonitorWorkArea { + position: PhysicalPosition::new(monitor.work_x, monitor.work_y), + size: PhysicalSize::new(monitor.work_width, monitor.work_height), + }, + scale_factor: monitor.scale_factor.max(1.0), + })) + } + + pub fn cursor_position(&self) -> Result> { + Ok(*self + .app + .inner + .cursor_position + .read() + .expect("desktop cursor position lock poisoned")) + } + + pub fn emit(&self, event: &str, payload: S) -> Result<()> { + let payload = serde_json::to_value(payload) + .map_err(|error| format!("failed to serialize event '{event}': {error}"))?; + self.app.send(BackendMessage::Event { + event: event.to_string(), + payload, + target: Some(self.label.clone()), + }) + } + + pub fn on_window_event( + &self, + handler: impl Fn(WindowEvent) + Send + Sync + 'static, + ) -> EventId { + self.app.listen_window(self.label.clone(), handler) + } + + pub fn operation(&self, operation: DesktopOperation) -> Result<()> { + self.app.send(BackendMessage::DesktopOperation { + label: self.label.clone(), + operation, + }) + } + + fn state_snapshot(&self) -> Result { + self.app + .inner + .windows + .read() + .expect("desktop window lock poisoned") + .get(&self.label) + .cloned() + .ok_or_else(|| format!("window '{}' does not exist", self.label)) + } +} + +#[derive(Clone)] +pub struct Channel { + id: u64, + target: String, + next_index: Arc, + sender: Option>, + local_sender: Option Result<()> + Send + Sync>>, + message: PhantomData, +} + +impl Channel { + pub fn new(sender: impl Fn(T) -> Result<()> + Send + Sync + 'static) -> Self { + Self { + id: 0, + target: String::new(), + next_index: Arc::new(AtomicU64::new(0)), + sender: None, + local_sender: Some(Arc::new(sender)), + message: PhantomData, + } + } + + pub fn from_value( + sender: mpsc::UnboundedSender, + value: Value, + target: impl Into, + ) -> Result { + let marker = value + .as_str() + .and_then(|value| value.strip_prefix("__CHANNEL__:")) + .ok_or_else(|| "invalid desktop channel marker".to_string())?; + let id = marker + .parse() + .map_err(|error| format!("invalid desktop channel id: {error}"))?; + Ok(Self { + id, + target: target.into(), + next_index: Arc::new(AtomicU64::new(0)), + sender: Some(sender), + local_sender: None, + message: PhantomData, + }) + } +} + +impl Channel { + pub fn send(&self, value: T) -> Result<()> { + if let Some(sender) = &self.local_sender { + return sender(value); + } + let message = serde_json::to_value(value) + .map_err(|error| format!("failed to serialize channel message: {error}"))?; + self.sender + .as_ref() + .ok_or_else(|| "desktop channel sender is unavailable".to_string())? + .send(ChannelMessage { + target: self.target.clone(), + channel_id: self.id, + index: self.next_index.fetch_add(1, Ordering::Relaxed), + message: Some(message), + end: false, + }) + .map_err(|_| "Electron desktop channel is closed".to_string()) + } +} + +impl Drop for Channel { + fn drop(&mut self) { + let Some(sender) = &self.sender else { + return; + }; + let _ = sender.send(ChannelMessage { + target: self.target.clone(), + channel_id: self.id, + index: self.next_index.load(Ordering::Relaxed), + message: None, + end: true, + }); + } +} + +pub trait Event: Sized + 'static { + const NAME: &'static str; + + fn emit(&self, app: &AppHandle) -> Result<()> + where + Self: Serialize, + { + app.emit(Self::NAME, self) + } + + fn listen_any( + app: &AppHandle, + handler: impl Fn(EventPayload) + Send + Sync + 'static, + ) -> EventId + where + Self: DeserializeOwned, + { + app.listen_value(Self::NAME, move |payload| { + match serde_json::from_value(payload) { + Ok(payload) => handler(EventPayload { payload }), + Err(error) => { + tracing::warn!(event = Self::NAME, %error, "Invalid desktop event payload") + } + } + }) + } +} + +pub struct EventPayload { + pub payload: T, +} + +pub struct RuntimeEvent { + payload: String, +} + +impl RuntimeEvent { + pub fn payload(&self) -> &str { + &self.payload + } +} + +pub struct Store { + path: PathBuf, + values: RwLock>, +} + +impl Store { + fn load(path: PathBuf) -> Result { + let values = match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to read desktop store: {error}"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Map::new(), + Err(error) => return Err(format!("failed to read desktop store: {error}")), + }; + Ok(Self { + path, + values: RwLock::new(values), + }) + } + + pub fn get(&self, key: &str) -> Option { + self.values + .read() + .expect("desktop store lock poisoned") + .get(key) + .cloned() + } + + pub fn set(&self, key: impl Into, value: T) { + match serde_json::to_value(value) { + Ok(value) => { + self.values + .write() + .expect("desktop store lock poisoned") + .insert(key.into(), value); + } + Err(error) => tracing::error!(%error, "Failed to serialize desktop store value"), + } + } + + pub fn delete(&self, key: &str) -> bool { + self.values + .write() + .expect("desktop store lock poisoned") + .remove(key) + .is_some() + } + + pub fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create desktop store directory: {error}"))?; + } + let bytes = + serde_json::to_vec_pretty(&*self.values.read().expect("desktop store lock poisoned")) + .map_err(|error| format!("failed to serialize desktop store: {error}"))?; + std::fs::write(&self.path, bytes) + .map_err(|error| format!("failed to write desktop store: {error}")) + } +} + +#[derive(Clone)] +pub struct AppPaths { + app_data_dir: PathBuf, + resource_dir: PathBuf, +} + +impl AppPaths { + pub fn discover(identifier: &str, resource_dir: PathBuf) -> Result { + let app_data_dir = std::env::var_os("CAP_ELECTRON_APP_DATA_DIR") + .map(PathBuf::from) + .map(Ok) + .unwrap_or_else(|| { + dirs::data_local_dir() + .ok_or_else(|| "failed to resolve local application data directory".to_string()) + .map(|directory| directory.join(identifier)) + })?; + Ok(Self { + app_data_dir, + resource_dir, + }) + } + + pub fn app_data_dir(&self) -> Result { + Ok(self.app_data_dir.clone()) + } + + pub fn app_local_data_dir(&self) -> Result { + Ok(self.app_data_dir.clone()) + } + + pub fn resource_dir(&self) -> Result { + Ok(self.resource_dir.clone()) + } + + pub fn resolve( + &self, + path: impl AsRef, + base: path::BaseDirectory, + ) -> Result { + let base = match base { + path::BaseDirectory::Resource => Some(self.resource_dir.clone()), + path::BaseDirectory::AppData + | path::BaseDirectory::AppLocalData + | path::BaseDirectory::AppConfig + | path::BaseDirectory::AppCache + | path::BaseDirectory::AppLog => Some(self.app_data_dir.clone()), + path::BaseDirectory::Home => dirs::home_dir(), + path::BaseDirectory::Desktop => dirs::desktop_dir(), + path::BaseDirectory::Document => dirs::document_dir(), + path::BaseDirectory::Download => dirs::download_dir(), + path::BaseDirectory::Picture => dirs::picture_dir(), + path::BaseDirectory::Video => dirs::video_dir(), + path::BaseDirectory::Audio => dirs::audio_dir(), + path::BaseDirectory::Cache => dirs::cache_dir(), + path::BaseDirectory::Config => dirs::config_dir(), + path::BaseDirectory::Data | path::BaseDirectory::LocalData => dirs::data_local_dir(), + path::BaseDirectory::Public + | path::BaseDirectory::Temp + | path::BaseDirectory::Template => Some(std::env::temp_dir()), + } + .ok_or_else(|| "failed to resolve desktop base directory".to_string())?; + Ok(base.join(path)) + } +} + +pub mod async_runtime { + use super::*; + + static HANDLE: OnceLock = OnceLock::new(); + + pub fn set(handle: tokio::runtime::Handle) { + let _ = HANDLE.set(handle); + } + + pub fn spawn(future: F) -> tokio::task::JoinHandle + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + HANDLE + .get() + .cloned() + .unwrap_or_else(tokio::runtime::Handle::current) + .spawn(future) + } + + pub fn spawn_blocking(task: F) -> tokio::task::JoinHandle + where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, + { + HANDLE + .get() + .cloned() + .unwrap_or_else(tokio::runtime::Handle::current) + .spawn_blocking(task) + } + + pub fn block_on(future: F) -> F::Output { + HANDLE + .get() + .cloned() + .unwrap_or_else(tokio::runtime::Handle::current) + .block_on(future) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Clone, Debug, Deserialize, Event, PartialEq, Serialize)] + struct RecordingStarted { + id: String, + } + + #[test] + fn event_names_match_the_existing_frontend_contract() { + assert_eq!(RecordingStarted::NAME, "recording-started"); + } + + #[test] + fn command_argument_names_are_deserialized_from_json() { + let mut arguments = + argument_object(serde_json::json!({ "projectPath": "/tmp/a.cap" })).unwrap(); + let project_path: PathBuf = deserialize_argument( + take_argument(&mut arguments, "projectPath").unwrap(), + "projectPath", + ) + .unwrap(); + assert_eq!(project_path, PathBuf::from("/tmp/a.cap")); + } + + #[test] + fn channel_messages_are_bound_to_the_invoking_window() { + let (sender, mut receiver) = mpsc::unbounded_channel(); + let channel = + Channel::::from_value(sender, serde_json::json!("__CHANNEL__:7"), "editor-42") + .unwrap(); + + channel.send(123).unwrap(); + drop(channel); + + let message = receiver.try_recv().unwrap(); + assert_eq!(message.target, "editor-42"); + assert_eq!(message.channel_id, 7); + assert_eq!(message.index, 0); + assert_eq!(message.message, Some(serde_json::json!(123))); + assert!(!message.end); + + let end = receiver.try_recv().unwrap(); + assert_eq!(end.target, "editor-42"); + assert_eq!(end.channel_id, 7); + assert_eq!(end.index, 1); + assert_eq!(end.message, None); + assert!(end.end); + } +} diff --git a/crates/desktop-runtime/src/protocol.rs b/crates/desktop-runtime/src/protocol.rs new file mode 100644 index 00000000000..f6267ddef6f --- /dev/null +++ b/crates/desktop-runtime/src/protocol.rs @@ -0,0 +1,245 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ShellMessage { + Hello { + token: String, + protocol_version: u32, + }, + Invoke { + id: u64, + window_label: String, + command: String, + arguments: Value, + }, + WindowState { + label: String, + state: WindowState, + }, + WindowEvent { + label: String, + event: WindowEvent, + }, + Event { + event: String, + #[serde(default)] + payload: Value, + }, + CursorPosition { + x: f64, + y: f64, + }, + NativeResult { + id: u64, + result: std::result::Result, + }, + Shutdown, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum BackendMessage { + Ready { + protocol_version: u32, + commands: Vec, + }, + InvokeResult { + id: u64, + response: CommandResponse, + }, + Event { + event: String, + payload: Value, + target: Option, + }, + Channel(ChannelMessage), + CreateWindow { + options: WindowOptions, + }, + DesktopOperation { + label: String, + operation: DesktopOperation, + }, + NativeOperation { + operation: String, + payload: Value, + }, + NativeRequest { + id: u64, + operation: String, + payload: Value, + }, + ShutdownComplete, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum CommandResponse { + Ok { value: Value }, + Error { error: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelMessage { + pub target: String, + pub channel_id: u64, + pub index: u64, + pub message: Option, + pub end: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowOptions { + pub label: String, + pub route: String, + pub title: String, + pub x: Option, + pub y: Option, + pub width: f64, + pub height: f64, + pub min_width: Option, + pub min_height: Option, + pub transparent: bool, + pub decorations: bool, + pub resizable: bool, + pub always_on_top: bool, + pub visible_on_all_workspaces: bool, + pub skip_taskbar: bool, + pub content_protected: bool, + pub focus: bool, + pub visible: bool, + pub initialization: Value, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowState { + #[serde(default)] + pub native_window_id: Option, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub visible: bool, + pub focused: bool, + pub minimized: bool, + pub maximized: bool, + pub fullscreen: bool, + pub always_on_top: bool, + pub scale_factor: f64, + #[serde(default)] + pub monitor: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowMonitorState { + pub name: Option, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub work_x: i32, + pub work_y: i32, + pub work_width: u32, + pub work_height: u32, + pub scale_factor: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum WindowEvent { + CloseRequested, + Destroyed, + Focused { focused: bool }, + Moved { x: f64, y: f64 }, + Resized { width: f64, height: f64 }, + ScaleFactorChanged { scale_factor: f64 }, + ThemeChanged { theme: String }, + DragDrop { paths: Vec }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum DesktopOperation { + Show, + Hide, + Close, + Destroy, + Focus, + Minimize, + Unminimize, + Maximize, + Unmaximize, + SetFullscreen { + fullscreen: bool, + }, + SetAlwaysOnTop { + always_on_top: bool, + }, + SetContentProtected { + enabled: bool, + }, + SetPosition { + x: f64, + y: f64, + physical: bool, + }, + SetSize { + width: f64, + height: f64, + physical: bool, + }, + SetMinSize { + width: Option, + height: Option, + physical: bool, + }, + SetTitle { + title: String, + }, + SetResizable { + resizable: bool, + }, + SetIgnoreCursorEvents { + ignore: bool, + }, + SetOpacity { + opacity: f64, + }, + SetTheme { + theme: String, + }, + SetTrafficLightPosition { + x: Option, + y: Option, + }, + RequestUserAttention { + critical: bool, + }, + SetProgress { + progress: Option, + }, +} diff --git a/crates/desktop-runtime/src/transport.rs b/crates/desktop-runtime/src/transport.rs new file mode 100644 index 00000000000..30365a06bbc --- /dev/null +++ b/crates/desktop-runtime/src/transport.rs @@ -0,0 +1,212 @@ +use std::{future::Future, path::PathBuf}; + +use serde::{Serialize, de::DeserializeOwned}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::TcpStream, + sync::mpsc, +}; + +use crate::{ + AppHandle, AppPaths, BackendMessage, ChannelMessage, CommandContext, CommandResponse, + PROTOCOL_VERSION, Result, ShellMessage, dispatch_command, +}; + +const MAX_FRAME_SIZE: usize = 128 * 1024 * 1024; + +pub struct BackendOptions { + pub identifier: String, + pub resource_dir: PathBuf, +} + +impl BackendOptions { + pub fn new(identifier: impl Into, resource_dir: impl Into) -> Self { + Self { + identifier: identifier.into(), + resource_dir: resource_dir.into(), + } + } +} + +pub async fn run_backend( + options: BackendOptions, + setup: Setup, + shutdown: Shutdown, +) -> Result<()> +where + Setup: FnOnce(&AppHandle) -> Result<()>, + Shutdown: FnOnce(AppHandle) -> ShutdownFuture, + ShutdownFuture: Future>, +{ + let address = std::env::var("CAP_ELECTRON_IPC_ADDR") + .map_err(|_| "CAP_ELECTRON_IPC_ADDR is not set".to_string())?; + let expected_token = std::env::var("CAP_ELECTRON_IPC_TOKEN") + .map_err(|_| "CAP_ELECTRON_IPC_TOKEN is not set".to_string())?; + let stream = TcpStream::connect(&address) + .await + .map_err(|error| format!("failed to connect to Electron at {address}: {error}"))?; + let (mut reader, mut writer) = stream.into_split(); + + let hello = read_frame::<_, ShellMessage>(&mut reader).await?; + match hello { + ShellMessage::Hello { + token, + protocol_version, + } if token == expected_token && protocol_version == PROTOCOL_VERSION => {} + ShellMessage::Hello { .. } => { + return Err("Electron desktop authentication failed".to_string()); + } + _ => return Err("Electron desktop did not begin with a hello message".to_string()), + } + + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel(); + let paths = AppPaths::discover(&options.identifier, options.resource_dir)?; + let app = AppHandle::new(outbound_tx.clone(), paths); + setup(&app)?; + + let (channel_tx, mut channel_rx) = mpsc::unbounded_channel::(); + let channel_outbound = outbound_tx.clone(); + tokio::spawn(async move { + while let Some(message) = channel_rx.recv().await { + if channel_outbound + .send(BackendMessage::Channel(message)) + .is_err() + { + break; + } + } + }); + + let writer_task = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + let shutdown_complete = matches!(message, BackendMessage::ShutdownComplete); + write_frame(&mut writer, &message).await?; + if shutdown_complete { + break; + } + } + Ok::<_, String>(()) + }); + + let mut commands = inventory::iter:: + .into_iter() + .map(|registration| registration.name.to_string()) + .collect::>(); + commands.sort_unstable(); + app.send(BackendMessage::Ready { + protocol_version: PROTOCOL_VERSION, + commands, + })?; + + loop { + match read_frame::<_, ShellMessage>(&mut reader).await? { + ShellMessage::Hello { .. } => { + return Err("Electron desktop sent a duplicate hello message".to_string()); + } + ShellMessage::Invoke { + id, + window_label, + command, + arguments, + } => { + let context = CommandContext::new(app.clone(), window_label, channel_tx.clone()); + let invoke_outbound = outbound_tx.clone(); + tokio::spawn(async move { + let response = match dispatch_command(context, &command, arguments).await { + Ok(value) => CommandResponse::Ok { value }, + Err(error) => CommandResponse::Error { error }, + }; + let _ = invoke_outbound.send(BackendMessage::InvokeResult { id, response }); + }); + } + ShellMessage::WindowState { label, state } => app.update_window_state(label, state), + ShellMessage::WindowEvent { label, event } => app.receive_window_event(label, event), + ShellMessage::Event { event, payload } => app.receive_event(&event, payload), + ShellMessage::CursorPosition { x, y } => app.receive_cursor_position(x, y), + ShellMessage::NativeResult { id, result } => app.receive_native_result(id, result), + ShellMessage::Shutdown => break, + } + } + + shutdown(app.clone()).await?; + app.send(BackendMessage::ShutdownComplete)?; + drop(app); + drop(outbound_tx); + writer_task + .await + .map_err(|error| format!("Electron desktop writer task failed: {error}"))??; + Ok(()) +} + +async fn read_frame(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, + T: DeserializeOwned, +{ + let length = reader + .read_u32() + .await + .map_err(|error| format!("failed to read Electron desktop frame: {error}"))? + as usize; + if length > MAX_FRAME_SIZE { + return Err(format!( + "Electron desktop frame exceeded {MAX_FRAME_SIZE} bytes" + )); + } + let mut bytes = vec![0; length]; + reader + .read_exact(&mut bytes) + .await + .map_err(|error| format!("failed to read Electron desktop payload: {error}"))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("invalid Electron desktop message: {error}")) +} + +async fn write_frame(writer: &mut W, message: &T) -> Result<()> +where + W: AsyncWrite + Unpin, + T: Serialize, +{ + let bytes = serde_json::to_vec(message) + .map_err(|error| format!("failed to serialize Electron desktop message: {error}"))?; + if bytes.len() > MAX_FRAME_SIZE { + return Err(format!( + "Electron desktop frame exceeded {MAX_FRAME_SIZE} bytes" + )); + } + writer + .write_u32(bytes.len() as u32) + .await + .map_err(|error| format!("failed to write Electron desktop frame: {error}"))?; + writer + .write_all(&bytes) + .await + .map_err(|error| format!("failed to write Electron desktop payload: {error}"))?; + writer + .flush() + .await + .map_err(|error| format!("failed to flush Electron desktop payload: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::duplex; + + #[tokio::test] + async fn framed_messages_round_trip() { + let (mut sender, mut receiver) = duplex(4096); + let message = BackendMessage::Event { + event: "recording-started".to_string(), + payload: serde_json::json!({ "id": "recording-1" }), + target: None, + }; + let expected = serde_json::to_value(&message).unwrap(); + let send = tokio::spawn(async move { write_frame(&mut sender, &message).await }); + let received = read_frame::<_, BackendMessage>(&mut receiver) + .await + .unwrap(); + send.await.unwrap().unwrap(); + assert_eq!(serde_json::to_value(received).unwrap(), expected); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b3211341374..565ab8e7569 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1,10 +1,14 @@ -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; use std::time::Instant; -use cap_project::{ClipTransitionType, CursorEvents, ProjectConfiguration}; +use cap_project::{BackgroundSource, ClipTransitionType, CursorEvents, ProjectConfiguration}; use cap_rendering::{ DecodedSegmentFrames, FrameLayout, FrameRenderStageTimings, FrameRenderer, Nv12RenderedFrame, - ProjectUniforms, RenderVideoConstants, RenderedFrame, RendererLayers, TransitionRenderInput, + ProjectUniforms, RenderVideoConstants, RenderedFrame, RendererLayers, RenderingError, + TransitionRenderInput, clean_background_path, }; use tokio::sync::{mpsc, oneshot}; @@ -51,12 +55,17 @@ pub type RendererLayersReceiver = oneshot::Receiver; pub type EditorFrameCallback = Box; +fn should_use_nv12_output(is_software_adapter: bool, nv12_output_available: bool) -> bool { + nv12_output_available && !is_software_adapter +} + pub struct Renderer { rx: mpsc::Receiver, frame_cb: EditorFrameCallback, render_constants: Arc, layers_rx: RendererLayersReceiver, telemetry: Option, + nv12_output_available: Arc, } pub struct RendererHandle { @@ -68,6 +77,20 @@ pub fn start_renderer_layers_creation( render_constants: &Arc, project: &ProjectConfiguration, ) -> RendererLayersReceiver { + if let BackgroundSource::Image { path } | BackgroundSource::Wallpaper { path } = + &project.background.source + && let Some(path) = path.as_deref().and_then(clean_background_path) + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + let constants = render_constants.clone(); + drop(runtime.spawn(async move { + constants + .background_textures + .ensure(&constants.device, &constants.queue, &path) + .await; + })); + } + let (layers_tx, layers_rx) = oneshot::channel(); let constants = render_constants.clone(); let use_svg = project.cursor.use_svg; @@ -111,6 +134,22 @@ impl Renderer { frame_cb: EditorFrameCallback, layers_rx: RendererLayersReceiver, telemetry: Option, + ) -> Result { + Self::spawn_with_output_capability( + render_constants, + frame_cb, + layers_rx, + telemetry, + Arc::new(AtomicBool::new(false)), + ) + } + + pub fn spawn_with_output_capability( + render_constants: Arc, + frame_cb: EditorFrameCallback, + layers_rx: RendererLayersReceiver, + telemetry: Option, + nv12_output_available: Arc, ) -> Result { let (tx, rx) = mpsc::channel(64); @@ -120,6 +159,7 @@ impl Renderer { render_constants, layers_rx, telemetry: telemetry.clone(), + nv12_output_available, }; tokio::spawn(this.run()); @@ -134,9 +174,11 @@ impl Renderer { render_constants, layers_rx, telemetry, + nv12_output_available, } = self; let mut frame_renderer = FrameRenderer::new(&render_constants); + let mut previous_nv12_output = false; let mut layers = match layers_rx.await { Ok(layers) => layers, @@ -313,9 +355,28 @@ impl Renderer { let queue_wait = current.queued_at.elapsed(); let drain_duration = queue_drain_start.elapsed(); + // Stay on the universally supported RGBA path until the webview + // confirms that its WebGPU renderer initialized successfully. + let use_nv12_output = should_use_nv12_output( + render_constants.is_software_adapter, + nv12_output_available.load(Ordering::Acquire), + ); + if use_nv12_output != previous_nv12_output { + if use_nv12_output { + let _ = frame_renderer.flush_pipeline().await; + frame_renderer.release_rgba_readback(); + } else { + let _ = frame_renderer.flush_pipeline_nv12().await; + } + previous_nv12_output = use_nv12_output; + } let flush_start = Instant::now(); if drained_count > 0 { - let _ = frame_renderer.flush_pipeline().await; + if use_nv12_output { + let _ = frame_renderer.flush_pipeline_nv12().await; + } else { + let _ = frame_renderer.flush_pipeline().await; + } } let flush_duration = if drained_count > 0 { flush_start.elapsed() @@ -326,17 +387,75 @@ impl Renderer { let render_start = Instant::now(); let input_frame_number = current.input.uniforms().frame_number; let frame_layout = current.input.uniforms().frame_layout(); - let render_result = match current.input { - PendingRenderInput::Single(input) => { - frame_renderer - .render_immediate_with_timings( - input.segment_frames, - input.uniforms, - &input.cursor, - true, - &mut layers, + let render_result: Result< + (EditorFrameOutput, FrameRenderStageTimings), + RenderingError, + > = match current.input { + PendingRenderInput::Single(input) if use_nv12_output => frame_renderer + .render_immediate_nv12( + input.segment_frames, + input.uniforms, + &input.cursor, + true, + &mut layers, + ) + .await + .map(|frame| { + ( + EditorFrameOutput::Nv12(frame), + FrameRenderStageTimings::default(), ) - .await + }), + PendingRenderInput::Single(input) => frame_renderer + .render_immediate_with_timings( + input.segment_frames, + input.uniforms, + &input.cursor, + true, + &mut layers, + ) + .await + .map(|(frame, timings)| (EditorFrameOutput::Rgba(frame), timings)), + PendingRenderInput::Transition { + outgoing, + incoming, + kind, + progress, + } if use_nv12_output => { + async { + let frame = frame_renderer + .render_transition_nv12( + TransitionRenderInput { + segment_frames: outgoing.segment_frames, + uniforms: outgoing.uniforms, + cursor: &outgoing.cursor, + render_display: true, + }, + TransitionRenderInput { + segment_frames: incoming.segment_frames, + uniforms: incoming.uniforms, + cursor: &incoming.cursor, + render_display: true, + }, + kind, + progress, + &mut layers, + ) + .await?; + let frame = if let Some(frame) = frame { + frame + } else { + frame_renderer + .flush_pipeline_nv12() + .await + .unwrap_or(Err(RenderingError::BufferMapWaitingFailed))? + }; + Ok(( + EditorFrameOutput::Nv12(frame), + FrameRenderStageTimings::default(), + )) + } + .await } PendingRenderInput::Transition { outgoing, @@ -362,15 +481,26 @@ impl Renderer { &mut layers, ) .await - .map(|frame| (frame, FrameRenderStageTimings::default())), + .map(|frame| { + ( + EditorFrameOutput::Rgba(frame), + FrameRenderStageTimings::default(), + ) + }), }; match render_result { Ok((frame, render_stage_timings)) => { let render_duration = render_start.elapsed(); - let frame_number = frame.frame_number; - let output_format = PlaybackRenderOutputFormat::Rgba; + let (frame_number, output_format) = match &frame { + EditorFrameOutput::Nv12(frame) => { + (frame.frame_number, PlaybackRenderOutputFormat::Nv12) + } + EditorFrameOutput::Rgba(frame) => { + (frame.frame_number, PlaybackRenderOutputFormat::Rgba) + } + }; let callback_start = Instant::now(); - (frame_cb)(EditorFrameOutput::Rgba(frame), frame_layout); + (frame_cb)(frame, frame_layout); let callback_duration = callback_start.elapsed(); if let Some(telemetry) = &telemetry { telemetry.emit(PlaybackTelemetryEvent::RendererFrame { @@ -610,3 +740,15 @@ impl RendererHandle { let _ = rx.await; } } + +#[cfg(test)] +mod tests { + use super::should_use_nv12_output; + + #[test] + fn nv12_output_requires_hardware_and_a_capable_consumer() { + assert!(should_use_nv12_output(false, true)); + assert!(!should_use_nv12_output(true, true)); + assert!(!should_use_nv12_output(false, false)); + } +} diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 5214531101a..f333741e8fe 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -115,12 +115,30 @@ impl EditorInstance { frame_cb: editor::EditorFrameCallback, shared_device: Option, ) -> Result, String> { - Self::new_with_audio_output( + Self::new_with_output_capability( + project_path, + on_state_change, + frame_cb, + shared_device, + Arc::new(AtomicBool::new(false)), + ) + .await + } + + pub async fn new_with_output_capability( + project_path: PathBuf, + on_state_change: impl Fn(&EditorState) + Send + Sync + 'static, + frame_cb: editor::EditorFrameCallback, + shared_device: Option, + nv12_output_available: Arc, + ) -> Result, String> { + Self::new_inner( project_path, on_state_change, frame_cb, shared_device, Arc::new(crate::AudioOutput::new()), + nv12_output_available, ) .await } @@ -134,6 +152,25 @@ impl EditorInstance { frame_cb: editor::EditorFrameCallback, shared_device: Option, audio_output: Arc, + ) -> Result, String> { + Self::new_inner( + project_path, + on_state_change, + frame_cb, + shared_device, + audio_output, + Arc::new(AtomicBool::new(false)), + ) + .await + } + + async fn new_inner( + project_path: PathBuf, + on_state_change: impl Fn(&EditorState) + Send + Sync + 'static, + frame_cb: editor::EditorFrameCallback, + shared_device: Option, + audio_output: Arc, + nv12_output_available: Arc, ) -> Result, String> { if !project_path.exists() { return Err(format!("Video path {} not found!", project_path.display())); @@ -337,10 +374,12 @@ impl EditorInstance { .map_err(|e| format!("Segment setup task failed: {e}"))??; let layers_rx = editor::finish_renderer_layers_creation(layers_rx).await; - let renderer = Arc::new(editor::Renderer::spawn( + let renderer = Arc::new(editor::Renderer::spawn_with_output_capability( render_constants.clone(), frame_cb, layers_rx, + None, + nv12_output_available, )?); let (preview_tx, preview_rx) = watch::channel(None); diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index bb496afd878..67b521fec16 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -2,8 +2,8 @@ use cap_project::{ ClipOffsets, ClipTransitionType, ProjectConfiguration, TimelineFrameMapping, XY, }; use cap_rendering::{ - DecodedSegmentFrames, PrecomputedCursorTimeline, ProjectUniforms, RecordingSegmentDecoders, - RenderVideoConstants, ZoomTransformTimeline, + DecodedFrame, DecodedSegmentFrames, PrecomputedCursorTimeline, ProjectUniforms, + RecordingSegmentDecoders, RenderVideoConstants, ZoomTransformTimeline, spring_mass_damper::SpringMassDamperSimulationConfig, }; use futures::stream::{FuturesUnordered, StreamExt}; @@ -11,7 +11,11 @@ use lru::LruCache; use std::{ collections::{HashSet, VecDeque}, num::NonZeroUsize, - sync::{Arc, RwLock, mpsc as std_mpsc}, + sync::{ + Arc, RwLock, + atomic::{AtomicUsize, Ordering}, + mpsc as std_mpsc, + }, time::{Duration, Instant}, }; use tokio::sync::watch; @@ -27,11 +31,17 @@ use crate::{ }, }; -const PREFETCH_BUFFER_SIZE: usize = 90; +const INITIAL_PREFETCH_BUFFER_SIZE: usize = 10; +const MAX_PREFETCH_BUFFER_SIZE: usize = 90; +const PREFETCH_CHANNEL_SIZE: usize = 4; +const MAX_PREFETCH_BUFFER_BYTES: usize = 128 * 1024 * 1024; const PARALLEL_DECODE_TASKS: usize = 4; const INITIAL_PARALLEL_DECODE_TASKS: usize = 4; -const MAX_PREFETCH_AHEAD: u32 = 90; -const FRAME_CACHE_SIZE: usize = 90; +// A Playback instance only advances its output frame number. Retaining the +// preceding 90 output frames therefore consumed memory without producing +// cache hits; keep only the current frame for a possible same-frame retry. +const FRAME_CACHE_SIZE: usize = 1; +const MAX_FRAME_CACHE_BYTES: usize = 16 * 1024 * 1024; const RAMP_UP_FRAME_COUNT: u32 = 15; #[cfg(target_os = "windows")] @@ -136,6 +146,15 @@ struct PrefetchedTransition { } impl PrefetchedFrame { + fn byte_len(&self) -> usize { + decoded_segment_frames_byte_len(&self.segment_frames).saturating_add( + self.transition + .as_ref() + .map(|transition| decoded_segment_frames_byte_len(&transition.segment_frames)) + .unwrap_or(0), + ) + } + fn into_cached(self) -> CachedFrame { ( Arc::new(self.segment_frames), @@ -155,14 +174,70 @@ impl PrefetchedFrame { type CachedTransition = (Arc, u32, ClipTransitionType, f32); type CachedFrame = (Arc, u32, Option); +fn decoded_frame_byte_len(frame: &DecodedFrame) -> usize { + frame.byte_len() +} + +fn decoded_segment_frames_byte_len(frames: &DecodedSegmentFrames) -> usize { + frames + .screen_frame + .as_ref() + .map(decoded_frame_byte_len) + .unwrap_or(0) + .saturating_add( + frames + .camera_frame + .as_ref() + .map(decoded_frame_byte_len) + .unwrap_or(0), + ) +} + +fn cached_frame_byte_len(frame: &CachedFrame) -> usize { + decoded_segment_frames_byte_len(&frame.0).saturating_add( + frame + .2 + .as_ref() + .map(|transition| decoded_segment_frames_byte_len(&transition.0)) + .unwrap_or(0), + ) +} + +fn prefetch_frame_limit(frame_bytes: usize) -> usize { + if frame_bytes == 0 { + return MAX_PREFETCH_BUFFER_SIZE; + } + + (MAX_PREFETCH_BUFFER_BYTES / frame_bytes) + .clamp(INITIAL_PREFETCH_BUFFER_SIZE, MAX_PREFETCH_BUFFER_SIZE) +} + +fn observe_prefetched_frame( + frame: &PrefetchedFrame, + largest_frame_bytes: &mut usize, + frame_limit: &AtomicUsize, +) { + let frame_bytes = frame.byte_len(); + if frame_bytes <= *largest_frame_bytes { + return; + } + + *largest_frame_bytes = frame_bytes; + frame_limit.store(prefetch_frame_limit(frame_bytes), Ordering::Relaxed); +} + struct FrameCache { cache: LruCache, + cache_bytes: usize, + max_bytes: usize, } impl FrameCache { - fn new(capacity: usize) -> Self { + fn new(capacity: usize, max_bytes: usize) -> Self { Self { cache: LruCache::new(NonZeroUsize::new(capacity).unwrap()), + cache_bytes: 0, + max_bytes, } } @@ -194,8 +269,24 @@ impl FrameCache { segment_index: u32, transition: Option, ) { - self.cache - .put(frame_number, (segment_frames, segment_index, transition)); + let frame = (segment_frames, segment_index, transition); + let frame_bytes = cached_frame_byte_len(&frame); + + if let Some((_, replaced)) = self.cache.push(frame_number, frame) { + self.cache_bytes = self + .cache_bytes + .saturating_sub(cached_frame_byte_len(&replaced)); + } + self.cache_bytes = self.cache_bytes.saturating_add(frame_bytes); + + while self.cache.len() > 1 && self.cache_bytes > self.max_bytes { + let Some((_, evicted)) = self.cache.pop_lru() else { + break; + }; + self.cache_bytes = self + .cache_bytes + .saturating_sub(cached_frame_byte_len(&evicted)); + } } fn evict_far_from(&mut self, current_frame: u32, max_distance: u32) { @@ -212,7 +303,11 @@ impl FrameCache { .collect(); for key in keys_to_remove { - self.cache.pop(&key); + if let Some(evicted) = self.cache.pop(&key) { + self.cache_bytes = self + .cache_bytes + .saturating_sub(cached_frame_byte_len(&evicted)); + } } } } @@ -362,9 +457,11 @@ impl Playback { event_rx, }; - let (prefetch_tx, prefetch_rx) = std_mpsc::channel::(); + let (prefetch_tx, prefetch_rx) = + std_mpsc::sync_channel::(PREFETCH_CHANNEL_SIZE); let (frame_request_tx, mut frame_request_rx) = watch::channel(self.start_frame_number); let (playback_position_tx, playback_position_rx) = watch::channel(self.start_frame_number); + let prefetch_frame_limit = Arc::new(AtomicUsize::new(INITIAL_PREFETCH_BUFFER_SIZE)); let output_size = ProjectUniforms::get_output_size( &self.render_constants.options, @@ -376,9 +473,10 @@ impl Playback { let in_flight_frames: Arc>> = Arc::new(RwLock::new(HashSet::new())); let prefetch_in_flight = in_flight_frames.clone(); + let producer_prefetch_frame_limit = Arc::clone(&prefetch_frame_limit); let _main_in_flight = in_flight_frames; - let prefetch_stop_rx = stop_rx.clone(); + let mut prefetch_stop_rx = stop_rx.clone(); let mut prefetch_project = self.project.clone(); let prefetch_segment_medias = self.segment_medias.clone(); let (prefetch_duration, has_timeline) = self @@ -404,12 +502,28 @@ impl Playback { let mut in_flight: FuturesUnordered = FuturesUnordered::new(); let mut frames_decoded: u32 = 0; let mut cached_project = prefetch_project.borrow().clone(); + let mut pending_frame: Option = None; loop { if *prefetch_stop_rx.borrow() { break; } + if let Some(frame) = pending_frame.take() { + match prefetch_tx.try_send(frame) { + Ok(()) => {} + Err(std_mpsc::TrySendError::Full(frame)) => { + pending_frame = Some(frame); + tokio::select! { + _ = prefetch_stop_rx.changed() => {} + _ = tokio::time::sleep(Duration::from_millis(1)) => {} + } + continue; + } + Err(std_mpsc::TrySendError::Disconnected(_)) => break, + } + } + if prefetch_project.has_changed().unwrap_or(false) { cached_project = prefetch_project.borrow_and_update().clone(); } @@ -432,14 +546,19 @@ impl Playback { in_flight_guard.clear(); } - if is_backward_seek || seek_distance > MAX_PREFETCH_AHEAD / 2 { + let reset_distance = + producer_prefetch_frame_limit.load(Ordering::Relaxed).max(2) as u32 / 2; + if is_backward_seek || seek_distance > reset_distance { in_flight = FuturesUnordered::new(); } } } let current_playback_frame = *playback_position_rx.borrow(); - let max_prefetch_ahead = MAX_PREFETCH_AHEAD; + let max_prefetch_ahead = producer_prefetch_frame_limit + .load(Ordering::Relaxed) + .clamp(INITIAL_PREFETCH_BUFFER_SIZE, MAX_PREFETCH_BUFFER_SIZE) + as u32; let max_prefetch_frame = current_playback_frame + max_prefetch_ahead; let initial_parallel_decode_tasks = INITIAL_PARALLEL_DECODE_TASKS; @@ -525,12 +644,19 @@ impl Playback { frames_decoded = frames_decoded.saturating_add(1); if let Some(segment_frames) = result { - let _ = prefetch_tx.send(PrefetchedFrame { + let frame = PrefetchedFrame { frame_number: frame_num, segment_frames, segment_index, transition, - }); + }; + match prefetch_tx.try_send(frame) { + Ok(()) => {} + Err(std_mpsc::TrySendError::Full(frame)) => { + pending_frame = Some(frame); + } + Err(std_mpsc::TrySendError::Disconnected(_)) => break, + } } else if frames_decoded <= 5 { warn!( frame = frame_num, @@ -576,8 +702,9 @@ impl Playback { let frame_duration = Duration::from_secs_f64(1.0 / fps_f64); let mut frame_number = self.start_frame_number; let mut prefetch_buffer: VecDeque = - VecDeque::with_capacity(PREFETCH_BUFFER_SIZE); - let mut frame_cache = FrameCache::new(FRAME_CACHE_SIZE); + VecDeque::with_capacity(MAX_PREFETCH_BUFFER_SIZE + PREFETCH_CHANNEL_SIZE); + let mut frame_cache = FrameCache::new(FRAME_CACHE_SIZE, MAX_FRAME_CACHE_BYTES); + let mut largest_prefetched_frame_bytes = 0usize; let mut total_frames_rendered = 0u64; let mut total_frames_skipped = 0u64; @@ -603,7 +730,9 @@ impl Playback { prefetch_buffer .iter() .any(|p| p.frame_number == frame_number) - || prefetch_buffer.len() >= warmup_target_frames + || prefetch_buffer.len() + >= warmup_target_frames + .min(prefetch_frame_limit.load(Ordering::Relaxed)) || first_time.elapsed() > warmup_after_first_timeout } else { false @@ -625,15 +754,27 @@ impl Playback { match prefetch_rx.recv_timeout(Duration::from_millis(50)) { Ok(prefetched) => { if prefetched.frame_number >= frame_number { + observe_prefetched_frame( + &prefetched, + &mut largest_prefetched_frame_bytes, + &prefetch_frame_limit, + ); prefetch_buffer.push_back(prefetched); if first_frame_time.is_none() { first_frame_time = Some(Instant::now()); } } - while prefetch_buffer.len() < warmup_target_frames { + while prefetch_buffer.len() + < warmup_target_frames.min(prefetch_frame_limit.load(Ordering::Relaxed)) + { match prefetch_rx.try_recv() { Ok(p) => { if p.frame_number >= frame_number { + observe_prefetched_frame( + &p, + &mut largest_prefetched_frame_bytes, + &prefetch_frame_limit, + ); prefetch_buffer.push_back(p); } } @@ -838,10 +979,17 @@ impl Playback { } } - while prefetch_buffer.len() < warmup_target_frames { + while prefetch_buffer.len() + < warmup_target_frames.min(prefetch_frame_limit.load(Ordering::Relaxed)) + { match prefetch_rx.try_recv() { Ok(prefetched) => { if prefetched.frame_number >= frame_number { + observe_prefetched_frame( + &prefetched, + &mut largest_prefetched_frame_bytes, + &prefetch_frame_limit, + ); prefetch_buffer.push_back(prefetched); } } @@ -923,7 +1071,10 @@ impl Playback { { prefetch_buffer.pop_front(); } - frame_cache.evict_far_from(frame_number, MAX_PREFETCH_AHEAD); + frame_cache.evict_far_from( + frame_number, + prefetch_frame_limit.load(Ordering::Relaxed) as u32, + ); let _ = frame_request_tx.send(frame_number); let _ = playback_position_tx.send(frame_number); if let Some(telemetry) = &self.telemetry { @@ -947,11 +1098,18 @@ impl Playback { prefetch_buffer.retain(|p| p.frame_number >= frame_number); let drain_budget = 16usize; let mut drained = 0usize; - while prefetch_buffer.len() < PREFETCH_BUFFER_SIZE && drained < drain_budget { + while prefetch_buffer.len() < prefetch_frame_limit.load(Ordering::Relaxed) + && drained < drain_budget + { match prefetch_rx.try_recv() { Ok(prefetched) => { drained += 1; if prefetched.frame_number >= frame_number { + observe_prefetched_frame( + &prefetched, + &mut largest_prefetched_frame_bytes, + &prefetch_frame_limit, + ); prefetch_buffer.push_back(prefetched); } } @@ -1006,6 +1164,11 @@ impl Playback { match prefetched_opt { Some(prefetched) => { + observe_prefetched_frame( + &prefetched, + &mut largest_prefetched_frame_bytes, + &prefetch_frame_limit, + ); if prefetched.frame_number == frame_number { frame_source = PlaybackFrameSource::PrefetchWaitExact; Some(prefetched.into_cached()) @@ -1084,10 +1247,18 @@ impl Playback { // regular drain budget of 16 per iteration can miss frames that arrived // between the drain and the buffer check. This protects both the // jump-to-min path and the +1 fallback path below. - while prefetch_buffer.len() < PREFETCH_BUFFER_SIZE { + let late_drain_limit = prefetch_frame_limit + .load(Ordering::Relaxed) + .saturating_add(PREFETCH_CHANNEL_SIZE); + while prefetch_buffer.len() < late_drain_limit { match prefetch_rx.try_recv() { Ok(p) => { if p.frame_number >= frame_number { + observe_prefetched_frame( + &p, + &mut largest_prefetched_frame_bytes, + &prefetch_frame_limit, + ); prefetch_buffer.push_back(p); } } @@ -1327,7 +1498,10 @@ impl Playback { { prefetch_buffer.pop_front(); } - frame_cache.evict_far_from(frame_number, MAX_PREFETCH_AHEAD); + frame_cache.evict_far_from( + frame_number, + prefetch_frame_limit.load(Ordering::Relaxed) as u32, + ); let _ = frame_request_tx.send(frame_number); let _ = playback_position_tx.send(frame_number); if let Some(telemetry) = &self.telemetry { @@ -1366,24 +1540,81 @@ impl Playback { } } +impl PlaybackHandle { + pub fn stop(&self) { + self.stop_tx.send(true).ok(); + } + + pub async fn receive_event(&mut self) -> watch::Ref<'_, PlaybackEvent> { + self.event_rx.changed().await.ok(); + self.event_rx.borrow_and_update() + } +} + #[cfg(test)] mod tests { use super::*; + fn decoded_frames(bytes: usize) -> DecodedSegmentFrames { + DecodedSegmentFrames { + screen_frame: Some(DecodedFrame::new(vec![0; bytes], 1, 1)), + camera_frame: None, + segment_time: 0.0, + recording_time: 0.0, + segment_has_camera: false, + } + } + #[test] fn timeline_music_enables_audio_playback_without_recorded_audio() { assert!(has_playback_audio(&[], true)); assert!(!has_playback_audio(&[], false)); } -} -impl PlaybackHandle { - pub fn stop(&self) { - self.stop_tx.send(true).ok(); + #[test] + fn prefetch_limit_tracks_decoded_frame_memory() { + assert_eq!(prefetch_frame_limit(0), MAX_PREFETCH_BUFFER_SIZE); + assert_eq!( + prefetch_frame_limit(4 * 1024 * 1024), + MAX_PREFETCH_BUFFER_BYTES / (4 * 1024 * 1024) + ); + assert_eq!( + prefetch_frame_limit(MAX_PREFETCH_BUFFER_BYTES), + INITIAL_PREFETCH_BUFFER_SIZE + ); } - pub async fn receive_event(&mut self) -> watch::Ref<'_, PlaybackEvent> { - self.event_rx.changed().await.ok(); - self.event_rx.borrow_and_update() + #[test] + fn frame_cache_evicts_oldest_frames_to_stay_within_byte_budget() { + let mut cache = FrameCache::new(10, 15); + cache.insert(1, Arc::new(decoded_frames(10)), 0, None); + cache.insert(2, Arc::new(decoded_frames(10)), 0, None); + + assert_eq!(cache.cache.len(), 1); + assert_eq!(cache.cache_bytes, 10); + assert!(cache.get(1).is_none()); + assert!(cache.get(2).is_some()); + } + + #[test] + fn frame_cache_keeps_one_oversized_frame() { + let mut cache = FrameCache::new(10, 4); + cache.insert(1, Arc::new(decoded_frames(10)), 0, None); + + assert_eq!(cache.cache.len(), 1); + assert_eq!(cache.cache_bytes, 10); + assert!(cache.get(1).is_some()); + } + + #[test] + fn frame_cache_accounts_for_count_based_eviction() { + let mut cache = FrameCache::new(2, usize::MAX); + cache.insert(1, Arc::new(decoded_frames(10)), 0, None); + cache.insert(2, Arc::new(decoded_frames(10)), 0, None); + cache.insert(3, Arc::new(decoded_frames(10)), 0, None); + + assert_eq!(cache.cache.len(), 2); + assert_eq!(cache.cache_bytes, 20); + assert!(cache.get(1).is_none()); } } diff --git a/crates/rendering/src/decoder/avassetreader.rs b/crates/rendering/src/decoder/avassetreader.rs index 1d121e2712c..fac9e4c0dd2 100644 --- a/crates/rendering/src/decoder/avassetreader.rs +++ b/crates/rendering/src/decoder/avassetreader.rs @@ -25,50 +25,29 @@ use super::{ const MAX_RELAXED_FALLBACK_DISTANCE: u32 = 8; const SCRUB_REUSE_THRESHOLD_SECS: f32 = 0.5; const DECODER_REQUEST_CLUSTER_GAP_FRAMES: u32 = FRAME_CACHE_SIZE as u32 / 2; - -#[derive(Clone)] -struct FrameData { - data: Arc>, - y_stride: u32, - uv_stride: u32, +// A frame-count-only limit can retain more than 1 GiB for high-resolution +// recordings. Keep the existing temporal cap, but also bound the raw decoded +// frame data so playback remains viable on memory-constrained devices. +const MAX_FRAME_CACHE_BYTES: usize = 256 * 1024 * 1024; + +fn frame_cache_needs_eviction( + current_frames: usize, + current_bytes: usize, + next_frame_bytes: usize, +) -> bool { + current_frames >= FRAME_CACHE_SIZE + || current_bytes.saturating_add(next_frame_bytes) > MAX_FRAME_CACHE_BYTES } #[derive(Clone)] struct ProcessedFrame { number: u32, - width: u32, - height: u32, - format: PixelFormat, - frame_data: FrameData, + frame: DecodedFrame, } impl ProcessedFrame { fn to_decoded_frame(&self) -> DecodedFrame { - let FrameData { - data, - y_stride, - uv_stride, - } = &self.frame_data; - - match self.format { - PixelFormat::Rgba => { - DecodedFrame::new_with_arc(Arc::clone(data), self.width, self.height) - } - PixelFormat::Nv12 => DecodedFrame::new_nv12_with_arc( - Arc::clone(data), - self.width, - self.height, - *y_stride, - *uv_stride, - ), - PixelFormat::Yuv420p => DecodedFrame::new_yuv420p_with_arc( - Arc::clone(data), - self.width, - self.height, - *y_stride, - *uv_stride, - ), - } + self.frame.clone() } } @@ -219,37 +198,43 @@ impl CachedFrame { | format::Pixel::RGBA | format::Pixel::BGRA | format::Pixel::YUV420P => { - let mut img = image_buf; - let (data, fmt, y_str, uv_str) = processor.extract_raw(&mut img); - Self(ProcessedFrame { - number, - width, - height, - format: fmt, - frame_data: FrameData { - data: Arc::new(data), - y_stride: y_str, - uv_stride: uv_str, - }, - }) + let frame = if pixel_format == format::Pixel::NV12 && image_buf.io_surf().is_some() + { + let y_stride = image_buf.plane_bytes_per_row(0) as u32; + let uv_stride = image_buf.plane_bytes_per_row(1) as u32; + DecodedFrame::new_nv12_with_image_buf( + width, height, y_stride, uv_stride, image_buf, + ) + } else { + let mut image_buf = image_buf; + let (data, fmt, y_str, uv_str) = processor.extract_raw(&mut image_buf); + let data = Arc::new(data); + match fmt { + PixelFormat::Nv12 => { + DecodedFrame::new_nv12_with_arc(data, width, height, y_str, uv_str) + } + PixelFormat::Rgba => DecodedFrame::new_with_arc(data, width, height), + PixelFormat::Yuv420p => { + DecodedFrame::new_yuv420p_with_arc(data, width, height, y_str, uv_str) + } + } + }; + Self(ProcessedFrame { number, frame }) } _ => { let black_frame = vec![0u8; (width * height * 4) as usize]; Self(ProcessedFrame { number, - width, - height, - format: PixelFormat::Rgba, - frame_data: FrameData { - data: Arc::new(black_frame), - y_stride: width * 4, - uv_stride: 0, - }, + frame: DecodedFrame::new(black_frame, width, height), }) } } } + fn byte_len(&self) -> usize { + self.0.frame.byte_len() + } + fn data(&self) -> &ProcessedFrame { &self.0 } @@ -460,34 +445,10 @@ impl AVAssetReaderDecoder { keyframe_index_arc.clone(), )?; - let mut decoders = vec![primary_instance]; - - let initial_positions = pool_manager.positions(); - for pos in initial_positions.iter().skip(1) { - let start_time = pos.position_secs; - match DecoderInstance::new( - path.clone(), - tokio_handle.clone(), - start_time, - keyframe_index_arc.clone(), - ) { - Ok(instance) => { - decoders.push(instance); - tracing::info!( - position_secs = start_time, - decoder_index = decoders.len() - 1, - "Created additional decoder instance for multi-position pool" - ); - } - Err(e) => { - tracing::warn!( - position_secs = start_time, - error = %e, - "Failed to create additional decoder instance, continuing with fewer decoders" - ); - } - } - } + // Linear playback only needs one reader. Additional readers retain + // AVFoundation state and decoded buffers, so create them only when a + // non-sequential request would otherwise reposition an active reader. + let decoders = vec![primary_instance]; let total_frames_estimate = (duration_secs * fps as f64).ceil() as u32; @@ -526,6 +487,46 @@ impl AVAssetReaderDecoder { let decoder_idx = best_id.min(decoder_count.saturating_sub(1)); + let max_decoder_count = self + .pool_manager + .optimal_pool_size() + .min(self.pool_manager.positions().len()); + if should_grow_decoder_pool(is_scrubbing, needs_reset, decoder_count, max_decoder_count) { + let (path, tokio_handle, keyframe_index) = { + let config = self.pool_manager.config(); + ( + config.path.clone(), + config.tokio_handle.clone(), + config.keyframe_index.clone(), + ) + }; + + match DecoderInstance::new(path, tokio_handle, requested_time, keyframe_index) { + Ok(instance) => { + let decoder_idx = self.decoders.len(); + let position = instance.current_position(); + self.decoders.push(instance); + self.pool_manager + .update_decoder_position(decoder_idx, position); + self.active_decoder_idx = decoder_idx; + tracing::info!( + decoder_index = decoder_idx, + requested_time = requested_time, + decoder_count = self.decoders.len(), + "Created decoder instance on demand for non-sequential access" + ); + return (decoder_idx, true); + } + Err(error) => { + tracing::warn!( + requested_time = requested_time, + error = %error, + "Failed to create decoder on demand; reusing an existing decoder" + ); + } + } + } + if needs_reset && decoder_idx < self.decoders.len() { self.decoders[decoder_idx].reset(requested_time); self.pool_manager.update_decoder_position( @@ -577,6 +578,7 @@ impl AVAssetReaderDecoder { ready_tx.send(Ok(init_result)).ok(); let mut cache = BTreeMap::::new(); + let mut cache_bytes = 0usize; #[allow(unused)] let mut last_active_frame = None::; @@ -765,6 +767,10 @@ impl AVAssetReaderDecoder { if was_reset { *last_sent_frame.borrow_mut() = None; cache.retain(|&f, _| f >= cache_min && f <= cache_max); + cache_bytes = cache + .values() + .map(CachedFrame::byte_len) + .fold(0usize, usize::saturating_add); } last_active_frame = Some(requested_frame); @@ -842,7 +848,13 @@ impl AVAssetReaderDecoder { let too_small_for_cache_bounds = current_frame < cache_min; if !too_small_for_cache_bounds { - if cache.len() >= FRAME_CACHE_SIZE { + let frame_bytes = cache_frame.byte_len(); + if let Some(replaced) = cache.remove(¤t_frame) { + cache_bytes = cache_bytes.saturating_sub(replaced.byte_len()); + } + while !cache.is_empty() + && frame_cache_needs_eviction(cache.len(), cache_bytes, frame_bytes) + { if let Some(last_active) = &last_active_frame { let frame_to_remove = if requested_frame > *last_active { *cache.keys().next().unwrap() @@ -853,12 +865,16 @@ impl AVAssetReaderDecoder { let max = *cache.keys().max().unwrap(); if current_frame > max { min } else { max } }; - cache.remove(&frame_to_remove); + if let Some(removed) = cache.remove(&frame_to_remove) { + cache_bytes = cache_bytes.saturating_sub(removed.byte_len()); + } } else { - cache.clear() + cache.clear(); + cache_bytes = 0; } } + cache_bytes = cache_bytes.saturating_add(frame_bytes); cache.insert(current_frame, cache_frame.clone()); let mut remaining_requests = Vec::with_capacity(pending_requests.len()); @@ -1116,10 +1132,40 @@ impl AVAssetReaderDecoder { } } +fn should_grow_decoder_pool( + is_scrubbing: bool, + needs_reset: bool, + decoder_count: usize, + max_decoder_count: usize, +) -> bool { + is_scrubbing && needs_reset && decoder_count < max_decoder_count +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn frame_cache_enforces_frame_and_byte_limits() { + assert!(!frame_cache_needs_eviction( + FRAME_CACHE_SIZE - 1, + MAX_FRAME_CACHE_BYTES - 1, + 1, + )); + assert!(frame_cache_needs_eviction(FRAME_CACHE_SIZE, 0, 1)); + assert!(frame_cache_needs_eviction(1, MAX_FRAME_CACHE_BYTES, 1,)); + assert!(frame_cache_needs_eviction(1, usize::MAX, 1)); + } + + #[test] + fn decoder_pool_grows_only_for_unserved_non_sequential_access() { + assert!(should_grow_decoder_pool(true, true, 1, 5)); + assert!(!should_grow_decoder_pool(false, true, 1, 5)); + assert!(!should_grow_decoder_pool(true, false, 1, 5)); + assert!(!should_grow_decoder_pool(true, true, 5, 5)); + assert!(!should_grow_decoder_pool(true, true, 1, 1)); + } + #[test] fn test_decoder_health_new() { let health = DecoderHealth::new(); diff --git a/crates/rendering/src/decoder/mod.rs b/crates/rendering/src/decoder/mod.rs index 8cf323adc8e..13a2064761f 100644 --- a/crates/rendering/src/decoder/mod.rs +++ b/crates/rendering/src/decoder/mod.rs @@ -2,12 +2,17 @@ use ::ffmpeg::Rational; use std::{ fmt, path::PathBuf, - sync::{Arc, mpsc}, + sync::{Arc, Weak, mpsc}, time::Duration, }; use tokio::sync::oneshot; use tracing::info; +#[cfg(target_os = "macos")] +use cidre::{arc::R, cv}; +#[cfg(target_os = "macos")] +use std::sync::{Mutex, OnceLock}; + #[cfg(target_os = "macos")] mod avassetreader; mod ffmpeg; @@ -150,10 +155,107 @@ pub struct DecodedFrame { format: PixelFormat, y_stride: u32, uv_stride: u32, + #[cfg(target_os = "macos")] + image_buf_backing: Option>, #[cfg(target_os = "windows")] d3d11_texture_backing: Option>, } +#[cfg(target_os = "macos")] +struct SendableImageBuf { + image_buf: Mutex>>, + raw_data: OnceLock>, +} + +#[cfg(target_os = "macos")] +// SAFETY: all CVPixelBuffer access is serialized by `image_buf`, and the Core +// Foundation object remains retained for the lifetime of this wrapper. +unsafe impl Send for SendableImageBuf {} +#[cfg(target_os = "macos")] +// SAFETY: see the `Send` implementation above. +unsafe impl Sync for SendableImageBuf {} + +#[cfg(target_os = "macos")] +impl SendableImageBuf { + fn new(image_buf: R) -> Self { + Self { + image_buf: Mutex::new(Some(image_buf)), + raw_data: OnceLock::new(), + } + } + + fn with_image_buf(&self, f: impl FnOnce(&cv::ImageBuf) -> T) -> Option { + let image_buf = self + .image_buf + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + image_buf.as_deref().map(f) + } + + fn raw_data(&self) -> &[u8] { + self.raw_data.get_or_init(|| { + let mut image_buf_guard = self + .image_buf + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(image_buf) = image_buf_guard.as_mut() else { + return Vec::new(); + }; + + unsafe { + image_buf + .lock_base_addr(cv::pixel_buffer::LockFlags::READ_ONLY) + .result() + .unwrap(); + } + + let y_size = image_buf.plane_bytes_per_row(0) * image_buf.plane_height(0); + let uv_size = image_buf.plane_bytes_per_row(1) * image_buf.plane_height(1); + let y_plane = + unsafe { std::slice::from_raw_parts(image_buf.plane_base_address(0), y_size) }; + let uv_plane = + unsafe { std::slice::from_raw_parts(image_buf.plane_base_address(1), uv_size) }; + + let mut data = Vec::with_capacity(y_size + uv_size); + data.extend_from_slice(y_plane); + data.extend_from_slice(uv_plane); + + unsafe { + image_buf.unlock_lock_base_addr(cv::pixel_buffer::LockFlags::READ_ONLY); + } + + // CPU consumers no longer need the IOSurface after its planes have + // been copied. Releasing it keeps cache accounting at one frame + // instead of retaining both the CVPixelBuffer and its materialized + // bytes for the lifetime of the cache entry. + *image_buf_guard = None; + + data + }) + } +} + +#[derive(Clone)] +pub(crate) struct DecodedFrameStorageIdentity { + data: Weak>, + width: u32, + height: u32, + format: PixelFormat, + y_stride: u32, + uv_stride: u32, +} + +impl DecodedFrameStorageIdentity { + pub(crate) fn matches(&self, other: &Self) -> bool { + self.data.ptr_eq(&other.data) + && self.width == other.width + && self.height == other.height + && self.format == other.format + && self.y_stride == other.y_stride + && self.uv_stride == other.uv_stride + } +} + impl fmt::Debug for DecodedFrame { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("DecodedFrame") @@ -176,6 +278,8 @@ impl DecodedFrame { format: PixelFormat::Rgba, y_stride: width * 4, uv_stride: 0, + #[cfg(target_os = "macos")] + image_buf_backing: None, #[cfg(target_os = "windows")] d3d11_texture_backing: None, } @@ -189,6 +293,8 @@ impl DecodedFrame { format: PixelFormat::Rgba, y_stride: width * 4, uv_stride: 0, + #[cfg(target_os = "macos")] + image_buf_backing: None, #[cfg(target_os = "windows")] d3d11_texture_backing: None, } @@ -202,6 +308,8 @@ impl DecodedFrame { format: PixelFormat::Nv12, y_stride, uv_stride, + #[cfg(target_os = "macos")] + image_buf_backing: None, #[cfg(target_os = "windows")] d3d11_texture_backing: None, } @@ -221,11 +329,32 @@ impl DecodedFrame { format: PixelFormat::Nv12, y_stride, uv_stride, + #[cfg(target_os = "macos")] + image_buf_backing: None, #[cfg(target_os = "windows")] d3d11_texture_backing: None, } } + #[cfg(target_os = "macos")] + pub(crate) fn new_nv12_with_image_buf( + width: u32, + height: u32, + y_stride: u32, + uv_stride: u32, + image_buf: R, + ) -> Self { + Self { + data: Arc::new(Vec::new()), + width, + height, + format: PixelFormat::Nv12, + y_stride, + uv_stride, + image_buf_backing: Some(Arc::new(SendableImageBuf::new(image_buf))), + } + } + pub fn new_yuv420p( data: Vec, width: u32, @@ -240,6 +369,8 @@ impl DecodedFrame { format: PixelFormat::Yuv420p, y_stride, uv_stride, + #[cfg(target_os = "macos")] + image_buf_backing: None, #[cfg(target_os = "windows")] d3d11_texture_backing: None, } @@ -259,6 +390,8 @@ impl DecodedFrame { format: PixelFormat::Yuv420p, y_stride, uv_stride, + #[cfg(target_os = "macos")] + image_buf_backing: None, #[cfg(target_os = "windows")] d3d11_texture_backing: None, } @@ -273,6 +406,8 @@ impl DecodedFrame { format: PixelFormat::Nv12, y_stride: width, uv_stride: width, + #[cfg(target_os = "macos")] + image_buf_backing: None, d3d11_texture_backing: Some(Arc::new(SendableD3D11Texture::new(texture))), } } @@ -291,6 +426,8 @@ impl DecodedFrame { format: PixelFormat::Nv12, y_stride: width, uv_stride: width, + #[cfg(target_os = "macos")] + image_buf_backing: None, d3d11_texture_backing: Some(Arc::new(SendableD3D11Texture::new_with_handle( texture, shared_handle, @@ -314,6 +451,8 @@ impl DecodedFrame { format: PixelFormat::Nv12, y_stride: width, uv_stride: width, + #[cfg(target_os = "macos")] + image_buf_backing: None, d3d11_texture_backing: Some(Arc::new(SendableD3D11Texture::new_with_yuv_handles( texture, shared_handle, @@ -351,9 +490,23 @@ impl DecodedFrame { } pub fn data(&self) -> &[u8] { + #[cfg(target_os = "macos")] + if self.data.is_empty() + && let Some(backing) = &self.image_buf_backing + { + return backing.raw_data(); + } + &self.data } + #[cfg(target_os = "macos")] + pub(crate) fn with_image_buf(&self, f: impl FnOnce(&cv::ImageBuf) -> T) -> Option { + self.image_buf_backing + .as_ref() + .and_then(|backing| backing.with_image_buf(f)) + } + pub fn width(&self) -> u32 { self.width } @@ -366,6 +519,34 @@ impl DecodedFrame { self.format } + pub fn byte_len(&self) -> usize { + if !self.data.is_empty() { + return self.data.len(); + } + + let y_bytes = (self.y_stride as usize).saturating_mul(self.height as usize); + let uv_bytes = (self.uv_stride as usize).saturating_mul((self.height / 2) as usize); + + match self.format { + PixelFormat::Rgba => (self.width as usize) + .saturating_mul(self.height as usize) + .saturating_mul(4), + PixelFormat::Nv12 => y_bytes.saturating_add(uv_bytes), + PixelFormat::Yuv420p => y_bytes.saturating_add(uv_bytes.saturating_mul(2)), + } + } + + pub(crate) fn storage_identity(&self) -> DecodedFrameStorageIdentity { + DecodedFrameStorageIdentity { + data: Arc::downgrade(&self.data), + width: self.width, + height: self.height, + format: self.format, + y_stride: self.y_stride, + uv_stride: self.uv_stride, + } + } + pub fn y_plane(&self) -> Option<&[u8]> { match self.format { PixelFormat::Nv12 | PixelFormat::Yuv420p => { @@ -373,7 +554,7 @@ impl DecodedFrame { .y_stride .checked_mul(self.height) .and_then(|v| usize::try_from(v).ok())?; - self.data.get(..y_size) + self.data().get(..y_size) } PixelFormat::Rgba => None, } @@ -386,7 +567,7 @@ impl DecodedFrame { .y_stride .checked_mul(self.height) .and_then(|v| usize::try_from(v).ok())?; - self.data.get(y_size..) + self.data().get(y_size..) } PixelFormat::Yuv420p | PixelFormat::Rgba => None, } @@ -404,7 +585,7 @@ impl DecodedFrame { .checked_mul(self.height / 2) .and_then(|v| usize::try_from(v).ok())?; let u_end = y_size.checked_add(u_size)?; - self.data.get(y_size..u_end) + self.data().get(y_size..u_end) } _ => None, } @@ -422,7 +603,7 @@ impl DecodedFrame { .checked_mul(self.height / 2) .and_then(|v| usize::try_from(v).ok())?; let v_start = y_size.checked_add(u_size)?; - self.data.get(v_start..) + self.data().get(v_start..) } _ => None, } @@ -896,6 +1077,71 @@ pub async fn spawn_decoder( mod tests { use super::*; + #[test] + fn decoded_frame_storage_identity_matches_clones() { + let frame = DecodedFrame::new_nv12(vec![0; 6], 2, 2, 2, 2); + let cloned = frame.clone(); + + assert!(frame.storage_identity().matches(&cloned.storage_identity())); + } + + #[test] + fn decoded_frame_storage_identity_rejects_distinct_or_reinterpreted_frames() { + let data = Arc::new(vec![0; 16]); + let original = DecodedFrame::new_with_arc(Arc::clone(&data), 2, 2); + let reinterpreted = DecodedFrame::new_with_arc(Arc::clone(&data), 1, 4); + let distinct = DecodedFrame::new(vec![0; 16], 2, 2); + + assert!( + !original + .storage_identity() + .matches(&reinterpreted.storage_identity()) + ); + assert!( + !original + .storage_identity() + .matches(&distinct.storage_identity()) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn iosurface_backed_nv12_materializes_raw_planes_only_on_demand() { + let mut image_buf = cv::ImageBuf::new(4, 2, cv::PixelFormat::_420V, None).unwrap(); + + unsafe { + image_buf + .lock_base_addr(cv::pixel_buffer::LockFlags::DEFAULT) + .result() + .unwrap(); + } + + let y_stride = image_buf.plane_bytes_per_row(0); + let uv_stride = image_buf.plane_bytes_per_row(1); + let y_size = y_stride * image_buf.plane_height(0); + let uv_size = uv_stride * image_buf.plane_height(1); + unsafe { + std::ptr::write_bytes(image_buf.plane_base_address(0).cast_mut(), 0x11, y_size); + std::ptr::write_bytes(image_buf.plane_base_address(1).cast_mut(), 0x22, uv_size); + image_buf.unlock_lock_base_addr(cv::pixel_buffer::LockFlags::DEFAULT); + } + + let frame = DecodedFrame::new_nv12_with_image_buf( + 4, + 2, + y_stride as u32, + uv_stride as u32, + image_buf, + ); + + assert_eq!(frame.byte_len(), y_size + uv_size); + assert!(frame.with_image_buf(|_| ()).is_some()); + assert_eq!(frame.y_plane().unwrap(), vec![0x11; y_size]); + assert_eq!(frame.uv_plane().unwrap(), vec![0x22; uv_size]); + assert_eq!(frame.data().len(), y_size + uv_size); + assert!(frame.with_image_buf(|_| ()).is_none()); + } + #[test] fn pts_to_frame_maps_real_presentation_time_to_index() { let tb = Rational::new(1, 600); diff --git a/crates/rendering/src/frame_pipeline.rs b/crates/rendering/src/frame_pipeline.rs index 1e4b660f25b..58ed57139f4 100644 --- a/crates/rendering/src/frame_pipeline.rs +++ b/crates/rendering/src/frame_pipeline.rs @@ -790,17 +790,13 @@ impl PipelinedGpuReadback { pub fn take_pending(&mut self) -> Option { self.pending.take() } - - pub fn has_pending(&self) -> bool { - self.pending.is_some() - } } pub struct RenderSession { pub textures: (wgpu::Texture, wgpu::Texture), texture_views: (wgpu::TextureView, wgpu::TextureView), pub current_is_left: bool, - pub pipelined_readback: PipelinedGpuReadback, + pipelined_readback: Option, texture_width: u32, texture_height: u32, } @@ -827,8 +823,6 @@ impl RenderSession { }; let textures = (make_texture(), make_texture()); - let padded = padded_bytes_per_row((width, height)); - let initial_buffer_size = (padded * height) as u64; Self { current_is_left: true, @@ -837,7 +831,9 @@ impl RenderSession { textures.1.create_view(&Default::default()), ), textures, - pipelined_readback: PipelinedGpuReadback::new(device, initial_buffer_size), + // NV12 output has its own compact readback pool. Allocate the much + // larger RGBA staging buffers only if an RGBA consumer is used. + pipelined_readback: None, texture_width: width, texture_height: height, } @@ -884,6 +880,10 @@ impl RenderSession { self.texture_height = height; } + pub fn release_rgba_readback(&mut self) { + self.pipelined_readback = None; + } + pub fn current_texture(&self) -> &wgpu::Texture { if self.current_is_left { &self.textures.0 @@ -994,9 +994,14 @@ pub async fn finish_encoder_timed( encoder: wgpu::CommandEncoder, ) -> Result<(Option, FinishEncoderTimings), RenderingError> { let mut timings = FinishEncoderTimings::default(); + let initial_buffer_size = + (padded_bytes_per_row(uniforms.output_size) * uniforms.output_size.1) as u64; + let readback = session + .pipelined_readback + .get_or_insert_with(|| PipelinedGpuReadback::new(device, initial_buffer_size)); let wait_start = Instant::now(); - let previous_frame = if let Some(prev) = session.pipelined_readback.take_pending() { + let previous_frame = if let Some(prev) = readback.take_pending() { Some(prev.wait(device).await?) } else { None @@ -1004,7 +1009,7 @@ pub async fn finish_encoder_timed( timings.wait_previous_duration = wait_start.elapsed(); let resize_start = Instant::now(); - session.pipelined_readback.perform_resize_if_needed(device); + readback.perform_resize_if_needed(device); timings.resize_duration = resize_start.elapsed(); let texture = if session.current_is_left { @@ -1014,9 +1019,7 @@ pub async fn finish_encoder_timed( }; let submit_start = Instant::now(); - session - .pipelined_readback - .submit_readback(device, queue, texture, uniforms, encoder)?; + readback.submit_readback(device, queue, texture, uniforms, encoder)?; timings.submit_readback_duration = submit_start.elapsed(); Ok((previous_frame, timings)) @@ -1083,7 +1086,8 @@ pub async fn flush_pending_readback( session: &mut RenderSession, device: &wgpu::Device, ) -> Option> { - if let Some(pending) = session.pipelined_readback.take_pending() { + let readback = session.pipelined_readback.as_mut()?; + if let Some(pending) = readback.take_pending() { Some(pending.wait(device).await) } else { None diff --git a/crates/rendering/src/layers/camera.rs b/crates/rendering/src/layers/camera.rs index e2be7f5dd55..4558de2f914 100644 --- a/crates/rendering/src/layers/camera.rs +++ b/crates/rendering/src/layers/camera.rs @@ -5,6 +5,7 @@ use wgpu::util::DeviceExt; use crate::{ CompositeVideoFrameUniforms, DecodedFrame, PixelFormat, composite_frame::CompositeVideoFramePipeline, + decoder::DecodedFrameStorageIdentity, yuv_converter::{YuvConverterPipelines, YuvToRgbaConverter}, }; @@ -17,6 +18,7 @@ pub struct CameraLayer { pipeline: Arc, hidden: bool, last_recording_time: Option, + last_frame_storage: Option, yuv_converter: YuvToRgbaConverter, blur_bind_group: Option, blur_active: bool, @@ -75,6 +77,7 @@ impl CameraLayer { pipeline: composite_pipeline, hidden: false, last_recording_time: None, + last_frame_storage: None, yuv_converter, blur_bind_group: None, blur_active: false, @@ -105,12 +108,16 @@ impl CameraLayer { return; }; - let frame_data_bytes = camera_frame.data(); let format = camera_frame.format(); + let frame_storage = camera_frame.storage_identity(); let is_same_frame = self - .last_recording_time - .is_some_and(|last| (last - recording_time).abs() < 0.001); + .last_frame_storage + .as_ref() + .is_some_and(|last| last.matches(&frame_storage)) + || self + .last_recording_time + .is_some_and(|last| (last - recording_time).abs() < 0.001); if !is_same_frame { let next_texture = 1 - self.current_texture; @@ -136,6 +143,7 @@ impl CameraLayer { match format { PixelFormat::Rgba => { + let frame_data_bytes = camera_frame.data(); let src_bytes_per_row = frame_size.x * 4; queue.write_texture( @@ -198,6 +206,7 @@ impl CameraLayer { frame_size, ); self.last_recording_time = Some(recording_time); + self.last_frame_storage = Some(frame_storage); self.current_texture = next_texture; return; } @@ -212,7 +221,20 @@ impl CameraLayer { } } - if let (Some(y_data), Some(uv_data)) = + #[cfg(target_os = "macos")] + let iosurface_converted = camera_frame + .with_image_buf(|image_buf| { + self.yuv_converter + .convert_nv12_from_iosurface(device, queue, image_buf) + .is_ok() + }) + .unwrap_or(false); + #[cfg(not(target_os = "macos"))] + let iosurface_converted = false; + + if iosurface_converted && self.yuv_converter.output_texture().is_some() { + self.copy_from_yuv_output(device, queue, next_texture, frame_size); + } else if let (Some(y_data), Some(uv_data)) = (camera_frame.y_plane(), camera_frame.uv_plane()) && self .yuv_converter @@ -266,7 +288,10 @@ impl CameraLayer { } self.last_recording_time = Some(recording_time); + self.last_frame_storage = Some(frame_storage); self.current_texture = next_texture; + } else { + self.last_recording_time = Some(recording_time); } } @@ -360,10 +385,15 @@ impl CameraLayer { }; let format = camera_frame.format(); + let frame_storage = camera_frame.storage_identity(); let is_same_frame = self - .last_recording_time - .is_some_and(|last| (last - recording_time).abs() < 0.001); + .last_frame_storage + .as_ref() + .is_some_and(|last| last.matches(&frame_storage)) + || self + .last_recording_time + .is_some_and(|last| (last - recording_time).abs() < 0.001); if !is_same_frame { let next_texture = 1 - self.current_texture; @@ -422,7 +452,20 @@ impl CameraLayer { return; } - if let (Some(y_data), Some(uv_data)) = + #[cfg(target_os = "macos")] + let iosurface_converted = camera_frame + .with_image_buf(|image_buf| { + self.yuv_converter + .convert_nv12_from_iosurface_to_encoder(device, encoder, image_buf) + .is_ok() + }) + .unwrap_or(false); + #[cfg(not(target_os = "macos"))] + let iosurface_converted = false; + + if iosurface_converted && self.yuv_converter.output_texture().is_some() { + self.copy_from_yuv_output_to_encoder(encoder, next_texture, frame_size); + } else if let (Some(y_data), Some(uv_data)) = (camera_frame.y_plane(), camera_frame.uv_plane()) && self .yuv_converter @@ -478,7 +521,10 @@ impl CameraLayer { } self.last_recording_time = Some(recording_time); + self.last_frame_storage = Some(frame_storage); self.current_texture = next_texture; + } else { + self.last_recording_time = Some(recording_time); } } diff --git a/crates/rendering/src/layers/display.rs b/crates/rendering/src/layers/display.rs index d67a5dbd55e..d4de70fa377 100644 --- a/crates/rendering/src/layers/display.rs +++ b/crates/rendering/src/layers/display.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use crate::{ DecodedSegmentFrames, PixelFormat, composite_frame::{CompositeVideoFramePipeline, CompositeVideoFrameUniforms}, + decoder::DecodedFrameStorageIdentity, yuv_converter::{YuvConverterPipelines, YuvToRgbaConverter}, }; @@ -68,6 +69,7 @@ pub struct DisplayLayer { pipeline: std::sync::Arc, bind_groups: [Option; 2], last_recording_time: Option, + last_frame_storage: Option, yuv_converter: YuvToRgbaConverter, pending_copy: Option, prefer_cpu_conversion: bool, @@ -120,6 +122,7 @@ impl DisplayLayer { pipeline: composite_pipeline, bind_groups: [bind_group_0, bind_group_1], last_recording_time: None, + last_frame_storage: None, yuv_converter, pending_copy: None, prefer_cpu_conversion, @@ -194,17 +197,21 @@ impl DisplayLayer { return (false, frame_size.x, frame_size.y); }; - let frame_data = screen_frame.data(); let actual_width = screen_frame.width(); let actual_height = screen_frame.height(); let source_size = XY::new(actual_width, actual_height); let uniforms = uniforms_for_source_frame(uniforms, frame_size, source_size); let format = screen_frame.format(); let current_recording_time = segment_frames.recording_time; + let frame_storage = screen_frame.storage_identity(); let skipped = self - .last_recording_time - .is_some_and(|last| (last - current_recording_time).abs() < 0.001); + .last_frame_storage + .as_ref() + .is_some_and(|last| last.matches(&frame_storage)) + || self + .last_recording_time + .is_some_and(|last| (last - current_recording_time).abs() < 0.001); let mut frame_uploaded = false; @@ -232,6 +239,7 @@ impl DisplayLayer { frame_uploaded = match format { PixelFormat::Rgba => { + let frame_data = screen_frame.data(); let src_bytes_per_row = source_size.x * 4; queue.write_texture( @@ -258,7 +266,22 @@ impl DisplayLayer { PixelFormat::Nv12 => { #[cfg(target_os = "macos")] if !self.prefer_cpu_conversion { - if let (Some(y_data), Some(uv_data)) = + let iosurface_converted = screen_frame + .with_image_buf(|image_buf| { + self.yuv_converter + .convert_nv12_from_iosurface(device, queue, image_buf) + .is_ok() + }) + .unwrap_or(false); + + if iosurface_converted && self.yuv_converter.output_texture().is_some() { + self.pending_copy = Some(PendingTextureCopy { + width: source_size.x, + height: source_size.y, + dst_texture_index: next_texture, + }); + true + } else if let (Some(y_data), Some(uv_data)) = (screen_frame.y_plane(), screen_frame.uv_plane()) { let y_stride = screen_frame.y_stride(); @@ -523,6 +546,7 @@ impl DisplayLayer { if frame_uploaded { self.last_recording_time = Some(current_recording_time); + self.last_frame_storage = Some(frame_storage); self.current_texture = next_texture; self.has_valid_frame = true; } else { @@ -569,10 +593,15 @@ impl DisplayLayer { let uniforms = uniforms_for_source_frame(uniforms, frame_size, source_size); let format = screen_frame.format(); let current_recording_time = segment_frames.recording_time; + let frame_storage = screen_frame.storage_identity(); let skipped = self - .last_recording_time - .is_some_and(|last| (last - current_recording_time).abs() < 0.001); + .last_frame_storage + .as_ref() + .is_some_and(|last| last.matches(&frame_storage)) + || self + .last_recording_time + .is_some_and(|last| (last - current_recording_time).abs() < 0.001); let mut frame_uploaded = false; @@ -748,7 +777,27 @@ impl DisplayLayer { #[cfg(not(target_os = "windows"))] if !self.prefer_cpu_conversion { - if let (Some(y_data), Some(uv_data)) = + #[cfg(target_os = "macos")] + let iosurface_converted = screen_frame + .with_image_buf(|image_buf| { + self.yuv_converter + .convert_nv12_from_iosurface_to_encoder( + device, encoder, image_buf, + ) + .is_ok() + }) + .unwrap_or(false); + #[cfg(not(target_os = "macos"))] + let iosurface_converted = false; + + if iosurface_converted && self.yuv_converter.output_texture().is_some() { + self.pending_copy = Some(PendingTextureCopy { + width: source_size.x, + height: source_size.y, + dst_texture_index: next_texture, + }); + true + } else if let (Some(y_data), Some(uv_data)) = (screen_frame.y_plane(), screen_frame.uv_plane()) { let y_stride = screen_frame.y_stride(); @@ -876,6 +925,7 @@ impl DisplayLayer { if frame_uploaded { self.last_recording_time = Some(current_recording_time); + self.last_frame_storage = Some(frame_storage); self.current_texture = next_texture; self.has_valid_frame = true; } else { diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index fe56996e6df..de465327433 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -4940,6 +4940,12 @@ impl<'a> FrameRenderer<'a> { } } + pub fn release_rgba_readback(&mut self) { + if let Some(session) = &mut self.session { + session.release_rgba_readback(); + } + } + pub async fn render_immediate_nv12( &mut self, segment_frames: DecodedSegmentFrames, diff --git a/crates/rendering/src/yuv_converter.rs b/crates/rendering/src/yuv_converter.rs index c1750dbbfbf..969e21208ee 100644 --- a/crates/rendering/src/yuv_converter.rs +++ b/crates/rendering/src/yuv_converter.rs @@ -868,6 +868,23 @@ impl YuvToRgbaConverter { device: &wgpu::Device, queue: &wgpu::Queue, image_buf: &cv::ImageBuf, + ) -> Result<&wgpu::TextureView, YuvConversionError> { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("NV12 IOSurface Conversion Encoder"), + }); + + self.convert_nv12_from_iosurface_to_encoder(device, &mut encoder, image_buf)?; + queue.submit(std::iter::once(encoder.finish())); + + Ok(self.current_output_view()) + } + + #[cfg(target_os = "macos")] + pub fn convert_nv12_from_iosurface_to_encoder( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + image_buf: &cv::ImageBuf, ) -> Result<&wgpu::TextureView, YuvConversionError> { if self.iosurface_cache.is_none() { return Err(IOSurfaceTextureError::NoMetalDevice.into()); @@ -929,13 +946,9 @@ impl YuvToRgbaConverter { ], }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("NV12 IOSurface Conversion Encoder"), - }); - { let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("NV12 IOSurface Conversion Pass"), + label: Some("NV12 IOSurface Conversion Pass (Batched)"), ..Default::default() }); compute_pass.set_pipeline(&self.pipelines.nv12_pipeline); @@ -943,8 +956,6 @@ impl YuvToRgbaConverter { compute_pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); } - queue.submit(std::iter::once(encoder.finish())); - Ok(self.current_output_view()) } diff --git a/crates/scap-targets/src/platform/macos.rs b/crates/scap-targets/src/platform/macos.rs index df961872eb2..98720d8fd1f 100644 --- a/crates/scap-targets/src/platform/macos.rs +++ b/crates/scap-targets/src/platform/macos.rs @@ -510,7 +510,7 @@ impl WindowImpl { pub fn app_icon(&self) -> Option> { use cocoa::base::{id, nil}; - use cocoa::foundation::{NSArray, NSAutoreleasePool, NSString}; + use cocoa::foundation::{NSArray, NSAutoreleasePool, NSPoint, NSRect, NSSize, NSString}; use objc::{class, msg_send, sel, sel_impl}; let owner_name = self.owner_name()?; @@ -549,13 +549,25 @@ impl WindowImpl { return None; } - let tiff_data: id = msg_send![icon, TIFFRepresentation]; - if tiff_data.is_null() { + // The target card renders this at 64 points. Asking AppKit for a + // proposed 64-point image selects the 128px Retina representation + // instead of serializing the full 1024px icon and every TIFF rep. + let mut proposed_rect = + NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(64.0, 64.0)); + let cg_image: *mut c_void = msg_send![ + icon, + CGImageForProposedRect: &mut proposed_rect + context: nil + hints: nil + ]; + if cg_image.is_null() { return None; } let bitmap_rep_class = class!(NSBitmapImageRep); - let bitmap_rep: id = msg_send![bitmap_rep_class, imageRepWithData: tiff_data]; + let bitmap_rep: id = msg_send![bitmap_rep_class, alloc]; + let bitmap_rep: id = msg_send![bitmap_rep, initWithCGImage: cg_image]; + let bitmap_rep: id = msg_send![bitmap_rep, autorelease]; if bitmap_rep.is_null() { return None; } diff --git a/crates/workspace-hack/Cargo.toml b/crates/workspace-hack/Cargo.toml index 32e6b341b5e..56405dc2307 100644 --- a/crates/workspace-hack/Cargo.toml +++ b/crates/workspace-hack/Cargo.toml @@ -66,7 +66,6 @@ simd-adler32 = { version = "0.3" } smallvec = { version = "1", default-features = false, features = ["const_new"] } specta = { version = "2.0.0-rc.20", features = ["derive", "function", "serde_json", "uuid"] } stable_deref_trait = { version = "1" } -tauri-utils = { version = "2", default-features = false, features = ["build", "compression", "resources"] } thiserror = { version = "2" } time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde"] } tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time", "tracing"] } @@ -111,7 +110,6 @@ smallvec = { version = "1", default-features = false, features = ["const_new"] } specta-macros = { version = "2.0.0-rc.17", features = ["DO_NOT_USE_function"] } stable_deref_trait = { version = "1" } syn = { version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } -tauri-utils = { version = "2", default-features = false, features = ["build", "compression", "resources"] } thiserror = { version = "2" } uuid = { version = "1", features = ["serde", "v4", "v7"] } diff --git a/package.json b/package.json index 55c4e1879e7..80a1cadde50 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "docker:up": "turbo run docker:up", "format": "pnpm exec biome check --write", "lint": "pnpm exec biome lint", - "tauri:build": "node scripts/build-desktop-binaries.mjs && dotenv -e .env -- pnpm --dir apps/desktop run preparescript && dotenv -e .env -- pnpm --dir apps/desktop tauri build --verbose", + "electron:build": "dotenv -e .env -- pnpm --dir apps/desktop build:electron", "typecheck": "pnpm --dir apps/web exec next typegen && pnpm tsc -b", "test": "turbo run test", "test:web": "pnpm --filter=@cap/web test", @@ -46,7 +46,6 @@ "test:desktop:memory:unit": "pnpm --dir apps/desktop test:memory:unit", "web": "pnpm --filter=@cap/web", "env-setup": "node scripts/env-cli.js", - "check-tauri-versions": "node scripts/check-tauri-plugin-versions.js", "clean": "find . -name node_modules -o -name .next -o -name .output -o -name .turbo -o -name dist -type d -prune | xargs rm -rf", "lgtm-otel": "docker run -p 3010:3000 -p 4317:4317 -p 4318:4318 --rm -it docker.io/grafana/otel-lgtm", "with-env": "dotenv -e .env --" @@ -59,7 +58,8 @@ "mysql2": "^3.15.2", "tsdown": "^0.15.6", "turbo": "^2.3.4", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "yaml": "^2.9.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bfe945d8f1..beb10d9ebbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 11.0.0 mysql2: specifier: ^3.15.2 - version: 3.22.5(@types/node@22.20.0) + version: 3.22.5(@types/node@24.13.3) tsdown: specifier: ^0.15.6 version: 0.15.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.1)(typescript@5.9.3) @@ -35,6 +35,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.9.3 + yaml: + specifier: ^2.9.0 + version: 2.9.0 apps/chrome-extension: dependencies: @@ -86,7 +89,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: 4.4.1 - version: 4.4.1(vite@6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 4.4.1(vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) autoprefixer: specifier: ^10.4.16 version: 10.5.2(postcss@8.5.16) @@ -101,13 +104,17 @@ importers: version: 5.9.3 vite: specifier: 6.3.5 - version: 6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) vitest: specifier: ^3.2.0 - version: 3.2.6(@types/debug@4.1.13)(@types/node@22.20.0)(@vitest/ui@3.2.6)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 3.2.6(@types/debug@4.1.13)(@types/node@24.13.3)(@vitest/ui@3.2.6)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) apps/desktop: dependencies: + electron-updater: + specifier: 6.8.9 + version: 6.8.9 + devDependencies: '@aerofoil/rive-solid-canvas': specifier: ^2.1.4 version: 2.1.4 @@ -126,6 +133,12 @@ importers: '@corvu/tooltip': specifier: ^0.2.1 version: 0.2.2(solid-js@1.9.13) + '@fontsource/geist-sans': + specifier: ^5.0.3 + version: 5.2.5 + '@iconify/json': + specifier: ^2.2.239 + version: 2.2.493 '@kobalte/core': specifier: ^0.13.11 version: 0.13.11(solid-js@1.9.13) @@ -138,6 +151,9 @@ importers: '@rive-app/canvas': specifier: ^2.32.2 version: 2.38.3 + '@solid-devtools/overlay': + specifier: ^0.33.5 + version: 0.33.5(solid-js@1.9.13) '@solid-primitives/bounds': specifier: ^0.0.122 version: 0.0.122(solid-js@1.9.13) @@ -191,67 +207,64 @@ importers: version: 0.14.10(solid-js@1.9.13) '@solidjs/start': specifier: ^1.1.3 - version: 1.3.2(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 1.3.2(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@tailwindcss/postcss': + specifier: ^4.2.2 + version: 4.3.2 + '@tailwindcss/typography': + specifier: ^0.5.9 + version: 0.5.20(tailwindcss@4.3.2) '@tanstack/solid-query': specifier: ^5.51.21 version: 5.101.2(solid-js@1.9.13) - '@tauri-apps/api': - specifier: 2.8.0 - version: 2.8.0 - '@tauri-apps/plugin-clipboard-manager': - specifier: ^2.3.0 - version: 2.3.2 - '@tauri-apps/plugin-deep-link': - specifier: ^2.4.1 - version: 2.4.9 - '@tauri-apps/plugin-dialog': - specifier: 2.4.2 - version: 2.4.2 - '@tauri-apps/plugin-fs': - specifier: 2.4.2 - version: 2.4.2 - '@tauri-apps/plugin-http': - specifier: 2.5.2 - version: 2.5.2 - '@tauri-apps/plugin-notification': - specifier: ^2.3.0 - version: 2.3.3 - '@tauri-apps/plugin-opener': - specifier: ^2.5.0 - version: 2.5.4 - '@tauri-apps/plugin-os': - specifier: ^2.3.0 - version: 2.3.2 - '@tauri-apps/plugin-process': - specifier: 2.3.0 - version: 2.3.0 - '@tauri-apps/plugin-shell': - specifier: ^2.3.0 - version: 2.3.5 - '@tauri-apps/plugin-store': - specifier: ^2.4.0 - version: 2.4.3 - '@tauri-apps/plugin-updater': - specifier: 2.9.0 - version: 2.9.0 + '@total-typescript/ts-reset': + specifier: ^0.6.1 + version: 0.6.1 '@ts-rest/core': specifier: ^3.52.1 - version: 3.52.1(@types/node@22.20.0)(zod@3.25.76) + version: 3.52.1(@types/node@24.13.3)(zod@3.25.76) + '@types/dom-webcodecs': + specifier: ^0.1.11 + version: 0.1.18 '@types/react-tooltip': specifier: ^4.2.4 version: 4.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + '@webgpu/types': + specifier: ^0.1.44 + version: 0.1.71 + concurrently: + specifier: 9.2.1 + version: 9.2.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 cva: specifier: npm:class-variance-authority@^0.7.0 version: class-variance-authority@0.7.1 effect: specifier: ^3.18.4 version: 3.21.4 + electron: + specifier: 43.4.0 + version: 43.4.0 + electron-builder: + specifier: 26.15.3 + version: 26.15.3(electron-builder-squirrel-windows@26.15.3) + esbuild: + specifier: ^0.25.12 + version: 0.25.12 lz4-wasm: specifier: ^0.9.2 version: 0.9.2 mp4box: specifier: ^0.5.2 version: 0.5.4 + solid-devtools: + specifier: ^0.34.5 + version: 0.34.5(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) solid-js: specifier: ^1.9.3 version: 1.9.13 @@ -267,6 +280,12 @@ importers: solid-transition-group: specifier: ^0.2.3 version: 0.2.3(solid-js@1.9.13) + tailwind-scrollbar: + specifier: ^3.1.0 + version: 3.1.0(tailwindcss@3.4.19(yaml@2.9.0)) + typescript: + specifier: ^5.8.3 + version: 5.9.3 unplugin-auto-import: specifier: ^0.18.2 version: 0.18.6(rollup@4.62.2) @@ -278,71 +297,31 @@ importers: version: 9.0.1 vinxi: specifier: ^0.5.6 - version: 0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) - webcodecs: - specifier: ^0.1.0 - version: 0.1.0 - zod: - specifier: ^3.25.76 - version: 3.25.76 - devDependencies: - '@fontsource/geist-sans': - specifier: ^5.0.3 - version: 5.2.5 - '@iconify/json': - specifier: ^2.2.239 - version: 2.2.493 - '@solid-devtools/overlay': - specifier: ^0.33.5 - version: 0.33.5(solid-js@1.9.13) - '@tailwindcss/postcss': - specifier: ^4.2.2 - version: 4.3.2 - '@tailwindcss/typography': - specifier: ^0.5.9 - version: 0.5.20(tailwindcss@4.3.2) - '@tauri-apps/cli': - specifier: '>=2.1.0' - version: 2.11.4 - '@total-typescript/ts-reset': - specifier: ^0.6.1 - version: 0.6.1 - '@types/dom-webcodecs': - specifier: ^0.1.11 - version: 0.1.18 - '@types/uuid': - specifier: ^9.0.8 - version: 9.0.8 - '@webgpu/types': - specifier: ^0.1.44 - version: 0.1.71 - cross-env: - specifier: ^7.0.3 - version: 7.0.3 - solid-devtools: - specifier: ^0.34.5 - version: 0.34.5(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) - tailwind-scrollbar: - specifier: ^3.1.0 - version: 3.1.0(tailwindcss@3.4.19(yaml@2.9.0)) - typescript: - specifier: ^5.8.3 - version: 5.9.3 + version: 0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) vite-plugin-top-level-await: specifier: ^1.4.4 - version: 1.6.0(@swc/helpers@0.5.23)(rollup@4.62.2)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 1.6.0(@swc/helpers@0.5.23)(rollup@4.62.2)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vite-plugin-wasm: specifier: ^3.4.1 - version: 3.6.0(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 3.6.0(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vite-tsconfig-paths: specifier: ^5.0.1 - version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vitest: specifier: ~2.1.9 - version: 2.1.9(@types/node@22.20.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.3)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0) + wait-on: + specifier: 9.0.1 + version: 9.0.1 + webcodecs: + specifier: ^0.1.0 + version: 0.1.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 apps/discord-bot: dependencies: @@ -373,7 +352,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.6.4 - version: 0.6.16(@cloudflare/workers-types@4.20260630.1)(@vitest/runner@3.2.6)(@vitest/snapshot@3.2.6)(vitest@2.1.9(@types/node@22.20.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 0.6.16(@cloudflare/workers-types@4.20260630.1)(@vitest/runner@3.2.6)(@vitest/snapshot@3.2.6)(vitest@2.1.9(@types/node@24.13.3)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)) '@cloudflare/workers-types': specifier: ^4.20250214.0 version: 4.20260630.1 @@ -382,7 +361,7 @@ importers: version: 5.9.3 vitest: specifier: ~2.1.9 - version: 2.1.9(@types/node@22.20.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.3)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0) wrangler: specifier: ^3.109.1 version: 3.114.17(@cloudflare/workers-types@4.20260630.1) @@ -549,7 +528,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.6(@types/debug@4.1.13)(@types/node@22.20.0)(@vitest/ui@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 3.2.6(@types/debug@4.1.13)(@types/node@24.13.3)(@vitest/ui@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) apps/storybook: dependencies: @@ -595,16 +574,16 @@ importers: version: 1.0.0-beta.7(@storybook/test@8.6.14(storybook@8.6.18(prettier@3.9.4)))(solid-js@1.9.13)(storybook@8.6.18(prettier@3.9.4)) storybook-solidjs-vite: specifier: ^1.0.0-beta.2 - version: 1.0.0-beta.7(@storybook/test@8.6.14(storybook@8.6.18(prettier@3.9.4)))(esbuild@0.25.12)(rollup@4.62.2)(solid-js@1.9.13)(storybook@8.6.18(prettier@3.9.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 1.0.0-beta.7(@storybook/test@8.6.14(storybook@8.6.18(prettier@3.9.4)))(esbuild@0.25.12)(rollup@4.62.2)(solid-js@1.9.13)(storybook@8.6.18(prettier@3.9.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) typescript: specifier: ^5.8.3 version: 5.9.3 vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.10.2 - version: 2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) apps/web: dependencies: @@ -1106,7 +1085,7 @@ importers: version: 0.71.2(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4) '@effect/sql-mysql2': specifier: ^0.47.0 - version: 0.47.1(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4))(@types/node@22.20.0)(effect@3.21.4) + version: 0.47.1(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4))(@types/node@24.13.3)(effect@3.21.4) '@effect/workflow': specifier: ^0.11.3 version: 0.11.5(@effect/platform@0.92.1(effect@3.21.4))(@effect/rpc@0.71.2(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4))(effect@3.21.4) @@ -1338,7 +1317,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.6(@types/debug@4.1.13)(@types/node@22.20.0)(@vitest/ui@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 3.2.6(@types/debug@4.1.13)(@types/node@24.13.3)(@vitest/ui@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) packages/sdk-embed: dependencies: @@ -1545,7 +1524,7 @@ importers: version: 0.18.6(rollup@4.62.2) unplugin-fonts: specifier: ^1.1.1 - version: 1.4.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 1.4.0(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) unplugin-icons: specifier: ^0.19.2 version: 0.19.3 @@ -1594,7 +1573,7 @@ importers: dependencies: '@ts-rest/core': specifier: ^3.52.1 - version: 3.52.1(@types/node@22.20.0)(zod@3.25.76) + version: 3.52.1(@types/node@24.13.3)(zod@3.25.76) zod: specifier: ^3.25.76 version: 3.25.76 @@ -1657,13 +1636,13 @@ importers: version: 3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0) drizzle-orm: specifier: 0.44.6 - version: 0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)) + version: 0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)) effect: specifier: ^3.18.4 version: 3.21.4 next: specifier: 16.3.0 - version: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -2824,6 +2803,50 @@ packages: resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} + '@electron-internal/extract-zip@1.0.5': + resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} + engines: {node: '>=22.12.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.2.0': + resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -4022,6 +4045,26 @@ packages: engines: {node: '>=6'} hasBin: true + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.7': + resolution: {integrity: sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==} + engines: {node: '>=14.0.0'} + + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@headlessui/react@1.7.19': resolution: {integrity: sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==} engines: {node: '>=10'} @@ -4480,6 +4523,14 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -4746,10 +4797,18 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -6805,6 +6864,10 @@ packages: '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -7429,6 +7492,10 @@ packages: '@swc/wasm@1.15.43': resolution: {integrity: sha512-jYqeckrzZGAU+9OSfmL15MWfhkKWRCC8QMssL/MZu/MaIP76mU3VHjzqxlwKIz9DOSR/9jCqi1+QlWE0ILNehA==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@t3-oss/env-core@0.12.0': resolution: {integrity: sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw==} peerDependencies: @@ -7644,181 +7711,74 @@ packages: '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/api@2.8.0': - resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==} - '@tauri-apps/cli-darwin-arm64@1.6.3': resolution: {integrity: sha512-fQN6IYSL8bG4NvkdKE4sAGF4dF/QqqQq4hOAU+t8ksOzHJr0hUlJYfncFeJYutr/MMkdF7hYKadSb0j5EE9r0A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-arm64@2.11.4': - resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@tauri-apps/cli-darwin-x64@1.6.3': resolution: {integrity: sha512-1yTXZzLajKAYINJOJhZfmMhCzweHSgKQ3bEgJSn6t+1vFkOgY8Yx4oFgWcybrrWI5J1ZLZAl47+LPOY81dLcyA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.11.4': - resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@1.6.3': resolution: {integrity: sha512-CjTEr9r9xgjcvos09AQw8QMRPuH152B1jvlZt4PfAsyJNPFigzuwed5/SF7XAd8bFikA7zArP4UT12RdBxrx7w==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': - resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@1.6.3': resolution: {integrity: sha512-G9EUUS4M8M/Jz1UKZqvJmQQCKOzgTb8/0jZKvfBuGfh5AjFBu8LHvlFpwkKVm1l4951Xg4ulUp6P9Q7WRJ9XSA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': - resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@tauri-apps/cli-linux-arm64-musl@1.6.3': resolution: {integrity: sha512-MuBTHJyNpZRbPVG8IZBN8+Zs7aKqwD22tkWVBcL1yOGL4zNNTJlkfL+zs5qxRnHlUsn6YAlbW/5HKocfpxVwBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-musl@2.11.4': - resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': - resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - '@tauri-apps/cli-linux-x64-gnu@1.6.3': resolution: {integrity: sha512-Uvi7M+NK3tAjCZEY1WGel+dFlzJmqcvu3KND+nqa22762NFmOuBIZ4KJR/IQHfpEYqKFNUhJfCGnpUDfiC3Oxg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-gnu@2.11.4': - resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@tauri-apps/cli-linux-x64-musl@1.6.3': resolution: {integrity: sha512-rc6B342C0ra8VezB/OJom9j/N+9oW4VRA4qMxS2f4bHY2B/z3J9NPOe6GOILeg4v/CV62ojkLsC3/K/CeF3fqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-musl@2.11.4': - resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@tauri-apps/cli-win32-arm64-msvc@1.6.3': resolution: {integrity: sha512-cSH2qOBYuYC4UVIFtrc1YsGfc5tfYrotoHrpTvRjUGu0VywvmyNk82+ZsHEnWZ2UHmu3l3lXIGRqSWveLln0xg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': - resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@1.6.3': resolution: {integrity: sha512-T8V6SJQqE4PSWmYBl0ChQVmS6AR2hXFHURH2DwAhgSGSQ6uBXgwlYFcfIeQpBQA727K2Eq8X2hGfvmoySyHMRw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': - resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - '@tauri-apps/cli-win32-x64-msvc@1.6.3': resolution: {integrity: sha512-HUkWZ+lYHI/Gjkh2QjHD/OBDpqLVmvjZGpLK9losur1Eg974Jip6k+vsoTUxQBCBDfj30eDBct9E1FvXOspWeg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.11.4': - resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@tauri-apps/cli@1.6.3': resolution: {integrity: sha512-q46umd6QLRKDd4Gg6WyZBGa2fWvk0pbeUA5vFomm4uOs1/17LIciHv2iQ4UD+2Yv5H7AO8YiE1t50V0POiEGEw==} engines: {node: '>= 10'} hasBin: true - '@tauri-apps/cli@2.11.4': - resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} - engines: {node: '>= 10'} - hasBin: true - - '@tauri-apps/plugin-clipboard-manager@2.3.2': - resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} - - '@tauri-apps/plugin-deep-link@2.4.9': - resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} - - '@tauri-apps/plugin-dialog@2.4.2': - resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} - - '@tauri-apps/plugin-fs@2.4.2': - resolution: {integrity: sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig==} - - '@tauri-apps/plugin-http@2.5.2': - resolution: {integrity: sha512-x1mQKHSLDk4mS2S938OTeyk8L7QyLpCrKZCZcjkljGsvTvRMojCvI9SeJ1kaxc7t8xSilkC7WdId8xER9TIGLg==} - - '@tauri-apps/plugin-notification@2.3.3': - resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} - - '@tauri-apps/plugin-opener@2.5.4': - resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} - - '@tauri-apps/plugin-os@2.3.2': - resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} - - '@tauri-apps/plugin-process@2.3.0': - resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} - - '@tauri-apps/plugin-shell@2.3.5': - resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} - '@tauri-apps/plugin-store@2.4.3': resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==} - '@tauri-apps/plugin-updater@2.9.0': - resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} - '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -7950,6 +7910,9 @@ packages: '@types/bun@1.3.14': resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} @@ -8046,6 +8009,9 @@ packages: '@types/filewriter@0.0.33': resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/google-protobuf@3.15.12': resolution: {integrity: sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==} @@ -8094,6 +8060,9 @@ packages: '@types/keygrip@1.0.6': resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/koa-compose@3.2.9': resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==} @@ -8136,6 +8105,9 @@ packages: '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} @@ -8171,6 +8143,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -8935,6 +8910,13 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 + archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} @@ -9045,6 +9027,10 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -9065,6 +9051,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + autoprefixer@10.5.2: resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==} engines: {node: ^10 || ^12 || >=14} @@ -9090,6 +9080,9 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + aws4fetch@1.0.18: resolution: {integrity: sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ==} @@ -9097,6 +9090,9 @@ packages: resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} engines: {node: '>=4'} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -9336,6 +9332,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -9398,6 +9398,14 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} + builtin-modules@5.0.0: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} @@ -9423,6 +9431,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -9439,6 +9451,10 @@ packages: resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} engines: {node: ^20.17.0 || >=22.9.0} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -9447,6 +9463,10 @@ packages: resolution: {integrity: sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==} engines: {node: '>=18'} + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -9575,6 +9595,9 @@ packages: chromium-edge-launcher@0.2.0: resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} @@ -9582,6 +9605,14 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -9632,6 +9663,9 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -9699,6 +9733,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + commander@6.2.1: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} @@ -9711,6 +9749,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} @@ -9718,6 +9760,10 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + compatx@0.2.0: resolution: {integrity: sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==} @@ -9740,6 +9786,11 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + concurrently@9.2.3: resolution: {integrity: sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==} engines: {node: '>=18'} @@ -9832,6 +9883,9 @@ packages: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} @@ -10034,6 +10088,10 @@ packages: resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==} engines: {node: '>=20'} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -10060,6 +10118,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -10112,6 +10174,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devalue@4.3.3: resolution: {integrity: sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==} @@ -10128,6 +10193,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -10135,6 +10203,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} + dns-packet@5.6.1: resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} engines: {node: '>=6'} @@ -10345,9 +10416,32 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + electron-builder-squirrel-windows@26.15.3: + resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} + + electron-builder@26.15.3: + resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} + electron-to-chromium@1.5.382: resolution: {integrity: sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@43.4.0: + resolution: {integrity: sha512-3qxGF0CeQbiox5oWV1JlbWGQ1VerbmDhTFqW4sJ8h7uqTHniFYPObXJcDna0DMh32et0fFyKzz0YY8lJv3t5jg==} + engines: {node: '>= 22.12.0'} + hasBin: true + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -10372,6 +10466,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-parser@5.2.3: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} @@ -10408,10 +10505,17 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -10466,6 +10570,9 @@ packages: es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -11382,6 +11489,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@11.3.1: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} @@ -11390,6 +11501,18 @@ packages: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + fs-minipass@3.0.3: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -11470,6 +11593,10 @@ packages: get-source@2.0.12: resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -11534,6 +11661,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -11577,6 +11708,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + got@14.6.6: resolution: {integrity: sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==} engines: {node: '>=20'} @@ -11706,6 +11841,10 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -11762,6 +11901,10 @@ packages: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} @@ -12166,6 +12309,14 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -12284,6 +12435,10 @@ packages: resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} engines: {node: '>= 0.6.0'} + joi@18.2.3: + resolution: {integrity: sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==} + engines: {node: '>= 20'} + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -12363,6 +12518,9 @@ packages: json-stringify-nice@1.1.4: resolution: {integrity: sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -12372,6 +12530,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -12431,6 +12592,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -12570,6 +12734,13 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -12607,6 +12778,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lowercase-keys@3.0.0: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -12694,6 +12869,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -12976,6 +13155,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -13002,6 +13186,14 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + mimic-response@4.0.0: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -13081,6 +13273,10 @@ packages: resolution: {integrity: sha512-CRdXtgfQH2jARmtNmPR0Q7jL20fiESbaYk1b0KvLD0jCdUuemepREtsbd8nbiY6BHV9OGGddAZITNXklupUPUQ==} engines: {node: '>=20.0.0'} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -13262,9 +13458,16 @@ packages: xml2js: optional: true + node-abi@4.33.0: + resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} + engines: {node: '>=22.12.0'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-av@5.2.4: resolution: {integrity: sha512-L5r+6k+YGvH5MZX8o55JHQbbeRZ+iFieAb1bhKtpa8hrqBEGuSwdkXRwpE4vd414JqVJsq/EQq6s+3qKeviQTg==} @@ -13345,6 +13548,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + normalize-url@8.1.1: resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} engines: {node: '>=14.16'} @@ -13727,6 +13934,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -13768,6 +13979,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + playwright-core@1.61.1: resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} @@ -13778,6 +13993,10 @@ packages: engines: {node: '>=18'} hasBin: true + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -13859,6 +14078,11 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -13948,6 +14172,10 @@ packages: promise-call-limit@3.0.2: resolution: {integrity: sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==} + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} @@ -13961,6 +14189,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -13975,6 +14206,13 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} @@ -14314,6 +14552,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -14490,6 +14732,10 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -14524,6 +14770,9 @@ packages: engines: {node: '>= 0.4'} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + responselike@4.0.2: resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==} engines: {node: '>=20'} @@ -14536,15 +14785,28 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rolldown-plugin-dts@0.17.8: resolution: {integrity: sha512-76EEBlhF00yeY6M7VpMkWKI4r9WjuoMiOGey7j4D6zf3m0BR+ZrrY9hvSXdueJ3ljxSLq4DJBKFpX/X9+L7EKw==} engines: {node: '>=20.19.0'} @@ -14652,6 +14914,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + sax@1.2.1: resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} @@ -14679,6 +14944,9 @@ packages: selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver-regex@4.0.5: resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} engines: {node: '>=12'} @@ -14687,6 +14955,10 @@ packages: resolution: {integrity: sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==} engines: {node: '>=12'} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -14723,6 +14995,10 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serialize-javascript@7.0.7: resolution: {integrity: sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==} engines: {node: '>=20.0.0'} @@ -14797,6 +15073,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + shell-quote@1.8.4: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} @@ -14850,6 +15130,10 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -15022,6 +15306,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql-escaper@1.3.3: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} @@ -15100,6 +15387,10 @@ packages: standard-navigation@0.0.7: resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==} + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -15300,6 +15591,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + super-regex@1.1.0: resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} engines: {node: '>=18'} @@ -15415,6 +15710,13 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + terminal-link@2.1.1: resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} engines: {node: '>=8'} @@ -15468,9 +15770,15 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -15523,6 +15831,9 @@ packages: resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} hasBin: true + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -15578,6 +15889,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -15697,6 +16011,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -15810,6 +16128,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@5.29.0: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} @@ -15919,6 +16240,10 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -16159,6 +16484,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -16474,6 +16802,11 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + wait-on@9.0.1: + resolution: {integrity: sha512-noeCAI+XbqWMXY23sKril0BSURhuLYarkVXwJv1uUWwoojZJE7pmX3vJ7kh7SZaNgPGzfsCSQIZM/AGvu0Q9pA==} + engines: {node: '>=20.0.0'} + hasBin: true + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -16592,6 +16925,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + which@6.0.1: resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -18030,7 +18368,7 @@ snapshots: optionalDependencies: workerd: 1.20250718.0 - '@cloudflare/vitest-pool-workers@0.6.16(@cloudflare/workers-types@4.20260630.1)(@vitest/runner@3.2.6)(@vitest/snapshot@3.2.6)(vitest@2.1.9(@types/node@22.20.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@cloudflare/vitest-pool-workers@0.6.16(@cloudflare/workers-types@4.20260630.1)(@vitest/runner@3.2.6)(@vitest/snapshot@3.2.6)(vitest@2.1.9(@types/node@24.13.3)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -18040,7 +18378,7 @@ snapshots: esbuild: 0.17.19 miniflare: 3.20250204.1 semver: 7.8.5 - vitest: 2.1.9(@types/node@22.20.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0) + vitest: 2.1.9(@types/node@24.13.3)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0) wrangler: 3.109.1(@cloudflare/workers-types@4.20260630.1) zod: 3.25.76 transitivePeerDependencies: @@ -18235,13 +18573,13 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@effect/sql-mysql2@0.47.1(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4))(@types/node@22.20.0)(effect@3.21.4)': + '@effect/sql-mysql2@0.47.1(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4))(@types/node@24.13.3)(effect@3.21.4)': dependencies: '@effect/experimental': 0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1) '@effect/platform': 0.92.1(effect@3.21.4) '@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.92.1(effect@3.21.4))(effect@3.21.4) effect: 3.21.4 - mysql2: 3.22.5(@types/node@22.20.0) + mysql2: 3.22.5(@types/node@24.13.3) transitivePeerDependencies: - '@types/node' @@ -18262,6 +18600,100 @@ snapshots: dependencies: '@types/hammerjs': 2.0.46 + '@electron-internal/extract-zip@1.0.5': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@5.1.0': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.8.5 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.28.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3(supports-color@8.1.1) + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.2.0': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + node-abi: 4.33.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + dir-compare: 4.2.0 + fs-extra: 11.3.6 + minimatch: 9.0.9 + plist: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3(supports-color@8.1.1) + fs-extra: 11.3.6 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -19248,6 +19680,22 @@ snapshots: protobufjs: 7.6.4 yargs: 17.7.3 + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.7': {} + + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + '@headlessui/react@1.7.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/react-virtual': 3.14.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -19680,6 +20128,19 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 @@ -19943,8 +20404,12 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/hashes@1.4.0': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.3.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -22412,6 +22877,8 @@ snapshots: '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -22733,11 +23200,11 @@ snapshots: dependencies: solid-js: 1.9.13 - '@solidjs/start@1.3.2(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@solidjs/start@1.3.2(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - '@tanstack/server-functions-plugin': 1.121.21(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0)) + '@tanstack/server-functions-plugin': 1.121.21(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0)) cookie-es: 2.0.1 defu: 6.1.7 error-stack-parser: 2.1.4 @@ -22749,8 +23216,8 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.1.0(solid-js@1.9.13) tinyglobby: 0.2.17 - vinxi: 0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) - vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vinxi: 0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) + vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@testing-library/jest-dom' - solid-js @@ -22869,12 +23336,12 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/builder-vite@10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@storybook/builder-vite@10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) storybook: 8.6.18(prettier@3.9.4) ts-dedent: 2.3.0 - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -22901,14 +23368,14 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@storybook/csf-plugin@10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: storybook: 8.6.18(prettier@3.9.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.25.12 rollup: 4.62.2 - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) '@storybook/csf-plugin@8.6.14(storybook@8.6.18(prettier@3.9.4))': dependencies: @@ -23110,6 +23577,10 @@ snapshots: '@swc/wasm@1.15.43': {} + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@t3-oss/env-core@0.12.0(typescript@5.9.3)(valibot@1.0.0-rc.1(typescript@5.9.3))(zod@3.25.76)': optionalDependencies: typescript: 5.9.3 @@ -23240,7 +23711,7 @@ snapshots: - csstype - utf-8-validate - '@tanstack/directive-functions-plugin@1.121.21(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@tanstack/directive-functions-plugin@1.121.21(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.29.7 @@ -23249,7 +23720,7 @@ snapshots: '@tanstack/router-utils': 1.162.2 babel-dead-code-elimination: 1.0.12 tiny-invariant: 1.3.3 - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -23307,7 +23778,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/server-functions-plugin@1.121.21(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@tanstack/server-functions-plugin@1.121.21(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.29.7 @@ -23316,7 +23787,7 @@ snapshots: '@babel/template': 7.29.7 '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 - '@tanstack/directive-functions-plugin': 1.121.21(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@tanstack/directive-functions-plugin': 1.121.21(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) babel-dead-code-elimination: 1.0.12 tiny-invariant: 1.3.3 transitivePeerDependencies: @@ -23334,73 +23805,39 @@ snapshots: '@tauri-apps/api@1.6.0': {} - '@tauri-apps/api@2.11.1': {} - - '@tauri-apps/api@2.8.0': {} - - '@tauri-apps/cli-darwin-arm64@1.6.3': + '@tauri-apps/api@2.11.1': optional: true - '@tauri-apps/cli-darwin-arm64@2.11.4': + '@tauri-apps/cli-darwin-arm64@1.6.3': optional: true '@tauri-apps/cli-darwin-x64@1.6.3': optional: true - '@tauri-apps/cli-darwin-x64@2.11.4': - optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@1.6.3': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': - optional: true - '@tauri-apps/cli-linux-arm64-gnu@1.6.3': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': - optional: true - '@tauri-apps/cli-linux-arm64-musl@1.6.3': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.4': - optional: true - - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': - optional: true - '@tauri-apps/cli-linux-x64-gnu@1.6.3': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.4': - optional: true - '@tauri-apps/cli-linux-x64-musl@1.6.3': optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.4': - optional: true - '@tauri-apps/cli-win32-arm64-msvc@1.6.3': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': - optional: true - '@tauri-apps/cli-win32-ia32-msvc@1.6.3': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': - optional: true - '@tauri-apps/cli-win32-x64-msvc@1.6.3': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.4': - optional: true - '@tauri-apps/cli@1.6.3': dependencies: semver: 7.8.5 @@ -23416,67 +23853,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 1.6.3 '@tauri-apps/cli-win32-x64-msvc': 1.6.3 - '@tauri-apps/cli@2.11.4': - optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.11.4 - '@tauri-apps/cli-darwin-x64': 2.11.4 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 - '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 - '@tauri-apps/cli-linux-arm64-musl': 2.11.4 - '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-musl': 2.11.4 - '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 - '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 - '@tauri-apps/cli-win32-x64-msvc': 2.11.4 - - '@tauri-apps/plugin-clipboard-manager@2.3.2': - dependencies: - '@tauri-apps/api': 2.8.0 - - '@tauri-apps/plugin-deep-link@2.4.9': - dependencies: - '@tauri-apps/api': 2.11.1 - - '@tauri-apps/plugin-dialog@2.4.2': - dependencies: - '@tauri-apps/api': 2.8.0 - - '@tauri-apps/plugin-fs@2.4.2': - dependencies: - '@tauri-apps/api': 2.8.0 - - '@tauri-apps/plugin-http@2.5.2': - dependencies: - '@tauri-apps/api': 2.8.0 - - '@tauri-apps/plugin-notification@2.3.3': - dependencies: - '@tauri-apps/api': 2.8.0 - - '@tauri-apps/plugin-opener@2.5.4': - dependencies: - '@tauri-apps/api': 2.11.1 - - '@tauri-apps/plugin-os@2.3.2': - dependencies: - '@tauri-apps/api': 2.8.0 - - '@tauri-apps/plugin-process@2.3.0': - dependencies: - '@tauri-apps/api': 2.8.0 - - '@tauri-apps/plugin-shell@2.3.5': - dependencies: - '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-store@2.4.3': dependencies: '@tauri-apps/api': 2.11.1 - - '@tauri-apps/plugin-updater@2.9.0': - dependencies: - '@tauri-apps/api': 2.8.0 + optional: true '@testing-library/dom@10.4.0': dependencies: @@ -23544,9 +23924,9 @@ snapshots: '@types/node': 20.19.43 zod: 3.25.76 - '@ts-rest/core@3.52.1(@types/node@22.20.0)(zod@3.25.76)': + '@ts-rest/core@3.52.1(@types/node@24.13.3)(zod@3.25.76)': optionalDependencies: - '@types/node': 22.20.0 + '@types/node': 24.13.3 zod: 3.25.76 '@tsconfig/bun@1.0.7': {} @@ -23621,6 +24001,13 @@ snapshots: dependencies: bun-types: 1.3.14 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 20.19.43 + '@types/responselike': 1.0.3 + '@types/canvas-confetti@1.9.0': {} '@types/chai@5.2.3': @@ -23724,6 +24111,10 @@ snapshots: '@types/filewriter@0.0.33': {} + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 20.19.43 + '@types/google-protobuf@3.15.12': {} '@types/graceful-fs@4.1.9': @@ -23768,6 +24159,10 @@ snapshots: '@types/keygrip@1.0.6': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 20.19.43 + '@types/koa-compose@3.2.9': dependencies: '@types/koa': 2.15.2 @@ -23820,6 +24215,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + '@types/prismjs@1.26.6': {} '@types/qs@6.15.1': {} @@ -23855,6 +24254,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/responselike@1.0.3': + dependencies: + '@types/node': 20.19.43 + '@types/semver@7.7.1': {} '@types/send@0.17.6': @@ -24257,7 +24660,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.3 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))': dependencies: '@babel/parser': 7.29.7 acorn: 8.17.0 @@ -24268,18 +24671,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.12 tslib: 2.8.1 - vinxi: 0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) + vinxi: 0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) - '@vinxi/server-components@0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))': + '@vinxi/server-components@0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0)) acorn: 8.17.0 acorn-loose: 8.5.2 acorn-typescript: 1.4.13(acorn@8.17.0) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.12 - vinxi: 0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) + vinxi: 0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0) '@virtual-grid/core@2.0.1': {} @@ -24308,14 +24711,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -24360,13 +24763,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 5.4.21(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0) '@vitest/mocker@3.2.6(vite@6.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: @@ -24376,21 +24779,21 @@ snapshots: optionalDependencies: vite: 6.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - '@vitest/mocker@3.2.6(vite@6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.6(vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - '@vitest/mocker@3.2.6(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.6(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) '@vitest/pretty-format@2.0.5': dependencies: @@ -24448,7 +24851,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/debug@4.1.13)(@types/node@22.20.0)(@vitest/ui@3.2.6)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: 3.2.6(@types/debug@4.1.13)(@types/node@24.13.3)(@vitest/ui@3.2.6)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) '@vitest/utils@2.0.5': dependencies: @@ -24962,6 +25365,54 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): + dependencies: + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.2.0 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 2.3.0 + '@peculiar/webcrypto': 1.7.1 + '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 + async-exit-hook: 2.0.1 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3(supports-color@8.1.1) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.3.0 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + pkijs: 3.4.0 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.19 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + unzipper: 0.12.5 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + archiver-utils@5.0.2: dependencies: glob: 10.5.0 @@ -25121,6 +25572,8 @@ snapshots: astring@1.9.0: {} + async-exit-hook@2.0.1: {} + async-function@1.0.0: {} async-listen@3.0.0: {} @@ -25135,6 +25588,8 @@ snapshots: asynckit@0.4.0: {} + at-least-node@1.0.0: {} + autoprefixer@10.5.2(postcss@8.5.16): dependencies: browserslist: 4.28.4 @@ -25176,10 +25631,22 @@ snapshots: aws-ssl-profiles@1.1.2: {} + aws4@1.13.2: {} + aws4fetch@1.0.18: {} axe-core@4.12.1: {} + axios@1.19.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} b4a@1.8.1: {} @@ -25488,6 +25955,9 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + bottleneck@2.19.5: {} bowser@2.14.1: {} @@ -25566,6 +26036,32 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@26.15.3: + dependencies: + '@types/debug': 4.1.13 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + js-yaml: 4.3.0 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + builtin-modules@5.0.0: {} bun-types@1.3.14: @@ -25585,6 +26081,8 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + c12@3.3.4(magicast@0.5.3): dependencies: chokidar: 5.0.0 @@ -25617,6 +26115,8 @@ snapshots: p-map: 7.0.5 ssri: 13.0.1 + cacheable-lookup@5.0.4: {} + cacheable-lookup@7.0.0: {} cacheable-request@13.0.19: @@ -25629,6 +26129,16 @@ snapshots: normalize-url: 8.1.1 responselike: 4.0.2 + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -25764,10 +26274,16 @@ snapshots: transitivePeerDependencies: - supports-color + chromium-pickle-js@0.2.0: {} + ci-info@2.0.0: {} ci-info@3.9.0: {} + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -25818,6 +26334,10 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clone@1.0.4: {} clsx@2.1.1: {} @@ -25872,16 +26392,23 @@ snapshots: commander@4.1.1: {} + commander@5.1.0: {} + commander@6.2.1: {} commander@7.2.0: {} commander@8.3.0: {} + commander@9.5.0: + optional: true + common-ancestor-path@2.0.0: {} commondir@1.0.1: {} + compare-version@0.1.2: {} + compatx@0.2.0: {} compress-commons@6.0.2: @@ -25917,6 +26444,15 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + concurrently@9.2.3: dependencies: chalk: 4.1.2 @@ -25992,6 +26528,9 @@ snapshots: croner@10.0.1: {} + cross-dirname@0.1.0: + optional: true + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -26120,10 +26659,10 @@ snapshots: '@deno/shim-deno': 0.19.2 undici-types: 5.28.4 - db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)): + db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)): optionalDependencies: - drizzle-orm: 0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)) - mysql2: 3.22.5(@types/node@22.20.0) + drizzle-orm: 0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)) + mysql2: 3.22.5(@types/node@24.13.3) debounce@2.2.0: {} @@ -26159,6 +26698,10 @@ snapshots: dependencies: mimic-response: 4.0.0 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} deep-equal@2.2.3: @@ -26197,6 +26740,8 @@ snapshots: dependencies: clone: 1.0.4 + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -26233,6 +26778,9 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: + optional: true + devalue@4.3.3: {} devalue@5.8.1: {} @@ -26245,12 +26793,27 @@ snapshots: diff@8.0.4: {} + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + dir-glob@3.0.1: dependencies: path-type: 4.0.0 dlv@1.1.3: {} + dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + fs-extra: 10.1.0 + js-yaml: 4.3.0 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + dns-packet@5.6.1: dependencies: '@leichtgewicht/ip-codec': 2.0.5 @@ -26340,13 +26903,13 @@ snapshots: bun-types: 1.3.14 mysql2: 3.22.5(@types/node@20.19.43) - drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)): + drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)): optionalDependencies: '@cloudflare/workers-types': 4.20260630.1 '@opentelemetry/api': 1.9.1 '@planetscale/database': 1.20.1 bun-types: 1.3.14 - mysql2: 3.22.5(@types/node@22.20.0) + mysql2: 3.22.5(@types/node@24.13.3) dts-resolver@2.1.3: {} @@ -26387,8 +26950,80 @@ snapshots: dependencies: jake: 10.9.4 + electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.15.3: + dependencies: + '@types/fs-extra': 9.0.13 + aws4: 1.13.2 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + form-data: 4.0.6 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + electron-to-chromium@1.5.382: {} + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.3.0 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3(supports-color@8.1.1) + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@43.4.0: + dependencies: + '@electron-internal/extract-zip': 1.0.5 + '@electron/get': 5.1.0 + '@types/node': 24.13.3 + transitivePeerDependencies: + - supports-color + emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} @@ -26403,6 +27038,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + engine.io-parser@5.2.3: {} engine.io@6.6.9: @@ -26448,8 +27087,12 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} + err-code@2.0.3: {} + error-stack-parser-es@1.0.5: {} error-stack-parser@2.1.4: @@ -26585,6 +27228,9 @@ snapshots: es-toolkit@1.49.0: {} + es6-error@4.1.1: + optional: true + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -28002,6 +28648,12 @@ snapshots: fresh@2.0.0: {} + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@11.3.1: dependencies: graceful-fs: 4.2.11 @@ -28014,6 +28666,25 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-minipass@3.0.3: dependencies: minipass: 7.1.3 @@ -28091,6 +28762,10 @@ snapshots: data-uri-to-buffer: 2.0.2 source-map: 0.6.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-stream@6.0.1: {} get-stream@8.0.1: {} @@ -28168,6 +28843,16 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.5 + serialize-error: 7.0.1 + optional: true + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -28211,6 +28896,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + got@14.6.6: dependencies: '@sindresorhus/is': 7.2.0 @@ -28422,6 +29121,10 @@ snapshots: hookable@5.5.3: {} + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -28490,6 +29193,11 @@ snapshots: http-shutdown@1.2.2: {} + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + http2-wrapper@2.2.1: dependencies: quick-lru: 5.1.1 @@ -28851,6 +29559,10 @@ snapshots: isarray@2.0.5: {} + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + isexe@2.0.0: {} isexe@3.1.5: {} @@ -29013,6 +29725,16 @@ snapshots: jmespath@0.16.0: {} + joi@18.2.3: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.7 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + jose@4.15.9: {} jose@5.10.0: {} @@ -29087,12 +29809,19 @@ snapshots: json-stringify-nice@1.1.4: {} + json-stringify-safe@5.0.1: + optional: true + json5@1.0.2: dependencies: minimist: 1.2.8 json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -29147,6 +29876,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + lazy-val@1.0.5: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -29278,6 +30009,10 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} lodash.throttle@4.1.1: {} @@ -29310,6 +30045,8 @@ snapshots: loupe@3.2.1: {} + lowercase-keys@2.0.0: {} + lowercase-keys@3.0.0: {} lru-cache@10.4.3: {} @@ -29405,6 +30142,11 @@ snapshots: marky@1.3.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} md-to-react-email@5.0.6(react@19.2.4): @@ -30065,6 +30807,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mime@3.0.0: {} mime@4.1.0: {} @@ -30077,6 +30821,10 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + mimic-response@4.0.0: {} min-indent@1.0.1: {} @@ -30173,6 +30921,10 @@ snapshots: mixpart@0.0.6: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mkdirp@1.0.4: {} mlly@1.8.2: @@ -30253,9 +31005,9 @@ snapshots: named-placeholders: 1.1.6 sql-escaper: 1.3.3 - mysql2@3.22.5(@types/node@22.20.0): + mysql2@3.22.5(@types/node@24.13.3): dependencies: - '@types/node': 22.20.0 + '@types/node': 24.13.3 aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 @@ -30354,7 +31106,7 @@ snapshots: - '@types/node' - babel-plugin-macros - next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.3.0 '@swc/helpers': 0.5.15 @@ -30376,13 +31128,13 @@ snapshots: '@opentelemetry/api': 1.9.1 '@playwright/test': 1.61.1 babel-plugin-react-compiler: 1.0.0 - sharp: 0.35.3(@types/node@22.20.0) + sharp: 0.35.3(@types/node@24.13.3) transitivePeerDependencies: - '@babel/core' - '@types/node' - babel-plugin-macros - nitropack@2.13.4(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(xml2js@0.6.2): + nitropack@2.13.4(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3))(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.62.2) @@ -30403,7 +31155,7 @@ snapshots: cookie-es: 2.0.1 croner: 10.0.1 crossws: 0.3.5 - db0: 0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)) + db0: 0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)) defu: 6.1.7 destr: 2.0.5 dot-prop: 10.1.0 @@ -30447,9 +31199,9 @@ snapshots: uncrypto: 0.1.3 unctx: 2.5.0 unenv: 2.0.0-rc.24 - unimport: 6.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + unimport: 6.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) unplugin-utils: 0.3.2 - unstorage: 1.17.5(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1) + unstorage: 1.17.5(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -30494,8 +31246,16 @@ snapshots: - vite - webpack + node-abi@4.33.0: + dependencies: + semver: 7.8.5 + node-addon-api@7.1.1: {} + node-api-version@0.2.1: + dependencies: + semver: 7.8.5 + node-av@5.2.4: dependencies: unzipper: 0.12.5 @@ -30594,6 +31354,8 @@ snapshots: normalize-path@3.0.0: {} + normalize-url@6.1.0: {} + normalize-url@8.1.1: {} npm-bundled@5.0.0: @@ -31051,6 +31813,8 @@ snapshots: pathval@2.0.1: {} + pe-library@0.4.1: {} + peberminta@0.9.0: {} pend@1.2.0: {} @@ -31085,6 +31849,15 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + playwright-core@1.61.1: {} playwright@1.61.1: @@ -31093,6 +31866,12 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.10 @@ -31173,6 +31952,11 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + optional: true + powershell-utils@0.1.0: {} preact-render-to-string@5.2.6(preact@10.29.3): @@ -31243,6 +32027,11 @@ snapshots: promise-call-limit@3.0.2: {} + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + promise@7.3.1: dependencies: asap: 2.0.6 @@ -31262,6 +32051,12 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@6.5.0: {} property-information@7.2.0: {} @@ -31285,6 +32080,13 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@1.3.2: {} punycode@2.3.1: {} @@ -31715,6 +32517,12 @@ snapshots: react@19.2.4: {} + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -31980,6 +32788,10 @@ snapshots: requires-port@1.0.0: {} + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + reselect@5.2.0: {} resend@4.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -32015,6 +32827,10 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + responselike@4.0.2: dependencies: lowercase-keys: 3.0.0 @@ -32029,12 +32845,28 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retry@0.12.0: {} + reusify@1.1.0: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + rimraf@3.0.2: dependencies: glob: 7.2.3 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rolldown-plugin-dts@0.17.8(rolldown@1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.1))(typescript@5.9.3): dependencies: '@babel/generator': 7.29.7 @@ -32221,6 +33053,10 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + sax@1.2.1: {} sax@1.6.0: {} @@ -32243,12 +33079,17 @@ snapshots: dependencies: parseley: 0.12.1 + semver-compare@1.0.0: + optional: true + semver-regex@4.0.5: {} semver-truncate@3.0.0: dependencies: semver: 7.8.5 + semver@5.7.2: {} + semver@6.3.1: {} semver@7.6.3: {} @@ -32295,6 +33136,11 @@ snapshots: serialize-error@2.1.0: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serialize-javascript@7.0.7: {} seroval-plugins@1.5.4(seroval@1.5.4): @@ -32418,7 +33264,7 @@ snapshots: '@types/node': 20.19.43 optional: true - sharp@0.35.3(@types/node@22.20.0): + sharp@0.35.3(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -32449,7 +33295,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 - '@types/node': 22.20.0 + '@types/node': 24.13.3 optional: true shebang-command@2.0.0: @@ -32458,6 +33304,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + shell-quote@1.8.4: {} shell-quote@1.9.0: {} @@ -32541,6 +33389,10 @@ snapshots: dependencies: is-arrayish: 0.3.4 + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -32608,7 +33460,7 @@ snapshots: ip-address: 10.2.0 smart-buffer: 4.2.0 - solid-devtools@0.34.5(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + solid-devtools@0.34.5(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) @@ -32617,7 +33469,7 @@ snapshots: '@solid-devtools/shared': 0.20.0(solid-js@1.9.13) solid-js: 1.9.13 optionalDependencies: - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -32738,6 +33590,9 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: + optional: true + sql-escaper@1.3.3: {} ssri@13.0.1: @@ -32810,6 +33665,8 @@ snapshots: standard-navigation@0.0.7: {} + stat-mode@1.0.0: {} + statuses@1.5.0: {} statuses@2.0.2: {} @@ -32827,16 +33684,16 @@ snapshots: stoppable@1.1.0: {} - storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.14(storybook@8.6.18(prettier@3.9.4)))(esbuild@0.25.12)(rollup@4.62.2)(solid-js@1.9.13)(storybook@8.6.18(prettier@3.9.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.14(storybook@8.6.18(prettier@3.9.4)))(esbuild@0.25.12)(rollup@4.62.2)(solid-js@1.9.13)(storybook@8.6.18(prettier@3.9.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: - '@storybook/builder-vite': 10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@storybook/builder-vite': 10.6.0-alpha.5(esbuild@0.25.12)(rollup@4.62.2)(storybook@8.6.18(prettier@3.9.4))(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.18(prettier@3.9.4)) magic-string: 0.30.21 solid-js: 1.9.13 storybook: 8.6.18(prettier@3.9.4) storybook-solidjs: 1.0.0-beta.7(@storybook/test@8.6.14(storybook@8.6.18(prettier@3.9.4)))(solid-js@1.9.13)(storybook@8.6.18(prettier@3.9.4)) - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@storybook/test' - esbuild @@ -33043,6 +33900,12 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + super-regex@1.1.0: dependencies: function-timeout: 1.0.2 @@ -33196,6 +34059,16 @@ snapshots: - bare-abort-controller - react-native-b4a + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + terminal-link@2.1.1: dependencies: ansi-escapes: 4.3.2 @@ -33256,8 +34129,14 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + tiny-invariant@1.3.3: {} + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyclip@0.1.15: {} @@ -33293,6 +34172,10 @@ snapshots: dependencies: tldts-core: 7.4.5 + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + tmp@0.2.7: {} tmpl@1.0.5: {} @@ -33333,6 +34216,10 @@ snapshots: trough@2.2.0: {} + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -33458,6 +34345,9 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.13.1: + optional: true + type-fest@0.20.2: {} type-fest@0.21.3: {} @@ -33584,6 +34474,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.18.2: {} + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 @@ -33666,7 +34558,7 @@ snapshots: transitivePeerDependencies: - rollup - unimport@6.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + unimport@6.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: acorn: 8.17.0 escape-string-regexp: 5.0.0 @@ -33680,7 +34572,7 @@ snapshots: scule: 1.3.0 strip-literal: 3.1.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) unplugin-utils: 0.3.2 transitivePeerDependencies: - '@farmfe/core' @@ -33746,6 +34638,8 @@ snapshots: universal-user-agent@7.0.3: {} + universalify@0.1.2: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -33763,11 +34657,11 @@ snapshots: transitivePeerDependencies: - rollup - unplugin-fonts@1.4.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + unplugin-fonts@1.4.0(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: fast-glob: 3.3.3 unplugin: 2.3.5 - vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) unplugin-icons@0.19.3: dependencies: @@ -33804,7 +34698,7 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + unplugin@3.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.4 @@ -33812,7 +34706,7 @@ snapshots: optionalDependencies: esbuild: 0.28.1 rollup: 4.62.2 - vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) unrs-resolver@1.12.2: dependencies: @@ -33841,7 +34735,7 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - unstorage@1.17.5(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1): + unstorage@1.17.5(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -33854,7 +34748,7 @@ snapshots: optionalDependencies: '@planetscale/database': 1.20.1 '@vercel/functions': 3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0) - db0: 0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)) + db0: 0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)) ioredis: 5.11.1 untun@0.1.3: @@ -33962,6 +34856,8 @@ snapshots: dependencies: react: 19.2.4 + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} util@0.12.5: @@ -34049,7 +34945,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@22.20.0)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@22.20.0))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0): + vinxi@0.5.11(@planetscale/database@1.20.1)(@types/node@24.13.3)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(mysql2@3.22.5(@types/node@24.13.3))(terser@5.48.0)(xml2js@0.6.2)(yaml@2.9.0): dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -34070,7 +34966,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.13.4(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(xml2js@0.6.2) + nitropack: 2.13.4(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3))(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(xml2js@0.6.2) node-fetch-native: 1.6.7 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -34082,8 +34978,8 @@ snapshots: ufo: 1.6.4 unctx: 2.5.0 unenv: 1.10.0 - unstorage: 1.17.5(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@22.20.0)))(mysql2@3.22.5(@types/node@22.20.0)))(ioredis@5.11.1) - vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + unstorage: 1.17.5(@planetscale/database@1.20.1)(@vercel/functions@3.7.4(@aws-sdk/credential-provider-web-identity@3.972.57)(ws@8.21.0))(db0@0.3.4(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20260630.1)(@opentelemetry/api@1.9.1)(@planetscale/database@1.20.1)(bun-types@1.3.14)(mysql2@3.22.5(@types/node@24.13.3)))(mysql2@3.22.5(@types/node@24.13.3)))(ioredis@5.11.1) + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) zod: 4.4.3 transitivePeerDependencies: - '@azure/app-configuration' @@ -34137,13 +35033,13 @@ snapshots: - xml2js - yaml - vite-node@2.1.9(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0): + vite-node@2.1.9(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 5.4.21(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0) transitivePeerDependencies: - '@types/node' - less @@ -34176,13 +35072,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -34197,13 +35093,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -34218,7 +35114,7 @@ snapshots: - tsx - yaml - vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.5.0)(solid-js@1.9.13)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 @@ -34226,27 +35122,27 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.13 solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vitefu: 1.1.3(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) optionalDependencies: '@testing-library/jest-dom': 6.5.0 transitivePeerDependencies: - supports-color - vite-plugin-top-level-await@1.6.0(@swc/helpers@0.5.23)(rollup@4.62.2)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-top-level-await@1.6.0(@swc/helpers@0.5.23)(rollup@4.62.2)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@rollup/plugin-virtual': 3.0.2(rollup@4.62.2) '@swc/core': 1.15.43(@swc/helpers@0.5.23) '@swc/wasm': 1.15.43 uuid: 10.0.0 - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@swc/helpers' - rollup - vite-plugin-wasm@3.6.0(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-wasm@3.6.0(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@6.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: @@ -34259,24 +35155,24 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0): + vite@5.4.21(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0): dependencies: esbuild: 0.21.5 postcss: 8.5.16 rollup: 4.62.2 optionalDependencies: - '@types/node': 22.20.0 + '@types/node': 24.13.3 fsevents: 2.3.3 lightningcss: 1.32.0 terser: 5.48.0 @@ -34297,7 +35193,7 @@ snapshots: terser: 5.48.0 yaml: 2.9.0 - vite@6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -34306,14 +35202,14 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 22.20.0 + '@types/node': 24.13.3 fsevents: 2.3.3 jiti: 1.21.7 lightningcss: 1.32.0 terser: 5.48.0 yaml: 2.9.0 - vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -34322,14 +35218,14 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 22.20.0 + '@types/node': 24.13.3 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.48.0 yaml: 2.9.0 - vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -34338,21 +35234,21 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 22.20.0 + '@types/node': 24.13.3 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.48.0 yaml: 2.9.0 - vitefu@1.1.3(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vitefu@1.1.3(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): optionalDependencies: - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vitest@2.1.9(@types/node@22.20.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0): + vitest@2.1.9(@types/node@24.13.3)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -34368,11 +35264,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0) - vite-node: 2.1.9(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 5.4.21(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0) + vite-node: 2.1.9(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.48.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.20.0 + '@types/node': 24.13.3 jsdom: 26.1.0 transitivePeerDependencies: - less @@ -34429,11 +35325,11 @@ snapshots: - tsx - yaml - vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.20.0)(@vitest/ui@3.2.6)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vitest@3.2.6(@types/debug@4.1.13)(@types/node@24.13.3)(@vitest/ui@3.2.6)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.6(vite@6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -34451,12 +35347,12 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@22.20.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 - '@types/node': 22.20.0 + '@types/node': 24.13.3 '@vitest/ui': 3.2.6(vitest@3.2.6) jsdom: 26.1.0 transitivePeerDependencies: @@ -34473,11 +35369,11 @@ snapshots: - tsx - yaml - vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.20.0)(@vitest/ui@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vitest@3.2.6(@types/debug@4.1.13)(@types/node@24.13.3)(@vitest/ui@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.6(vite@6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -34495,12 +35391,12 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 6.3.5(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 - '@types/node': 22.20.0 + '@types/node': 24.13.3 '@vitest/ui': 3.2.6(vitest@3.2.6) jsdom: 26.1.0 transitivePeerDependencies: @@ -34523,6 +35419,17 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + wait-on@9.0.1: + dependencies: + axios: 1.19.0 + joi: 18.2.3 + lodash: 4.18.1 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + - supports-color + walk-up-path@4.0.0: {} walker@1.0.8: @@ -34702,6 +35609,10 @@ snapshots: dependencies: isexe: 3.1.5 + which@5.0.0: + dependencies: + isexe: 3.1.5 + which@6.0.1: dependencies: isexe: 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e721379fd7..591f70404e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,6 @@ packages: - "apps/*" - "packages/*" - - "crates/tauri-plugin-*" - "infra" - "scripts/orgIdBackfill" diff --git a/scripts/build-desktop-binaries.mjs b/scripts/build-desktop-binaries.mjs index 66af8651a33..23a0de38670 100644 --- a/scripts/build-desktop-binaries.mjs +++ b/scripts/build-desktop-binaries.mjs @@ -9,7 +9,7 @@ const binariesDir = path.join( repoRoot, "apps", "desktop", - "src-tauri", + "src-backend", "binaries", ); @@ -61,6 +61,43 @@ async function isUpToDate(destPath, sourcePaths) { return true; } +async function unlinkIfExists(targetPath) { + await fs.unlink(targetPath).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); +} + +async function stageBuiltBinaries(src, dests, target) { + await fs.copyFile(src, dests[0]); + console.log(`Copied ${src} -> ${dests[0]}`); + + for (const dest of dests.slice(1)) { + if (target.includes("windows")) { + await fs.copyFile(src, dest); + console.log(`Copied ${src} -> ${dest}`); + continue; + } + + await unlinkIfExists(dest); + const linkTarget = path.basename(dests[0]); + await fs.symlink(linkTarget, dest); + console.log(`Linked ${dest} -> ${linkTarget}`); + } +} + +async function canonicalizeDuplicateBinaries(dests, target) { + if (target.includes("windows") || dests.length < 2) return; + + const linkTarget = path.basename(dests[0]); + for (const dest of dests.slice(1)) { + const existingTarget = await fs.readlink(dest).catch(() => null); + if (existingTarget === linkTarget) continue; + await unlinkIfExists(dest); + await fs.symlink(linkTarget, dest); + console.log(`Linked ${dest} -> ${linkTarget}`); + } +} + async function main() { const target = process.argv[2] || process.env.RUST_TARGET_TRIPLE || detectHostTriple(); @@ -69,6 +106,19 @@ async function main() { await fs.mkdir(binariesDir, { recursive: true }); for (const sidecar of [ + { + packageName: "cap-desktop", + sourceBinary: "cap-desktop", + destBinaries: ["cap-desktop"], + watchPaths: [ + path.join(repoRoot, "apps", "desktop", "src-backend"), + path.join(repoRoot, "crates", "desktop-runtime"), + path.join(repoRoot, "crates", "desktop-runtime-macros"), + path.join(repoRoot, "crates", "editor"), + path.join(repoRoot, "crates", "rendering"), + path.join(repoRoot, "Cargo.lock"), + ], + }, { packageName: "cap-muxer", sourceBinary: "cap-muxer", @@ -99,6 +149,19 @@ async function main() { ]) { await buildSidecar(sidecar, target, ext); } + + if (target.includes("windows")) await stageWindowsRuntimeLibraries(); +} + +async function stageWindowsRuntimeLibraries() { + const releaseDir = path.join(repoRoot, "target", "release"); + for (const name of await fs.readdir(releaseDir)) { + if (!name.toLowerCase().endsWith(".dll")) continue; + await fs.copyFile( + path.join(releaseDir, name), + path.join(binariesDir, name), + ); + } } async function buildSidecar(sidecar, target, ext) { @@ -120,6 +183,7 @@ async function buildSidecar(sidecar, target, ext) { ) ).every(Boolean) ) { + await canonicalizeDuplicateBinaries(dests, target); console.log( `${sidecar.destBinaries.join(", ")} desktop binaries up to date`, ); @@ -143,10 +207,7 @@ async function buildSidecar(sidecar, target, ext) { process.exit(1); } - for (const dest of dests) { - await fs.copyFile(src, dest); - console.log(`Copied ${src} -> ${dest}`); - } + await stageBuiltBinaries(src, dests, target); } main().catch((err) => { diff --git a/scripts/build-desktop-binaries.sh b/scripts/build-desktop-binaries.sh index ee08300f9db..a767b94cfff 100755 --- a/scripts/build-desktop-binaries.sh +++ b/scripts/build-desktop-binaries.sh @@ -7,7 +7,7 @@ if [[ -z "$TARGET" ]]; then fi REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BINARIES_DIR="$REPO_ROOT/apps/desktop/src-tauri/binaries" +BINARIES_DIR="$REPO_ROOT/apps/desktop/src-backend/binaries" EXT="" case "$TARGET" in @@ -39,5 +39,10 @@ build_sidecar() { done } +build_sidecar "cap-desktop" "cap-desktop" "cap-desktop" build_sidecar "cap-muxer" "cap-muxer" "cap-muxer" build_sidecar "cap" "cap" "cap-cli" "cap-exporter" + +if [[ "$TARGET" == *windows* ]]; then + find "$REPO_ROOT/target/release" -maxdepth 1 -type f -name '*.dll' -exec cp {} "$BINARIES_DIR/" \; +fi diff --git a/scripts/check-tauri-plugin-versions.js b/scripts/check-tauri-plugin-versions.js deleted file mode 100755 index 80fae557904..00000000000 --- a/scripts/check-tauri-plugin-versions.js +++ /dev/null @@ -1,337 +0,0 @@ -#!/usr/bin/env node - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Check for debug flag -const DEBUG = process.argv.includes("--debug") || process.env.DEBUG === "true"; - -function debug(message) { - if (DEBUG) { - console.log(`[DEBUG] ${message}`); - } -} - -// Helper function to parse semantic version -function parseVersion(version) { - const match = version.match(/^(\d+)\.(\d+)\.(\d+)/); - if (!match) { - throw new Error(`Invalid version format: ${version}`); - } - return { - major: parseInt(match[1], 10), - minor: parseInt(match[2], 10), - patch: parseInt(match[3], 10), - full: version, - }; -} - -// Parse pnpm-lock.yaml to extract Tauri plugin versions -function parsePnpmLock(lockfilePath) { - const content = fs.readFileSync(lockfilePath, "utf8"); - const plugins = {}; - - const lines = content.split("\n"); - let currentPlugin = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Look for @tauri-apps/plugin- packages (with or without quotes) - const pluginMatch = line.match( - /^\s*['"]?(@tauri-apps\/plugin-[^'":\s]+)['"]?:\s*$/, - ); - if (pluginMatch) { - currentPlugin = pluginMatch[1]; - debug(`Found JS plugin: ${currentPlugin} at line ${i + 1}`); - continue; - } - - // Look for version when we're tracking a plugin - if (currentPlugin) { - const versionMatch = line.match(/^\s*version:\s*['"]?([^'"]+)['"]?\s*$/); - if (versionMatch) { - plugins[currentPlugin] = versionMatch[1]; - debug(` Version: ${versionMatch[1]}`); - currentPlugin = null; - } - - // Reset if we hit another top-level key - if ( - line.match(/^\s*[^'":\s]+:\s*$/) && - !line.match(/^\s*(specifier|version):/) - ) { - currentPlugin = null; - } - } - - // Also check for inline version specifications in dependency lists - const inlineMatch = line.match(/@tauri-apps\/plugin-([^@]+)@([^)]+)/); - if (inlineMatch) { - const pluginName = inlineMatch[1]; - const version = inlineMatch[2]; - plugins[`@tauri-apps/plugin-${pluginName}`] = version; - debug( - `Found inline JS plugin: @tauri-apps/plugin-${pluginName}@${version}`, - ); - } - } - - return plugins; -} - -// Parse Cargo.lock to extract Tauri plugin versions -function parseCargoLock(lockfilePath) { - const content = fs.readFileSync(lockfilePath, "utf8"); - const plugins = {}; - - const lines = content.split("\n"); - let currentPackage = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Look for package declarations - const packageMatch = line.match(/^name = "tauri-plugin-(.+)"$/); - if (packageMatch) { - currentPackage = `tauri-plugin-${packageMatch[1]}`; - debug(`Found Rust plugin: ${currentPackage} at line ${i + 1}`); - continue; - } - - // Look for version when we're in a tauri-plugin package - if (currentPackage && line.match(/^version = "(.+)"$/)) { - const versionMatch = line.match(/^version = "(.+)"$/); - if (versionMatch) { - plugins[currentPackage] = versionMatch[1]; - debug(` Version: ${versionMatch[1]}`); - currentPackage = null; - } - } - - // Reset when we hit a new [[package]] block - if (line.match(/^\[\[package\]\]$/)) { - currentPackage = null; - } - } - - return plugins; -} - -// Map plugin names between JS and Rust -function mapPluginNames(jsName, rustPlugins) { - // Convert @tauri-apps/plugin-clipboard-manager to tauri-plugin-clipboard-manager - const rustName = jsName.replace("@tauri-apps/plugin-", "tauri-plugin-"); - return rustPlugins[rustName] ? rustName : null; -} - -// Compare versions and report mismatches -function compareVersions(jsPlugins, rustPlugins) { - const results = { - matching: [], - mismatched: [], - jsOnly: [], - rustOnly: [], - }; - - // Check JS plugins against Rust plugins - for (const [jsName, jsVersion] of Object.entries(jsPlugins)) { - const rustName = mapPluginNames(jsName, rustPlugins); - - if (!rustName) { - results.jsOnly.push({ name: jsName, version: jsVersion }); - continue; - } - - const rustVersion = rustPlugins[rustName]; - - try { - const jsParsed = parseVersion(jsVersion); - const rustParsed = parseVersion(rustVersion); - - if ( - jsParsed.major === rustParsed.major && - jsParsed.minor === rustParsed.minor - ) { - results.matching.push({ - jsName, - rustName, - jsVersion, - rustVersion, - majorMinor: `${jsParsed.major}.${jsParsed.minor}`, - }); - } else { - results.mismatched.push({ - jsName, - rustName, - jsVersion, - rustVersion, - jsMajorMinor: `${jsParsed.major}.${jsParsed.minor}`, - rustMajorMinor: `${rustParsed.major}.${rustParsed.minor}`, - }); - } - } catch (error) { - console.warn( - `Warning: Could not parse versions for ${jsName} (${jsVersion}) / ${rustName} (${rustVersion}): ${error.message}`, - ); - } - } - - // Check for Rust-only plugins - for (const [rustName, rustVersion] of Object.entries(rustPlugins)) { - const jsName = `@tauri-apps/plugin-${rustName.replace("tauri-plugin-", "")}`; - if (!jsPlugins[jsName]) { - results.rustOnly.push({ name: rustName, version: rustVersion }); - } - } - - return results; -} - -// Main function -function main() { - const rootDir = path.resolve(__dirname, ".."); - const pnpmLockPath = path.join(rootDir, "pnpm-lock.yaml"); - const cargoLockPath = path.join(rootDir, "Cargo.lock"); - - // Check if files exist - if (!fs.existsSync(pnpmLockPath)) { - console.error(`❌ Error: pnpm-lock.yaml not found at ${pnpmLockPath}`); - console.error("Please run this script from the project root directory."); - process.exit(1); - } - - if (!fs.existsSync(cargoLockPath)) { - console.error(`❌ Error: Cargo.lock not found at ${cargoLockPath}`); - console.error("Please run this script from the project root directory."); - process.exit(1); - } - - debug(`Reading pnpm lockfile: ${pnpmLockPath}`); - debug(`Reading Cargo lockfile: ${cargoLockPath}`); - - console.log("🔍 Checking Tauri plugin version consistency...\n"); - - try { - // Parse both lockfiles - const jsPlugins = parsePnpmLock(pnpmLockPath); - const rustPlugins = parseCargoLock(cargoLockPath); - - console.log(`Found ${Object.keys(jsPlugins).length} JS Tauri plugins`); - console.log( - `Found ${Object.keys(rustPlugins).length} Rust Tauri plugins\n`, - ); - - if (DEBUG) { - console.log("JS Plugins found:"); - Object.entries(jsPlugins).forEach(([name, version]) => { - console.log(` ${name}@${version}`); - }); - console.log("\nRust Plugins found:"); - Object.entries(rustPlugins).forEach(([name, version]) => { - console.log(` ${name}@${version}`); - }); - console.log(); - } - - // Compare versions - const results = compareVersions(jsPlugins, rustPlugins); - - // Report results - if (results.matching.length > 0) { - console.log("✅ Matching versions (major.minor):"); - results.matching.forEach( - ({ jsName, rustName, jsVersion, rustVersion, majorMinor }) => { - console.log( - ` ${majorMinor}: ${jsName}@${jsVersion} ↔ ${rustName}@${rustVersion}`, - ); - }, - ); - console.log(); - } - - let hasErrors = false; - - if (results.mismatched.length > 0) { - hasErrors = true; - console.log("❌ Version mismatches (major.minor):"); - results.mismatched.forEach( - ({ - jsName, - rustName, - jsVersion, - rustVersion, - jsMajorMinor, - rustMajorMinor, - }) => { - console.log( - ` ${jsName}@${jsVersion} (${jsMajorMinor}) ↔ ${rustName}@${rustVersion} (${rustMajorMinor})`, - ); - }, - ); - console.log(); - } - - if (results.jsOnly.length > 0) { - console.log("⚠️ JS-only plugins (no Rust equivalent found):"); - results.jsOnly.forEach(({ name, version }) => { - console.log(` ${name}@${version}`); - }); - console.log(); - } - - if (results.rustOnly.length > 0) { - console.log("⚠️ Rust-only plugins (no JS equivalent found):"); - results.rustOnly.forEach(({ name, version }) => { - console.log(` ${name}@${version}`); - }); - console.log(); - } - - // Summary - if (hasErrors) { - console.log("💥 Version consistency check failed!"); - console.log( - "Please ensure that Tauri plugins have matching major.minor versions between package.json and Cargo.toml", - ); - process.exit(1); - } else { - console.log("🎉 All Tauri plugin versions are consistent!"); - } - } catch (error) { - console.error(`❌ Error: ${error.message}`); - if (DEBUG) { - console.error("Stack trace:", error.stack); - } - process.exit(1); - } -} - -// Show usage if help flag is passed -if (process.argv.includes("--help") || process.argv.includes("-h")) { - console.log(` -Usage: node check-tauri-plugin-versions.js [options] - -Options: - --debug Enable debug logging - --help Show this help message - -Description: - Checks that all @tauri-apps/plugin-* packages have matching major.minor - versions with their corresponding tauri-plugin-* crates. - - The script reads pnpm-lock.yaml and Cargo.lock from the project root - and compares versions to ensure compatibility. - -Exit codes: - 0 - All versions match - 1 - Version mismatches found or error occurred -`); - process.exit(0); -} - -main(); diff --git a/scripts/prepare-electron-release-assets.mjs b/scripts/prepare-electron-release-assets.mjs new file mode 100644 index 00000000000..9d3a20ab9c3 --- /dev/null +++ b/scripts/prepare-electron-release-assets.mjs @@ -0,0 +1,139 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse, stringify } from "yaml"; + +const TARGETS = [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", +]; +const RELEASE_ASSET_PATTERN = /\.(?:deb|dmg|exe|yml|zip)$/i; +const MAC_MANIFEST_PATTERN = /-mac\.yml$/i; +const REQUIRED_PATTERNS = new Map([ + ["x86_64-apple-darwin", [/\.dmg$/i, /\.zip$/i, MAC_MANIFEST_PATTERN]], + ["aarch64-apple-darwin", [/\.dmg$/i, /\.zip$/i, MAC_MANIFEST_PATTERN]], + ["x86_64-pc-windows-msvc", [/\.exe$/i, /\.yml$/i]], + ["x86_64-unknown-linux-gnu", [/\.deb$/i, /\.yml$/i]], +]); + +function parseManifest(contents, source) { + const manifest = parse(contents); + if (!manifest || typeof manifest !== "object") + throw new Error(`${source} is not a YAML object`); + if (typeof manifest.version !== "string" || !manifest.version) + throw new Error(`${source} has no version`); + if (!Array.isArray(manifest.files) || manifest.files.length === 0) + throw new Error(`${source} has no update files`); + for (const file of manifest.files) { + if ( + !file || + typeof file !== "object" || + typeof file.url !== "string" || + typeof file.sha512 !== "string" + ) + throw new Error(`${source} contains an invalid update file`); + } + return manifest; +} + +export function mergeMacManifests(x64Contents, arm64Contents) { + const x64 = parseManifest(x64Contents, "x64 macOS manifest"); + const arm64 = parseManifest(arm64Contents, "arm64 macOS manifest"); + if (x64.version !== arm64.version) + throw new Error( + `macOS manifest versions differ: ${x64.version} vs ${arm64.version}`, + ); + + const files = [...x64.files, ...arm64.files]; + const urls = new Set(files.map((file) => file.url)); + if (urls.size !== files.length) + throw new Error("macOS manifests contain duplicate update URLs"); + if (![...urls].some((url) => url.includes("arm64"))) + throw new Error("arm64 macOS manifest has no arm64 artifact"); + if (![...urls].some((url) => !url.includes("arm64"))) + throw new Error("x64 macOS manifest has no x64 artifact"); + + return stringify( + { + ...x64, + files, + }, + { lineWidth: 0 }, + ); +} + +export async function prepareReleaseAssets(inputDir, outputDir) { + await fs.rm(outputDir, { recursive: true, force: true }); + await fs.mkdir(outputDir, { recursive: true }); + + const copiedNames = new Set(); + const macManifests = new Map(); + for (const target of TARGETS) { + const artifactDir = path.join(inputDir, `release-assets-${target}`); + const entries = await fs.readdir(artifactDir, { withFileTypes: true }); + const assets = entries.filter( + (entry) => entry.isFile() && RELEASE_ASSET_PATTERN.test(entry.name), + ); + if (assets.length === 0) + throw new Error(`No release assets found for ${target}`); + for (const requiredPattern of REQUIRED_PATTERNS.get(target) ?? []) { + if (!assets.some((asset) => requiredPattern.test(asset.name))) + throw new Error( + `Release assets for ${target} are missing ${requiredPattern}`, + ); + } + + for (const asset of assets) { + const source = path.join(artifactDir, asset.name); + if ((await fs.stat(source)).size === 0) + throw new Error(`Release asset is empty: ${asset.name}`); + if ( + target.endsWith("apple-darwin") && + MAC_MANIFEST_PATTERN.test(asset.name) + ) { + macManifests.set(target, { name: asset.name, source }); + continue; + } + if (copiedNames.has(asset.name)) + throw new Error(`Duplicate release asset name: ${asset.name}`); + copiedNames.add(asset.name); + await fs.copyFile(source, path.join(outputDir, asset.name)); + } + } + + const x64Manifest = macManifests.get("x86_64-apple-darwin"); + const arm64Manifest = macManifests.get("aarch64-apple-darwin"); + if (!x64Manifest || !arm64Manifest) + throw new Error("Both macOS updater manifests are required"); + if (x64Manifest.name !== arm64Manifest.name) + throw new Error( + `macOS manifest names differ: ${x64Manifest.name} vs ${arm64Manifest.name}`, + ); + + const mergedManifest = mergeMacManifests( + await fs.readFile(x64Manifest.source, "utf8"), + await fs.readFile(arm64Manifest.source, "utf8"), + ); + await fs.writeFile( + path.join(outputDir, x64Manifest.name), + mergedManifest, + "utf8", + ); + + return (await fs.readdir(outputDir)).sort(); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const [, , inputDir, outputDir] = process.argv; + if (!inputDir || !outputDir) + throw new Error( + "Usage: node scripts/prepare-electron-release-assets.mjs ", + ); + const assets = await prepareReleaseAssets(inputDir, outputDir); + console.log(`Prepared ${assets.length} release assets: ${assets.join(", ")}`); +} diff --git a/scripts/prepare-electron-release-assets.test.mjs b/scripts/prepare-electron-release-assets.test.mjs new file mode 100644 index 00000000000..6e1ed8b8601 --- /dev/null +++ b/scripts/prepare-electron-release-assets.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { afterEach, test } from "node:test"; +import { parse, stringify } from "yaml"; +import { + mergeMacManifests, + prepareReleaseAssets, +} from "./prepare-electron-release-assets.mjs"; + +const tempDirs = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); + +function manifest(url, sha512) { + return stringify({ + version: "1.2.3", + files: [{ url, sha512, size: 42 }], + path: url, + sha512, + releaseDate: "2026-08-13T00:00:00.000Z", + }); +} + +test("merges macOS updater files while retaining x64 legacy metadata", () => { + const merged = parse( + mergeMacManifests( + manifest("Cap-1.2.3-mac.zip", "x64-hash"), + manifest("Cap-1.2.3-arm64-mac.zip", "arm64-hash"), + ), + ); + + assert.equal(merged.path, "Cap-1.2.3-mac.zip"); + assert.equal(merged.sha512, "x64-hash"); + assert.deepEqual( + merged.files.map((file) => file.url), + ["Cap-1.2.3-mac.zip", "Cap-1.2.3-arm64-mac.zip"], + ); +}); + +test("assembles unique platform assets and one merged macOS manifest", async () => { + const root = await mkdtemp(path.join(tmpdir(), "cap-release-assets-")); + tempDirs.push(root); + const input = path.join(root, "input"); + const output = path.join(root, "output"); + const fixtures = { + "x86_64-apple-darwin": { + "Cap-1.2.3.dmg": "dmg-x64", + "Cap-1.2.3-mac.zip": "zip-x64", + "latest-mac.yml": manifest("Cap-1.2.3-mac.zip", "x64-hash"), + }, + "aarch64-apple-darwin": { + "Cap-1.2.3-arm64.dmg": "dmg-arm64", + "Cap-1.2.3-arm64-mac.zip": "zip-arm64", + "latest-mac.yml": manifest("Cap-1.2.3-arm64-mac.zip", "arm64-hash"), + }, + "x86_64-pc-windows-msvc": { + "Cap-Setup-1.2.3.exe": "exe", + "latest.yml": "version: 1.2.3\n", + }, + "x86_64-unknown-linux-gnu": { + "Cap-1.2.3.deb": "deb", + "latest-linux.yml": "version: 1.2.3\n", + }, + }; + + for (const [target, files] of Object.entries(fixtures)) { + const dir = path.join(input, `release-assets-${target}`); + await mkdir(dir, { recursive: true }); + await Promise.all( + Object.entries(files).map(([name, contents]) => + writeFile(path.join(dir, name), contents), + ), + ); + } + + const assets = await prepareReleaseAssets(input, output); + assert.deepEqual(assets, [ + "Cap-1.2.3-arm64-mac.zip", + "Cap-1.2.3-arm64.dmg", + "Cap-1.2.3-mac.zip", + "Cap-1.2.3.deb", + "Cap-1.2.3.dmg", + "Cap-Setup-1.2.3.exe", + "latest-linux.yml", + "latest-mac.yml", + "latest.yml", + ]); + const merged = parse( + await readFile(path.join(output, "latest-mac.yml"), "utf8"), + ); + assert.equal(merged.files.length, 2); +}); diff --git a/scripts/setup.js b/scripts/setup.js index 9b42839ea77..f1f570238ba 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -28,6 +28,26 @@ function cargoConfigPath(value) { return value.replaceAll("\\", "/"); } +async function fetchBytes(url, attempts = 3) { + let lastError; + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const response = await fetch(url); + if (!response.ok) + throw new Error(`Download failed with HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + lastError = error; + if (attempt === attempts) break; + console.warn(`Download attempt ${attempt} failed; retrying...`); + await new Promise((resolve) => setTimeout(resolve, attempt * 2_000)); + } + } + + throw lastError; +} + async function main() { await fs.mkdir(targetDir, { recursive: true }); @@ -66,10 +86,10 @@ async function main() { if (!(await fileExists(nativeDepsTarPath))) { console.log(`Downloading ${nativeDepsTar}`); - const nativeDepsBytes = await fetch(`${NATIVE_DEPS_URL}/${nativeDepsTar}`) - .then((r) => r.blob()) - .then((b) => b.arrayBuffer()); - await fs.writeFile(nativeDepsTarPath, Buffer.from(nativeDepsBytes)); + const nativeDepsBytes = await fetchBytes( + `${NATIVE_DEPS_URL}/${nativeDepsTar}`, + ); + await fs.writeFile(nativeDepsTarPath, nativeDepsBytes); console.log("Downloaded native deps"); downloadedNativeDeps = true; } else console.log(`Using cached ${nativeDepsTar}`); @@ -143,10 +163,8 @@ async function main() { const ffmpegZip = `ffmpeg-${FFMPEG_VERSION}.zip`; const ffmpegZipPath = path.join(targetDir, ffmpegZip); if (!(await fileExists(ffmpegZipPath))) { - const ffmpegZipBytes = await fetch(FFMPEG_ZIP_URL) - .then((r) => r.blob()) - .then((b) => b.arrayBuffer()); - await fs.writeFile(ffmpegZipPath, Buffer.from(ffmpegZipBytes)); + const ffmpegZipBytes = await fetchBytes(FFMPEG_ZIP_URL); + await fs.writeFile(ffmpegZipPath, ffmpegZipBytes); console.log(`Downloaded ${ffmpegZip}`); downloadedFfmpeg = true; } else console.log(`Using cached ${ffmpegZip}`); @@ -237,10 +255,8 @@ async function main() { let downloadedNativeDeps = false; if (!(await fileExists(nativeDepsTarPath))) { console.log(`Downloading ${nativeDepsTar}`); - const bytes = await fetch(`${NATIVE_DEPS_URL}/${nativeDepsTar}`) - .then((r) => r.blob()) - .then((b) => b.arrayBuffer()); - await fs.writeFile(nativeDepsTarPath, Buffer.from(bytes)); + const bytes = await fetchBytes(`${NATIVE_DEPS_URL}/${nativeDepsTar}`); + await fs.writeFile(nativeDepsTarPath, bytes); console.log("Downloaded native deps"); downloadedNativeDeps = true; } else console.log(`Using cached ${nativeDepsTar}`); @@ -279,7 +295,6 @@ async function main() { console.log( `Staged ${sonameLibs.length} FFmpeg shared libraries for Linux bundling`, ); - await writeLinuxTauriConfig(sonameLibs); cargoConfigContents += `\n[target.${triple}]\nrustflags = ["-C", "link-arg=-Wl,-rpath,$ORIGIN", "-C", "link-arg=-Wl,-rpath,$ORIGIN/../lib/cap"]\n`; } @@ -386,10 +401,8 @@ async function setupMacOSOnnxRuntime() { if (!(await fileExists(archivePath))) { console.log(`Downloading ${asset.name}`); - const bytes = await fetch(url) - .then((r) => r.blob()) - .then((b) => b.arrayBuffer()); - await fs.writeFile(archivePath, Buffer.from(bytes)); + const bytes = await fetchBytes(url); + await fs.writeFile(archivePath, bytes); console.log(`Downloaded ${asset.name}`); } else console.log(`Using cached ${asset.name}`); @@ -440,10 +453,8 @@ async function setupWindowsOnnxRuntime() { if (!(await fileExists(archivePath))) { console.log(`Downloading ${asset.name}`); - const bytes = await fetch(url) - .then((r) => r.blob()) - .then((b) => b.arrayBuffer()); - await fs.writeFile(archivePath, Buffer.from(bytes)); + const bytes = await fetchBytes(url); + await fs.writeFile(archivePath, bytes); console.log(`Downloaded ${asset.name}`); } else console.log(`Using cached ${asset.name}`); @@ -512,30 +523,6 @@ async function fileExists(path) { .catch(() => false); } -async function writeLinuxTauriConfig(sonameLibs) { - const configPath = path.join( - __root, - "apps", - "desktop", - "src-tauri", - "tauri.linux.conf.json", - ); - const files = {}; - - for (const name of sonameLibs.toSorted()) { - files[`/usr/lib/cap/${name}`] = - `../../../target/native-deps/cap-deb-libs/${name}`; - } - - await writeFileIfChanged( - configPath, - `${JSON.stringify({ bundle: { linux: { deb: { files } } } }, null, "\t")}\n`, - ); - console.log( - `Generated Linux Tauri deb config with ${sonameLibs.length} shared libraries`, - ); -} - async function missingFiles(dir, names) { if (!(await fileExists(dir))) return names; diff --git a/scripts/verify-recording-pipeline.mjs b/scripts/verify-recording-pipeline.mjs index e261f42abf9..9d2e4b7af5a 100644 --- a/scripts/verify-recording-pipeline.mjs +++ b/scripts/verify-recording-pipeline.mjs @@ -41,7 +41,7 @@ const desktopBundleId = const signDesktopApp = process.env.CAP_VERIFY_SIGN_DESKTOP !== "0"; const desktopEntitlementsPath = process.env.CAP_VERIFY_DESKTOP_ENTITLEMENTS ? path.resolve(process.env.CAP_VERIFY_DESKTOP_ENTITLEMENTS) - : path.join(root, "apps", "desktop", "src-tauri", "Entitlements.plist"); + : path.join(root, "apps", "desktop", "src-backend", "Entitlements.plist"); const desktopLogDirs = ["so.cap.desktop.dev", "so.cap.desktop"].map((name) => path.join(os.homedir(), "Library", "Logs", name), ); diff --git a/turbo.json b/turbo.json index bf4056ab19c..1085a2f729a 100644 --- a/turbo.json +++ b/turbo.json @@ -4,7 +4,7 @@ "globalEnv": ["*"], "tasks": { "build": { - "inputs": ["**/*.ts", "**/*.tsx", "!src-tauri/**", "!node_modules/**"], + "inputs": ["**/*.ts", "**/*.tsx", "!src-backend/**", "!node_modules/**"], "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, @@ -32,12 +32,12 @@ "outputs": ["dist/**"] }, "build:web:docker": { - "inputs": ["**/*.ts", "**/*.tsx", "!src-tauri/**", "!node_modules/**"], + "inputs": ["**/*.ts", "**/*.tsx", "!src-backend/**", "!node_modules/**"], "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "build:web": { - "inputs": ["**/*.ts", "**/*.tsx", "!src-tauri/**", "!node_modules/**"], + "inputs": ["**/*.ts", "**/*.tsx", "!src-backend/**", "!node_modules/**"], "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] },