diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f42a92d..1f85736 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,18 +20,15 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 - + - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '22' - + - name: Install dependencies run: npm ci - - - name: Install app dependencies - run: npx electron-builder install-app-deps - + - name: Build Windows app run: npm run build:win env: @@ -234,8 +231,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Install app dependencies - run: npx electron-builder install-app-deps + # bsdtar (from libarchive-tools) is required by fpm to build pacman + # packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it. + - name: Install pacman build dependencies + run: sudo apt-get update && sudo apt-get install -y libarchive-tools - name: Build Linux app run: npm run build:linux @@ -250,4 +249,5 @@ jobs: release/**/*.AppImage release/**/*.zsync release/**/*.deb + release/**/*.pacman retention-days: 30 diff --git a/.github/workflows/bump-nix-package.yml b/.github/workflows/bump-nix-package.yml new file mode 100644 index 0000000..5ff3c73 --- /dev/null +++ b/.github/workflows/bump-nix-package.yml @@ -0,0 +1,118 @@ +name: Bump Nix package on release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to bump (e.g. v1.5.0)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + BRANCH="chore/bump-nix-${VERSION}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Compute npmDepsHash + id: hash + run: | + set -euo pipefail + HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json) + if [[ -z "$HASH" ]]; then + echo "::error::prefetch-npm-deps returned an empty hash" + exit 1 + fi + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "Computed npmDepsHash: $HASH" + + - name: Update nix/package.nix + env: + VERSION: ${{ steps.meta.outputs.version }} + HASH: ${{ steps.hash.outputs.hash }} + run: | + set -euo pipefail + # Update version line: ` version = "";` + sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix + # Update npmDepsHash line: ` npmDepsHash = "";` + sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix + + echo "=== diff ===" + git --no-pager diff nix/package.nix || true + + - name: Create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + HASH: ${{ steps.hash.outputs.hash }} + BRANCH: ${{ steps.meta.outputs.branch }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + + if git diff --quiet -- nix/package.nix; then + echo "nix/package.nix already at v${VERSION} with this hash — nothing to do." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Replace any prior bump branch to keep the workflow idempotent. + git push origin --delete "$BRANCH" 2>/dev/null || true + git checkout -b "$BRANCH" + git add nix/package.nix + git commit -m "chore: bump nix package to v${VERSION}" + git push -u origin "$BRANCH" + + gh pr create \ + --title "chore: bump nix package to v${VERSION}" \ + --base main \ + --head "$BRANCH" \ + --body "$(cat < Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR. + EOF + )" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4194797..3c9e8ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run test - run: npm run test:browser:install - run: npm run test:browser diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml new file mode 100644 index 0000000..62b4b7a --- /dev/null +++ b/.github/workflows/publish-winget.yml @@ -0,0 +1,26 @@ +name: Publish release to WinGet + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish to winget (e.g. v1.4.0)" + required: true + type: string + +jobs: + publish: + runs-on: windows-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + steps: + - uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: SiddharthVaddem.OpenScreen + # Match the Windows installer asset attached to each release. + # Today: "Openscreen.Setup.latest.exe". Adjust this regex if you + # ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe"). + installers-regex: 'Setup\..*\.exe$' + release-tag: ${{ inputs.tag || github.event.release.tag_name }} + token: ${{ secrets.WINGET_ACC_TOKEN }} diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml new file mode 100644 index 0000000..3d65cb0 --- /dev/null +++ b/.github/workflows/update-homebrew-cask.yml @@ -0,0 +1,168 @@ +name: Update Homebrew Cask + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish to the tap (e.g. v1.4.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + update-cask: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + env: + TAP_OWNER: siddharthvaddem + TAP_REPO: homebrew-openscreen + CASK_NAME: openscreen + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Find macOS DMG assets + id: assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name') + + # arm64 DMG: explicit "arm64" / "apple silicon" / fallback to any .dmg + # whose name does NOT contain "x64" or non-mac platform markers. + ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iE '(arm64|apple[-_. ]?silicon)' | head -n1 || true) + if [[ -z "$ARM_NAME" ]]; then + ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iv 'x64' | grep -iv 'linux' | grep -iv 'win' | head -n1 || true) + fi + + # x64 DMG + X64_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iE '(x64|x86[-_]?64|intel)' | head -n1 || true) + + if [[ -z "$ARM_NAME" || -z "$X64_NAME" ]]; then + echo "::error::Could not locate both arm64 and x64 DMGs in release assets" + echo "Available assets:" + echo "$NAMES" + exit 1 + fi + + echo "arm_name=$ARM_NAME" >> "$GITHUB_OUTPUT" + echo "x64_name=$X64_NAME" >> "$GITHUB_OUTPUT" + echo "Found arm64 asset: $ARM_NAME" + echo "Found x64 asset: $X64_NAME" + + - name: Download DMGs and compute sha256 + id: shas + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + ARM_NAME: ${{ steps.assets.outputs.arm_name }} + X64_NAME: ${{ steps.assets.outputs.x64_name }} + run: | + set -euo pipefail + BASE="https://github.com/${REPO}/releases/download/${TAG}" + curl -fsSL --retry 3 -o /tmp/arm.dmg "${BASE}/${ARM_NAME}" + curl -fsSL --retry 3 -o /tmp/x64.dmg "${BASE}/${X64_NAME}" + ARM_SHA=$(sha256sum /tmp/arm.dmg | awk '{print $1}') + X64_SHA=$(sha256sum /tmp/x64.dmg | awk '{print $1}') + echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT" + echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT" + + - name: Checkout tap + uses: actions/checkout@v4 + with: + repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPO }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Write cask file + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + ARM_NAME: ${{ steps.assets.outputs.arm_name }} + X64_NAME: ${{ steps.assets.outputs.x64_name }} + ARM_SHA: ${{ steps.shas.outputs.arm_sha }} + X64_SHA: ${{ steps.shas.outputs.x64_sha }} + run: | + set -euo pipefail + mkdir -p tap/Casks + BASE="https://github.com/${REPO}/releases/download/${TAG}" + + # #{version} is Ruby interpolation written literally to the cask + # file (bash heredoc leaves "#{...}" alone). \${VERSION}, \${ARM_SHA}, + # etc. are bash variables expanded by the heredoc. The literal + # #{version} fixes Homebrew's "URL is unversioned" audit warning by + # making the version string statically detectable. + cat > "tap/Casks/${CASK_NAME}.rb" <= :big_sur" + + app "Openscreen.app" + + zap trash: [ + "~/Library/Application Support/Openscreen", + "~/Library/Caches/com.siddharthvaddem.openscreen", + "~/Library/Logs/Openscreen", + "~/Library/Preferences/com.siddharthvaddem.openscreen.plist", + "~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState", + ] + end + EOF + + - name: Commit and push to tap + working-directory: tap + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "Casks/${CASK_NAME}.rb" + if git diff --cached --quiet; then + echo "Cask already up to date for ${VERSION} — nothing to commit." + exit 0 + fi + git commit -m "Bump ${CASK_NAME} to ${VERSION}" + git push diff --git a/README.md b/README.md index 9ed0d1a..7009a22 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ > [!WARNING] -> This is very much in beta and might be buggy here and there (but hope you have a good experience!). +> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need.

OpenScreen Logo @@ -21,11 +21,11 @@

OpenScreen is your free, open-source alternative to Screen Studio (sort of).

-If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well! +If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well! Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job! -OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !) +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it.

OpenScreen App Preview 3 @@ -33,16 +33,19 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist

## Core Features -- Record specific windows or your whole screen. -- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position. +- Record a specific window, region, or your whole screen. - Record microphone and system audio. -- Crop video recordings to hide parts. -- Choose between wallpapers, solid colors, gradients or a custom background. -- Motion blur for smoother pan and zoom effects. -- Add annotations (text, arrows, images). -- Trim sections of the clip. -- Customize the speed of different segments. -- Export in different aspect ratios and resolutions. +- Webcam overlay with picture-in-picture, drag-to-position, and shape options. +- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position. +- Wallpapers, solid colors, gradients, or a custom background. +- Motion blur for smoother pan and zoom transitions. +- Crop, trim, and per-segment speed control on the timeline. +- Blur effects to hide sensitive parts of the screen. +- Cursor and click highlighting. +- Text, arrow, and image annotations. +- Save and reopen projects without re-recording. +- Export to MP4 or GIF in multiple aspect ratios and resolutions. +- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese. ## Installation @@ -50,7 +53,20 @@ Download the latest installer for your platform from the [GitHub Releases](https ### macOS -If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation: +The easiest way to install on macOS is via [Homebrew](https://brew.sh): + +```bash +brew install --cask siddharthvaddem/openscreen/openscreen +``` + +Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it. + +To update later: `brew upgrade --cask openscreen` +To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data) + +#### Manual install (if you prefer) + +If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation: ```bash xattr -rd com.apple.quarantine /Applications/Openscreen.app @@ -60,18 +76,72 @@ Note: Give your terminal Full Disk Access in **System Settings > Privacy & Secur After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app. +### Windows + +Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): + +```bash +winget install SiddharthVaddem.OpenScreen +``` + +To update later: `winget upgrade SiddharthVaddem.OpenScreen` +To uninstall: `winget uninstall SiddharthVaddem.OpenScreen` + +If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases). + ### Linux -Download the `.AppImage` file from the releases page. Make it executable and run: +Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro: +**Debian / Ubuntu / Pop!_OS (`.deb`)** +```bash +sudo apt install ./Openscreen-Linux-latest.deb +``` + +**Arch / Manjaro (`.pacman`)** +```bash +sudo pacman -U Openscreen-Linux-latest.pacman +``` + +**Any distro (`.AppImage`)** ```bash chmod +x Openscreen-Linux-*.AppImage ./Openscreen-Linux-*.AppImage ``` +**NixOS / Nix (flake)** + +Try without installing: +```bash +nix run github:siddharthvaddem/openscreen +``` + +Install into your user profile: +```bash +nix profile install github:siddharthvaddem/openscreen +``` + +For a NixOS system config (flake): +```nix +{ + inputs.openscreen.url = "github:siddharthvaddem/openscreen"; + + outputs = { nixpkgs, openscreen, ... }: { + nixosConfigurations. = nixpkgs.lib.nixosSystem { + modules = [ + openscreen.nixosModules.default + { programs.openscreen.enable = true; } + ]; + }; + }; +} +``` + +For Home Manager, use `openscreen.homeManagerModules.default` with the same `programs.openscreen.enable = true;`. + You may need to grant screen recording permissions depending on your desktop environment. -**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox: +**Sandbox error:** If the AppImage fails to launch with a "sandbox" error, run it with `--no-sandbox`: ```bash ./Openscreen-Linux-*.AppImage --no-sandbox ``` @@ -94,16 +164,16 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron --- -_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_ ## Documentation See the documentation here: [OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen) +Refresh if outdated. ## Contributing -Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute. +Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed. ## Star History diff --git a/docs/tests/writing-tests.md b/docs/tests/writing-tests.md new file mode 100644 index 0000000..09ede7e --- /dev/null +++ b/docs/tests/writing-tests.md @@ -0,0 +1,149 @@ +# Writing Tests + +This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files. + +## Unit tests + +**Config:** `vitest.config.ts` +**Runs in:** jsdom (simulated DOM, no real browser) +**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts` +**CI command:** `npm run test` + +Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.). + +### File placement + +Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory. + +``` +src/lib/compositeLayout.ts +src/lib/compositeLayout.test.ts # co-located + +src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped +``` + +### Example + +```ts +import { describe, expect, it } from "vitest"; +import { computeCompositeLayout } from "./compositeLayout"; + +describe("computeCompositeLayout", () => { + it("anchors the overlay in the lower-right corner", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 1920, height: 1080 }, + screenSize: { width: 1920, height: 1080 }, + webcamSize: { width: 1280, height: 720 }, + }); + + expect(layout).not.toBeNull(); + expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2); + expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2); + }); +}); +``` + +### Path aliases + +The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths. + +```ts +import { SUPPORTED_LOCALES } from "@/i18n/config"; +``` + +### Running locally + +```bash +npm run test # run once +npm run test:watch # watch mode +``` + +--- + +## Browser tests + +**Config:** `vitest.browser.config.ts` +**Runs in:** real Chromium via Playwright (headless) +**File pattern:** `src/**/*.browser.test.ts` +**CI commands:** `npm run test:browser:install` then `npm run test:browser` + +Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc. + +### File placement + +Name the file `.browser.test.ts` and place it next to the source file. + +``` +src/lib/exporter/videoExporter.ts +src/lib/exporter/videoExporter.browser.test.ts +``` + +### Loading fixture assets + +Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server. + +```ts +import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +``` + +### Example + +```ts +import { describe, expect, it } from "vitest"; +import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +import { VideoExporter } from "./videoExporter"; + +describe("VideoExporter (real browser)", () => { + it("exports a valid MP4 blob from a real video", async () => { + const exporter = new VideoExporter({ + videoUrl: sampleVideoUrl, + width: 320, + height: 180, + frameRate: 15, + bitrate: 1_000_000, + wallpaper: "#1a1a2e", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }); + + const result = await exporter.export(); + + expect(result.success, result.error).toBe(true); + expect(result.blob).toBeInstanceOf(Blob); + }); +}); +``` + +### Timeouts + +Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast. + +### Running locally + +First install the browser (one-time): + +```bash +npm run test:browser:install +``` + +Then run the tests: + +```bash +npm run test:browser +``` + +--- + +## Choosing the right type + +| Situation | Use | +|---|---| +| Pure function / data transformation | Unit test | +| i18n key coverage | Unit test | +| React hook logic (no real browser APIs) | Unit test | +| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test | +| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test | +| File export producing a real `Blob` | Browser test | diff --git a/electron-builder.json5 b/electron-builder.json5 index ca053ef..372cdf7 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -3,6 +3,11 @@ "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", "appId": "com.siddharthvaddem.openscreen", "asar": true, + // .node binaries can't be dlopen'd from inside an asar — must live unpacked. + "asarUnpack": [ + "node_modules/uiohook-napi/**/*", + "**/*.node" + ], "productName": "Openscreen", "npmRebuild": true, "buildDependenciesFromSource": true, @@ -46,13 +51,15 @@ "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", - "NSCameraUseContinuityCameraDeviceType": true, - "com.apple.security.device.audio-input": true + "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", + "NSCameraUseContinuityCameraDeviceType": true } }, "linux": { "target": [ - "AppImage" + "AppImage", + "deb", + "pacman" ], "icon": "icons/icons/png", "artifactName": "${productName}-Linux-${version}.${ext}", diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..1d528cd 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -37,6 +37,11 @@ interface Window { status: string; error?: string; }>; + requestAccessibilityAccess: () => Promise<{ + success: boolean; + granted: boolean; + error?: string; + }>; assetBaseUrl: string; storeRecordedVideo: ( videoData: ArrayBuffer, @@ -68,15 +73,31 @@ interface Window { getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; + clicks: number[]; message?: string; error?: string; }>; onStopRecordingFromTray: (callback: () => void) => () => void; openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; - saveExportedVideo: ( - videoData: ArrayBuffer, + pickExportSavePath: ( fileName: string, - ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + exportFolder?: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + writeExportToPath: ( + videoData: ArrayBuffer, + filePath: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; setCurrentRecordingSession: ( @@ -136,6 +157,7 @@ interface Window { saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; hudOverlayHide: () => void; hudOverlayClose: () => void; + setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; @@ -143,7 +165,15 @@ interface Window { setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; + onRequestCloseConfirm: (callback: () => void) => () => void; + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void; setLocale: (locale: string) => Promise; + saveDiagnostic: (payload: { + error: string; + stack?: string; + projectState: unknown; + logs: string[]; + }) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>; }; } diff --git a/electron/i18n.ts b/electron/i18n.ts index 4222741..e16ac86 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -1,6 +1,8 @@ // Lightweight i18n for the Electron main process. // Imports the same JSON translation files used by the renderer. +import commonAr from "../src/i18n/locales/ar/common.json"; +import dialogsAr from "../src/i18n/locales/ar/dialogs.json"; import commonEn from "../src/i18n/locales/en/common.json"; import dialogsEn from "../src/i18n/locales/en/dialogs.json"; import commonEs from "../src/i18n/locales/es/common.json"; @@ -13,12 +15,14 @@ import commonKo from "../src/i18n/locales/ko-KR/common.json"; import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json"; import commonTr from "../src/i18n/locales/tr/common.json"; import dialogsTr from "../src/i18n/locales/tr/dialogs.json"; +import commonVi from "../src/i18n/locales/vi/common.json"; +import dialogsVi from "../src/i18n/locales/vi/dialogs.json"; import commonZh from "../src/i18n/locales/zh-CN/common.json"; import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; import commonZhTw from "../src/i18n/locales/zh-TW/common.json"; import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json"; -type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr"; +type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi"; type Namespace = "common" | "dialogs"; type MessageMap = Record; @@ -31,6 +35,8 @@ const messages: Record> = { "ja-JP": { common: commonJa, dialogs: dialogsJa }, "ko-KR": { common: commonKo, dialogs: dialogsKo }, tr: { common: commonTr, dialogs: dialogsTr }, + ar: { common: commonAr, dialogs: dialogsAr }, + vi: { common: commonVi, dialogs: dialogsVi }, }; let currentLocale: Locale = "en"; @@ -44,7 +50,9 @@ export function setMainLocale(locale: string) { locale === "fr" || locale === "ja-JP" || locale === "ko-KR" || - locale === "tr" + locale === "tr" || + locale === "ar" || + locale === "vi" ) { currentLocale = locale; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e067f59..19178be 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,6 +1,11 @@ import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; + +const nodeRequire = createRequire(import.meta.url); + import { app, BrowserWindow, @@ -56,6 +61,21 @@ function isPathAllowed(filePath: string): boolean { return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir)); } +/** + * Helper function to build dialog options with a parent window only when it's valid. + * This prevents passing stale or destroyed BrowserWindow references to dialog calls. + */ +function buildDialogOptions( + baseOptions: T, + parentWindow: BrowserWindow | null, +): T & { parent?: BrowserWindow } { + const mainWindow = parentWindow; + if (mainWindow && !mainWindow.isDestroyed()) { + return { ...baseOptions, parent: mainWindow }; + } + return baseOptions; +} + function hasAllowedImportVideoExtension(filePath: string): boolean { return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } @@ -280,19 +300,24 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { const telemetryPath = `${screenVideoPath}.cursor.json`; const pendingBatch = cursorTelemetryBuffer.takeNextBatch(); - if (pendingBatch && pendingBatch.samples.length > 0) { + const pendingClicks = takeCursorClickTimestamps(); + if ((pendingBatch && pendingBatch.samples.length > 0) || pendingClicks.length > 0) { try { await fs.writeFile( telemetryPath, JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: pendingBatch.samples }, + { + version: CURSOR_TELEMETRY_VERSION, + samples: pendingBatch?.samples ?? [], + clicks: pendingClicks, + }, null, 2, ), "utf-8", ); } catch (err) { - cursorTelemetryBuffer.prependBatch(pendingBatch); + if (pendingBatch) cursorTelemetryBuffer.prependBatch(pendingBatch); throw err; } } @@ -321,15 +346,114 @@ const cursorTelemetryBuffer = createCursorTelemetryBuffer({ maxActiveSamples: MAX_CURSOR_SAMPLES, }); +// Mouse click timestamps (macOS only — uiohook-napi behind Accessibility). +const MAX_CURSOR_CLICKS = 60 * 60 * 60; // ~1 click/sec for an hour +let cursorClickTimestampsMs: number[] = []; +let uioHookInstance: { + start: () => void; + stop: () => void; + on: (...a: unknown[]) => void; + off?: (...a: unknown[]) => void; + removeListener?: (...a: unknown[]) => void; +} | null = null; +let uioHookMouseDownHandler: ((event: { time?: number }) => void) | null = null; +let uioHookFailureLogged = false; + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function loadUioHookForClicks(): typeof uioHookInstance { + try { + // Dynamic require + try/catch so a broken native binary doesn't crash startup. + const mod = nodeRequire("uiohook-napi"); + const candidate = mod.uIOhook ?? mod.default?.uIOhook ?? mod.uiohook ?? mod.default; + if (candidate && typeof candidate.start === "function" && typeof candidate.on === "function") { + return candidate; + } + return null; + } catch (error) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn("[clickCapture] uiohook-napi unavailable:", error); + } + return null; + } +} + +function startClickCapture() { + if (process.platform !== "darwin") return; + if (uioHookInstance) return; + + // Passive check — the prompt fires from the renderer when the user toggles + // "Only on clicks" so it doesn't stack with the screen-recording prompt. + try { + if (!systemPreferences.isTrustedAccessibilityClient(false)) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn( + "[clickCapture] Accessibility permission not granted — click capture disabled.", + ); + } + return; + } + } catch { + // fall through; uiohook will fail defensively below + } + + const hook = loadUioHookForClicks(); + if (!hook) return; + + uioHookMouseDownHandler = (event) => { + const elapsed = Math.max(0, Date.now() - cursorCaptureStartTimeMs); + void event; + if (cursorClickTimestampsMs.length >= MAX_CURSOR_CLICKS) return; + cursorClickTimestampsMs.push(elapsed); + }; + + try { + hook.on("mousedown", uioHookMouseDownHandler); + hook.start(); + uioHookInstance = hook; + } catch (error) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn("[clickCapture] failed to start uiohook:", error); + } + uioHookMouseDownHandler = null; + } +} + +function stopClickCapture() { + if (!uioHookInstance) return; + try { + if (uioHookMouseDownHandler) { + if (typeof uioHookInstance.off === "function") { + uioHookInstance.off("mousedown", uioHookMouseDownHandler); + } else if (typeof uioHookInstance.removeListener === "function") { + uioHookInstance.removeListener("mousedown", uioHookMouseDownHandler); + } + } + uioHookInstance.stop(); + } catch (error) { + console.warn("[clickCapture] failed to stop uiohook:", error); + } + uioHookInstance = null; + uioHookMouseDownHandler = null; +} + +function takeCursorClickTimestamps(): number[] { + const out = cursorClickTimestampsMs; + cursorClickTimestampsMs = []; + return out; +} + function stopCursorCapture() { if (cursorCaptureInterval) { clearInterval(cursorCaptureInterval); cursorCaptureInterval = null; } + stopClickCapture(); } function sampleCursorPoint() { @@ -594,6 +718,50 @@ export function registerIpcHandlers( } }); + ipcMain.handle("request-screen-access", async () => { + if (process.platform !== "darwin") { + return { success: true, granted: true, status: "granted" }; + } + + try { + const status = systemPreferences.getMediaAccessStatus("screen"); + if (status === "granted") { + return { success: true, granted: true, status }; + } + + // Screen recording has no askForMediaAccess equivalent — the TCC prompt + // is triggered by desktopCapturer.getSources(). Fire it and return so + // the renderer can re-check status after the user responds. + if (status === "not-determined") { + desktopCapturer.getSources({ types: ["screen"] }).catch(() => { + // Ignore prompt trigger failures; the renderer will re-check status. + }); + return { success: true, granted: false, status: "not-determined" }; + } + + return { success: true, granted: false, status }; + } catch (error) { + console.error("Failed to request screen access:", error); + return { success: false, granted: false, status: "unknown", error: String(error) }; + } + }); + + // macOS Accessibility prompt for global click capture. First call shows the + // system dialog; the user has to toggle the app in System Settings (no + // programmatic grant exists for Accessibility). + ipcMain.handle("request-accessibility-access", () => { + if (process.platform !== "darwin") { + return { success: true, granted: true }; + } + try { + const granted = systemPreferences.isTrustedAccessibilityClient(true); + return { success: true, granted }; + } catch (error) { + console.error("Failed to request accessibility access:", error); + return { success: false, granted: false, error: String(error) }; + } + }); + ipcMain.handle("open-source-selector", () => { const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { @@ -723,6 +891,8 @@ export function registerIpcHandlers( const id = typeof recordingId === "number" ? recordingId : Date.now(); cursorTelemetryBuffer.startSession(id); cursorCaptureStartTimeMs = Date.now(); + cursorClickTimestampsMs = []; + startClickCapture(); sampleCursorPoint(); cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS); } else { @@ -787,11 +957,19 @@ export function registerIpcHandlers( }) .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs); - return { success: true, samples }; + const rawClicks = Array.isArray(parsed?.clicks) ? parsed.clicks : []; + const clicks: number[] = rawClicks + .map((value: unknown) => + typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null, + ) + .filter((v: number | null): v is number => v !== null) + .sort((a: number, b: number) => a - b); + + return { success: true, samples, clicks }; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === "ENOENT") { - return { success: true, samples: [] }; + return { success: true, samples: [], clicks: [] }; } console.error("Failed to load cursor telemetry:", error); return { @@ -799,6 +977,7 @@ export function registerIpcHandlers( message: "Failed to load cursor telemetry", error: String(error), samples: [], + clicks: [], }; } }); @@ -835,38 +1014,72 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => { + ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => { try { - // Determine file type from extension const isGif = fileName.toLowerCase().endsWith(".gif"); const filters = isGif ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - const result = await dialog.showSaveDialog({ - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + // Prefer the user's last export folder if it still exists, otherwise fall + // back to ~/Downloads. Validation must happen here because the renderer + // can't stat the filesystem. + let defaultDir = app.getPath("downloads"); + if (exportFolder) { + try { + const stats = await fs.stat(exportFolder); + if (stats.isDirectory()) { + defaultDir = exportFolder; + } + } catch (err) { + console.warn( + `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, + err, + ); + } + } + const dialogOptions = buildDialogOptions( + { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: "Export canceled", - }; + return { success: false, canceled: true, message: "Export canceled" }; } - // --- FIX: Normalize the path for Windows compatibility --- - const normalizedPath = path.normalize(result.filePath); + return { success: true, path: path.normalize(result.filePath) }; + } catch (error) { + console.error("Failed to show save dialog:", error); + return { + success: false, + message: "Failed to show save dialog", + error: String(error), + }; + } + }); - // Ensure the parent directory exists (Windows may fail if the folder is missing) + ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => { + try { + // Sanity-check the path. The renderer is trusted (contextIsolation is on), + // but a stale state bug shouldn't be able to clobber arbitrary files. + if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { + return { success: false, message: "Invalid path" }; + } + const lower = filePath.toLowerCase(); + if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) { + return { success: false, message: "Invalid file type" }; + } + + const normalizedPath = path.normalize(filePath); await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); - // --- END FIX --- - await fs.writeFile(normalizedPath, Buffer.from(videoData)); return { @@ -875,7 +1088,7 @@ export function registerIpcHandlers( message: "Video exported successfully", }; } catch (error) { - console.error("Failed to save exported video:", error); + console.error("Failed to write exported video:", error); return { success: false, message: "Failed to save exported video", @@ -885,18 +1098,22 @@ export function registerIpcHandlers( }); ipcMain.handle("open-video-file-picker", async () => { try { - const result = await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.selectVideo"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.videoFiles"), - extensions: ["webm", "mp4", "mov", "avi", "mkv"], - }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.selectVideo"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.videoFiles"), + extensions: ["webm", "mp4", "mov", "avi", "mkv"], + }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }, + getMainWindow(), + ); + const result = await dialog.showOpenDialog(dialogOptions); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true }; @@ -975,18 +1192,22 @@ export function registerIpcHandlers( ? safeName : `${safeName}.${PROJECT_FILE_EXTENSION}`; - const result = await dialog.showSaveDialog({ - title: mainT("dialogs", "fileDialogs.saveProject"), - defaultPath: path.join(RECORDINGS_DIR, defaultName), - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - ], - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.saveProject"), + defaultPath: path.join(RECORDINGS_DIR, defaultName), + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); if (result.canceled || !result.filePath) { return { @@ -1017,19 +1238,23 @@ export function registerIpcHandlers( ipcMain.handle("load-project-file", async () => { try { - const result = await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.openProject"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.openProject"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }, + getMainWindow(), + ); + const result = await dialog.showOpenDialog(dialogOptions); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true, message: "Open project canceled" }; @@ -1151,4 +1376,45 @@ export function registerIpcHandlers( return { success: false, error: String(error) }; } }); + + ipcMain.handle( + "save-diagnostic", + async ( + _, + payload: { error: string; stack?: string; projectState: unknown; logs: string[] }, + ) => { + const { filePath, canceled } = await dialog.showSaveDialog({ + title: "Save Diagnostic File", + defaultPath: `openscreen-diagnostic-${Date.now()}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (canceled || !filePath) return { success: false, canceled: true }; + + const diagnostic = { + timestamp: new Date().toISOString(), + appVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + osVersion: os.version(), + totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024), + nodeVersion: process.versions.node, + electronVersion: process.versions.electron, + chromeVersion: process.versions.chrome, + error: payload.error, + stack: payload.stack, + projectState: payload.projectState, + recentLogs: payload.logs, + }; + + try { + await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8"); + return { success: true, path: filePath }; + } catch (error) { + console.error("Failed to write diagnostic file:", error); + return { success: false, error: String(error) }; + } + }, + ); } diff --git a/electron/main.ts b/electron/main.ts index ad0a33f..3fe8599 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, - dialog, + desktopCapturer, ipcMain, Menu, nativeImage, @@ -30,6 +30,18 @@ if (process.platform === "darwin") { app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); } +// Enable Wayland support for proper screen capture and window management +// on Wayland compositors (Hyprland, GNOME, KDE, etc.) +if (process.platform === "linux") { + const isWayland = + process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined; + if (isWayland) { + app.commandLine.appendSwitch("ozone-platform", "wayland"); + // Enable WebRTCPipeWireCapturer for screen capture on Wayland + app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer"); + } +} + export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); async function ensureRecordingsDir() { @@ -124,15 +136,30 @@ function setupApplicationMenu() { template.push({ label: app.name, submenu: [ - { role: "about" }, + { + role: "about", + label: mainT("common", "actions.about") || "About OpenScreen", + }, { type: "separator" }, - { role: "services" }, + { + role: "services", + label: mainT("common", "actions.services") || "Services", + }, { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, + { + role: "hide", + label: mainT("common", "actions.hide") || "Hide OpenScreen", + }, + { + role: "hideOthers", + label: mainT("common", "actions.hideOthers") || "Hide Others", + }, + { + role: "unhide", + label: mainT("common", "actions.unhide") || "Show All", + }, { type: "separator" }, - { role: "quit" }, + { role: "quit", label: mainT("common", "actions.quit") || "Quit" }, ], }); } @@ -156,40 +183,89 @@ function setupApplicationMenu() { accelerator: "CmdOrCtrl+Shift+S", click: () => sendEditorMenuAction("menu-save-project-as"), }, - ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ...(isMac + ? [] + : [ + { type: "separator" as const }, + { + role: "quit" as const, + label: mainT("common", "actions.quit") || "Quit", + }, + ]), ], }, { label: mainT("common", "actions.edit") || "Edit", submenu: [ - { role: "undo" }, - { role: "redo" }, + { role: "undo", label: mainT("common", "actions.undo") || "Undo" }, + { role: "redo", label: mainT("common", "actions.redo") || "Redo" }, { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, + { role: "cut", label: mainT("common", "actions.cut") || "Cut" }, + { role: "copy", label: mainT("common", "actions.copy") || "Copy" }, + { role: "paste", label: mainT("common", "actions.paste") || "Paste" }, + { + role: "selectAll", + label: mainT("common", "actions.selectAll") || "Select All", + }, ], }, { label: mainT("common", "actions.view") || "View", submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, + { + role: "reload", + label: mainT("common", "actions.reload") || "Reload", + }, + { + role: "forceReload", + label: mainT("common", "actions.forceReload") || "Force Reload", + }, + { + role: "toggleDevTools", + label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools", + }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, + { + role: "resetZoom", + label: mainT("common", "actions.actualSize") || "Actual Size", + }, + { + role: "zoomIn", + label: mainT("common", "actions.zoomIn") || "Zoom In", + }, + { + role: "zoomOut", + label: mainT("common", "actions.zoomOut") || "Zoom Out", + }, { type: "separator" }, - { role: "togglefullscreen" }, + { + role: "togglefullscreen", + label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen", + }, ], }, { label: mainT("common", "actions.window") || "Window", submenu: isMac - ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] - : [{ role: "minimize" }, { role: "close" }], + ? [ + { + role: "minimize", + label: mainT("common", "actions.minimize") || "Minimize", + }, + { role: "zoom" }, + { type: "separator" }, + { role: "front" }, + ] + : [ + { + role: "minimize", + label: mainT("common", "actions.minimize") || "Minimize", + }, + { + role: "close", + label: mainT("common", "actions.close") || "Close", + }, + ], }, ); @@ -220,7 +296,11 @@ function getTrayIcon(filename: string, size: number) { function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen"; + const trayToolTip = recording + ? mainT("common", "actions.recordingStatus", { + source: selectedSourceName, + }) || `Recording: ${selectedSourceName}` + : "OpenScreen"; const menuTemplate = recording ? [ { @@ -253,6 +333,7 @@ function updateTrayMenu(recording: boolean = false) { let editorHasUnsavedChanges = false; let isForceClosing = false; +let isCloseConfirmInFlight = false; ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; @@ -284,39 +365,35 @@ function createEditorWindowWrapper() { editorHasUnsavedChanges = false; mainWindow.on("close", (event) => { - if (isForceClosing || !editorHasUnsavedChanges) return; + if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return; event.preventDefault(); - - const choice = dialog.showMessageBoxSync(mainWindow!, { - type: "warning", - buttons: [ - mainT("dialogs", "unsavedChanges.saveAndClose"), - mainT("dialogs", "unsavedChanges.discardAndClose"), - mainT("common", "actions.cancel"), - ], - defaultId: 0, - cancelId: 2, - title: mainT("dialogs", "unsavedChanges.title"), - message: mainT("dialogs", "unsavedChanges.message"), - detail: mainT("dialogs", "unsavedChanges.detail"), - }); + isCloseConfirmInFlight = true; const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; - if (choice === 0) { - // Save & Close — tell renderer to save, then close - windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { - if (!shouldClose) return; + // Ask renderer to show the custom in-app dialog + windowToClose.webContents.send("request-close-confirm"); + + ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => { + if (event.sender.id !== windowToClose?.webContents.id) return; + isCloseConfirmInFlight = false; + if (!windowToClose || windowToClose.isDestroyed()) return; + + if (choice === "save") { + // Tell renderer to save the project, then close when done + windowToClose.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => { + if (event.sender.id !== windowToClose?.webContents.id) return; + if (!shouldClose) return; + forceCloseEditorWindow(windowToClose); + }); + } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); - }); - } else if (choice === 1) { - // Discard & Close - forceCloseEditorWindow(windowToClose); - } - // choice === 2: Cancel — do nothing, window stays open + } + // "cancel": flag reset, window stays open + }); }); } @@ -340,10 +417,11 @@ function createCountdownOverlayWindowWrapper() { return countdownOverlayWindow; } -// On macOS, applications and their menu bar stay active until the user quits -// explicitly with Cmd + Q. +// Closing every window quits the app entirely (tray icon goes too). +// The in-app "Return to Recorder" button covers the editor → HUD round-trip, +// so closing the last window is an explicit "I'm done" signal. app.on("window-all-closed", () => { - // Keep app running (macOS behavior) + app.quit(); }); app.on("activate", () => { @@ -365,23 +443,57 @@ app.on("activate", () => { // Register all IPC handlers when app is ready app.whenReady().then(async () => { - // Allow microphone/media permission checks + // Force the app into "regular" activation policy so the Dock icon appears. + // The HUD overlay (transparent + frameless + skipTaskbar) is the first + // window we open, and AppKit otherwise classifies us as an accessory app. + if (process.platform === "darwin") { + app.dock?.show(); + } + + // Allow microphone/media/screen permission checks session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { - const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; + const allowed = [ + "media", + "audioCapture", + "microphone", + "videoCapture", + "camera", + "screen", + "display-capture", + ]; return allowed.includes(permission); }); session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; + const allowed = [ + "media", + "audioCapture", + "microphone", + "videoCapture", + "camera", + "screen", + "display-capture", + ]; callback(allowed.includes(permission)); }); - // Request microphone permission from macOS + // Request microphone and screen recording permissions from macOS if (process.platform === "darwin") { const micStatus = systemPreferences.getMediaAccessStatus("microphone"); if (micStatus !== "granted") { await systemPreferences.askForMediaAccess("microphone"); } + + // Screen recording has no askForMediaAccess equivalent — the TCC prompt is + // triggered by the first desktopCapturer.getSources() call. Firing it here + // at startup settles the permission state early and prevents repeated prompts + // driven by later getSources() calls (fixes repeated permission dialog). + const screenStatus = systemPreferences.getMediaAccessStatus("screen"); + if (screenStatus === "not-determined") { + desktopCapturer.getSources({ types: ["screen"] }).catch(() => { + // Ignore prompt trigger failures; later capture attempts report status. + }); + } } // Listen for HUD overlay quit event (macOS only) diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f0..5980b4c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -16,6 +16,9 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlayClose: () => { ipcRenderer.send("hud-overlay-close"); }, + setHudOverlayIgnoreMouseEvents: (ignore: boolean) => { + ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, @@ -40,6 +43,9 @@ contextBridge.exposeInMainWorld("electronAPI", { requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, + requestAccessibilityAccess: () => { + return ipcRenderer.invoke("request-accessibility-access"); + }, storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); @@ -68,8 +74,11 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName); + pickExportSavePath: (fileName: string, exportFolder?: string) => { + return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder); + }, + writeExportToPath: (videoData: ArrayBuffer, filePath: string) => { + return ipcRenderer.invoke("write-export-to-path", videoData, filePath); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); @@ -131,6 +140,14 @@ contextBridge.exposeInMainWorld("electronAPI", { setLocale: (locale: string) => { return ipcRenderer.invoke("set-locale", locale); }, + saveDiagnostic: (payload: { + error: string; + stack?: string; + projectState: unknown; + logs: string[]; + }) => { + return ipcRenderer.invoke("save-diagnostic", payload); + }, setMicrophoneExpanded: (expanded: boolean) => { ipcRenderer.send("hud:setMicrophoneExpanded", expanded); }, @@ -163,4 +180,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("request-save-before-close", listener); return () => ipcRenderer.removeListener("request-save-before-close", listener); }, + onRequestCloseConfirm: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("request-close-confirm", listener); + return () => ipcRenderer.removeListener("request-close-confirm", listener); + }, + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => { + ipcRenderer.send("close-confirm-response", choice); + }, }); diff --git a/electron/windows.ts b/electron/windows.ts index f94009a..4d4e752 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -24,6 +24,12 @@ ipcMain.on("hud-overlay-hide", () => { } }); +ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true }); + } +}); + /** * Creates the always-on-top HUD overlay window centred at the bottom of the * primary display. The window is frameless, transparent, and follows the user @@ -63,6 +69,7 @@ export function createHudOverlayWindow(): BrowserWindow { backgroundThrottling: false, }, }); + win.setIgnoreMouseEvents(true, { forward: true }); // Follow the user across macOS Spaces (virtual desktops). // Without this the HUD stays pinned to the Space it was first opened on. diff --git a/macos.entitlements b/macos.entitlements index 5c6ddcf..38d8b29 100644 --- a/macos.entitlements +++ b/macos.entitlements @@ -21,5 +21,9 @@ com.apple.security.device.camera + + + com.apple.security.device.screen-capture + diff --git a/nix/package.nix b/nix/package.nix index 13a8658..33dc4f7 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -11,7 +11,7 @@ buildNpmPackage { nodejs = nodejs_22; pname = "openscreen"; - version = "1.3.0"; + version = "1.4.0"; src = let @@ -33,7 +33,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U="; + npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index a449101..afe2091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", + "hasInstallScript": true, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -47,11 +48,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.5", "uuid": "^13.0.0", "web-demuxer": "^4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -185,6 +188,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -393,6 +397,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -716,6 +721,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -764,6 +770,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1078,25 +1085,18 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", - "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", "dev": true, "license": "MIT", "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "got": "^11.7.0", - "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", - "node-gyp": "^11.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^7.5.6", - "yargs": "^17.0.1" + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" @@ -1105,19 +1105,6 @@ "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -1215,7 +1202,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1237,7 +1223,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1254,7 +1239,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1269,7 +1253,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1791,80 +1774,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2048,62 +1957,11 @@ "node": ">= 8" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@pixi/color": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -2118,8 +1976,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2146,8 +2003,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2174,22 +2030,19 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2201,7 +2054,6 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2213,7 +2065,6 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2224,17 +2075,6 @@ "url": "^0.11.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", @@ -3803,8 +3643,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3879,8 +3718,7 @@ "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/debug": { "version": "4.1.13", @@ -3918,8 +3756,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -4018,6 +3855,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4029,6 +3867,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4310,6 +4149,7 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -4464,13 +4304,13 @@ "license": "MIT" }, "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/acorn": { @@ -4502,6 +4342,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4973,18 +4814,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -5039,6 +4868,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5073,6 +4903,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5172,92 +5003,6 @@ "node": ">= 10.0.0" } }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "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", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -5305,7 +5050,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -5471,19 +5215,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -5548,16 +5279,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -5679,8 +5400,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5828,19 +5548,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -5909,16 +5616,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -5993,6 +5690,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -6085,8 +5783,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -6135,15 +5832,7 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC", - "peer": true - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/ejs": { "version": "3.1.10", @@ -6326,7 +6015,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6347,7 +6035,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6396,17 +6083,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6601,8 +6277,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/expect-type": { "version": "1.3.0", @@ -6818,36 +6493,6 @@ "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", "license": "MIT" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6921,19 +6566,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7466,17 +7098,8 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } + "license": "BSD-3-Clause", + "optional": true }, "node_modules/indent-string": { "version": "4.0.0", @@ -7507,16 +7130,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7575,16 +7188,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7601,19 +7204,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -7643,22 +7233,6 @@ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", "license": "MIT" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -8002,23 +7576,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -8132,7 +7689,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8147,29 +7703,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -8287,16 +7820,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -8366,136 +7889,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -8515,7 +7908,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -8619,16 +8011,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -8687,28 +8069,49 @@ } }, "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" } }, "node_modules/node-gyp/node_modules/semver": { @@ -8724,6 +8127,32 @@ "node": ">=10" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -8732,19 +8161,19 @@ "license": "MIT" }, "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -8792,7 +8221,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -8848,86 +8276,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -8954,26 +8302,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parse-svg-path": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", @@ -9019,30 +8347,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9125,6 +8429,7 @@ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz", "integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==", "license": "MIT", + "peer": true, "workspaces": [ "examples", "playground" @@ -9170,6 +8475,7 @@ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -9240,6 +8546,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9384,7 +8691,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -9402,7 +8708,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -9413,7 +8718,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9429,7 +8733,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9438,13 +8741,13 @@ } }, "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/progress": { @@ -9543,7 +8846,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -9602,6 +8904,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9614,6 +8917,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9650,8 +8954,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -9785,21 +9088,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9987,7 +9275,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -10082,27 +9369,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10215,7 +9481,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -10235,7 +9500,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -10252,7 +9516,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -10271,7 +9534,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -10363,41 +9625,12 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10446,19 +9679,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10483,16 +9703,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -10518,35 +9728,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10576,20 +9757,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -10707,6 +9874,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10790,7 +9958,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -11105,6 +10272,19 @@ "node": ">=14.17" } }, + "node_modules/uiohook-napi": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz", + "integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -11122,32 +10302,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -11204,7 +10358,6 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", - "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -11217,8 +10370,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -11311,6 +10463,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11400,7 +10553,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", @@ -11423,6 +10577,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11520,16 +10675,6 @@ "node": ">=18" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-demuxer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/web-demuxer/-/web-demuxer-4.0.0.tgz", @@ -11625,38 +10770,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", diff --git a/package.json b/package.json index 102e97c..2ccb0b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "packageManager": "npm@10.9.4", "engines": { @@ -21,15 +21,17 @@ "i18n:check": "node scripts/i18n-check.mjs", "preview": "vite preview", "build:mac": "tsc && vite build && electron-builder --mac", - "build:win": "tsc && vite build && electron-builder --win", - "build:linux": "tsc && vite build && electron-builder --linux AppImage deb", + "build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false", + "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false", "test": "vitest --run", "test:watch": "vitest", "build-vite": "tsc && vite build", "test:browser": "vitest --config vitest.browser.config.ts --run", "test:browser:install": "playwright install --with-deps chromium-headless-shell", "test:e2e": "playwright test", - "prepare": "husky" + "prepare": "husky", + "rebuild:native": "node ./scripts/rebuild-native.mjs", + "postinstall": "npm run rebuild:native" }, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", @@ -71,11 +73,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.5", "uuid": "^13.0.0", "web-demuxer": "^4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/scripts/rebuild-native.mjs b/scripts/rebuild-native.mjs new file mode 100644 index 0000000..e028602 --- /dev/null +++ b/scripts/rebuild-native.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +// uiohook-napi click capture is macOS-only at runtime (gated in +// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners +// without X11 dev headers don't fail npm install. The library's prebuilt +// .node binaries are still bundled and loadable; we just don't need a fresh +// build against Electron's ABI on platforms where we don't load it. +if (process.platform !== "darwin") { + console.log( + `[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`, + ); + process.exit(0); +} + +const result = spawnSync( + process.execPath, + ["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"], + { stdio: "inherit" }, +); +process.exit(result.status ?? 0); diff --git a/src/App.tsx b/src/App.tsx index 4045b5d..6f737b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,19 @@ -import { useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; -import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; -import VideoEditor from "./components/video-editor/VideoEditor"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; +const VideoEditor = lazy(() => import("./components/video-editor/VideoEditor")); +const ShortcutsConfigDialog = lazy(() => + import("./components/video-editor/ShortcutsConfigDialog").then((module) => ({ + default: module.ShortcutsConfigDialog, + })), +); + export default function App() { const [windowType, setWindowType] = useState( () => new URLSearchParams(window.location.search).get("windowType") || "", @@ -25,6 +30,20 @@ export default function App() { document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); } + + // HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow + // so the renderer can't introduce scrollbars (see issue #305). + if (type === "hud-overlay") { + document.documentElement.style.height = "100%"; + document.documentElement.style.overflow = "hidden"; + document.body.style.height = "100%"; + document.body.style.margin = "0"; + document.body.style.overflow = "hidden"; + const root = document.getElementById("root"); + root?.style.setProperty("height", "100%"); + root?.style.setProperty("min-height", "0"); + root?.style.setProperty("overflow", "hidden"); + } }, [windowType]); useEffect(() => { @@ -45,8 +64,10 @@ export default function App() { case "editor": return ( - - + }> + + + ); default: diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 132fa0a..20b8718 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -44,13 +44,13 @@ position: fixed; right: 0; top: 0; - width: 12rem; - padding: 0.375rem; - border-radius: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.14); - background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98)); - box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55); - backdrop-filter: blur(14px); + width: 11rem; + padding: 0.25rem; + border-radius: 0.625rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(8, 9, 12, 0.96); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.045); + backdrop-filter: blur(18px) saturate(140%); pointer-events: auto; box-sizing: border-box; } @@ -60,10 +60,10 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.5rem 0.625rem; - border-radius: 0.5rem; + padding: 0.425rem 0.5rem; + border-radius: 0.45rem; font-size: 11px; - color: rgba(255, 255, 255, 0.88); + color: rgba(255, 255, 255, 0.72); background: transparent; border: 0; cursor: pointer; @@ -72,12 +72,12 @@ .languageMenuItem:hover, .languageMenuItem:focus-visible { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.075); color: #ffffff; outline: none; } .languageMenuItemActive { - background: rgba(255, 255, 255, 0.12); + background: rgba(52, 178, 123, 0.14); color: #ffffff; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 9b7d809..6a14fc0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -62,16 +62,16 @@ function getIcon(name: IconName, className?: string) { } const hudGroupClasses = - "flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]"; + "flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]"; const hudIconBtnClasses = - "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95"; + "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95"; const hudAuxIconBtnClasses = - "flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; + "flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; const windowBtnClasses = - "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; + "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; @@ -87,6 +87,7 @@ export function LaunchWindow() { resolveSystemLocaleSuggestion, } = useI18n(); const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; + const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase(); const { recording, @@ -248,6 +249,13 @@ export function LaunchWindow() { return () => cancelAnimationFrame(id); }, [isLanguageMenuOpen]); + useEffect(() => { + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true); + return () => { + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false); + }; + }, []); + const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -314,7 +322,19 @@ export function LaunchWindow() { }; return ( -
+ // Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh): + // 100vw can exceed the inner layout width when scrollbars affect the + // viewport (notably on Windows), causing a horizontal scrollbar once the + // recording toolbar widened (issue #305). +
{ + const target = event.target as HTMLElement | null; + const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']")); + window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture); + }} + onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)} + > {systemLocaleSuggestion && (
{/* Mic selector */} {showMicControls && (
setIsMicHovered(true)} onMouseLeave={() => setIsMicHovered(false)} onFocus={() => setIsMicFocused(true)} @@ -403,7 +424,7 @@ export function LaunchWindow() { {/* Webcam selector */} {showWebcamControls && (
setIsWebcamHovered(true)} onMouseLeave={() => setIsWebcamHovered(false)} onFocus={() => setIsWebcamFocused(true)} @@ -479,7 +500,8 @@ export function LaunchWindow() { {/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
{/* Drag handle */}
@@ -488,13 +510,15 @@ export function LaunchWindow() { {/* Source selector */} {/* Audio controls group */} @@ -542,7 +566,7 @@ export function LaunchWindow() { ? paused ? "bg-amber-500/10 hover:bg-amber-500/15" : "bg-red-500/12 hover:bg-red-500/16" - : "bg-white/5 hover:bg-white/[0.08]" + : "bg-white/[0.06] hover:bg-white/[0.10]" }`} onClick={toggleRecording} disabled={!hasSelectedSource && !recording} @@ -618,11 +642,12 @@ export function LaunchWindow() { aria-expanded={isLanguageMenuOpen} aria-haspopup="menu" onClick={() => setIsLanguageMenuOpen((open) => !open)} - className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`} + className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`} > -
- -
+ + + {activeLanguageLabel} +
diff --git a/src/components/launch/SourceSelector.module.css b/src/components/launch/SourceSelector.module.css index 48d5507..5bd4d96 100644 --- a/src/components/launch/SourceSelector.module.css +++ b/src/components/launch/SourceSelector.module.css @@ -1,8 +1,8 @@ .glassContainer { - background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - border-radius: 30px; + background: linear-gradient(145deg, rgba(13, 14, 17, 0.94) 0%, rgba(8, 9, 12, 0.9) 100%); + backdrop-filter: blur(24px) saturate(150%); + -webkit-backdrop-filter: blur(24px) saturate(150%); + border-radius: 24px; corner-shape: squircle; /* Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector. @@ -11,34 +11,36 @@ /* box-shadow: 0 0px 16px 0 rgba(0, 0, 0, 0.32), 0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */ - border: 1.5px solid rgba(60, 60, 80, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); } .sourceCard { corner-shape: squircle; - border-radius: 20px; - background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%); - border: 1px solid rgba(60, 60, 80, 0.22); - box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18); + border-radius: 13px; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.07); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); transition: box-shadow 0.2s ease, border-color 0.2s ease, + background-color 0.2s ease, transform 0.2s ease; cursor: pointer; } .sourceCard:hover { - border-color: rgba(120, 120, 160, 0.35); + background: rgba(255, 255, 255, 0.065); + border-color: rgba(255, 255, 255, 0.14); transform: translateY(-1px); - box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.04); } .selected { - border: 1.5px solid #34b27b; - background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%); + border-color: rgba(52, 178, 123, 0.68); + background: linear-gradient(145deg, rgba(52, 178, 123, 0.13), rgba(255, 255, 255, 0.045)); box-shadow: - 0 0 12px rgba(52, 178, 123, 0.15), - 0 0 4px rgba(52, 178, 123, 0.1); + 0 0 0 1px rgba(52, 178, 123, 0.18) inset, + 0 12px 28px rgba(0, 0, 0, 0.22); } .selected:hover { @@ -46,16 +48,16 @@ } .icon { - width: 13px; - height: 13px; + width: 12px; + height: 12px; color: #c7d2fe; } .name { - font-size: 0.8rem; + font-size: 0.72rem; color: #e4e4e7; font-weight: 500; - letter-spacing: 0.01em; + letter-spacing: 0; } .cardText { @@ -65,14 +67,14 @@ /* Checkmark badge */ .checkBadge { - width: 18px; - height: 18px; + width: 17px; + height: 17px; background: #34b27b; border-radius: 9999px; display: flex; align-items: center; justify-content: center; - box-shadow: 0 0 8px rgba(52, 178, 123, 0.4); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35); } /* scrollbar */ diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index a2aec55..1a0675a 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -77,24 +77,24 @@ export function SourceSelector() { return (
handleSourceSelect(source)} > -
+
{source.name} {isSelected && ( -
+
- +
)}
-
+
{source.appIcon && ( )} @@ -106,21 +106,21 @@ export function SourceSelector() { return (
-
+
- + {t("sourceSelector.screens", { count: String(screenSources.length) })} {t("sourceSelector.windows", { count: String(windowSources.length) })} @@ -128,14 +128,14 @@ export function SourceSelector() {
{screenSources.map(renderSourceCard)}
{windowSources.map(renderSourceCard)}
@@ -143,18 +143,18 @@ export function SourceSelector() {
-
+
diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index f416c32..345423f 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -82,7 +82,7 @@ export function AnnotationOverlay({ ); const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null); const mosaicCanvasRef = useRef(null); - const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur"; + const blurType = "mosaic"; const blurOverlayColor = annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : ""; const mosaicGridOverlayColor = @@ -106,7 +106,7 @@ export function AnnotationOverlay({ const { x, y, width, height } = liveRect; useEffect(() => { - if (annotation.type !== "blur" || blurType !== "mosaic") { + if (annotation.type !== "blur") { return; } void previewFrameVersion; @@ -173,7 +173,6 @@ export function AnnotationOverlay({ ); }, [ annotation, - blurType, containerHeight, containerWidth, height, diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 3f8064e..72e25a8 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -7,7 +7,6 @@ import { ChevronDown, Copy, Image as ImageIcon, - Info, Italic, Trash2, Type, @@ -148,39 +147,39 @@ export function AnnotationSettingsPanel({ }; return ( -
-
-
- {t("annotation.title")} - +
+
+
+ {t("annotation.active")} +
{t("annotation.title")}
{/* Type Selector */} onTypeChange(value as AnnotationType)} - className="mb-6" + className="mb-4" > - + {t("annotation.typeText")} {t("annotation.typeImage")}
- -
-
- - {t("annotation.shortcutsAndTips")} -
-
    -
  • {t("annotation.tipMovePlayhead")}
  • -
  • {t("annotation.tipTabCycle")}
  • -
  • {t("annotation.tipShiftTabCycle")}
  • -
-
); diff --git a/src/components/video-editor/BlurSettingsPanel.tsx b/src/components/video-editor/BlurSettingsPanel.tsx index 09bfe3a..7ead894 100644 --- a/src/components/video-editor/BlurSettingsPanel.tsx +++ b/src/components/video-editor/BlurSettingsPanel.tsx @@ -1,12 +1,5 @@ -import { Info, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { useScopedT } from "@/contexts/I18nContext"; import { getBlurOverlayColor } from "@/lib/blurEffects"; @@ -19,9 +12,7 @@ import { DEFAULT_BLUR_BLOCK_SIZE, DEFAULT_BLUR_DATA, MAX_BLUR_BLOCK_SIZE, - MAX_BLUR_INTENSITY, MIN_BLUR_BLOCK_SIZE, - MIN_BLUR_INTENSITY, } from "./types"; interface BlurSettingsPanelProps { @@ -49,13 +40,15 @@ export function BlurSettingsPanel({ ]; return ( -
-
-
- {t("annotation.blurShape")} - - {t("annotation.active")} +
+
+
+ + {t("annotation.blurTypeMosaic")} +
+ {t("annotation.typeBlur")} +
@@ -69,6 +62,7 @@ export function BlurSettingsPanel({ const nextBlurData: BlurData = { ...DEFAULT_BLUR_DATA, ...blurRegion.blurData, + type: "mosaic", shape: shape.value, }; onBlurDataChange(nextBlurData); @@ -77,7 +71,7 @@ export function BlurSettingsPanel({ }); }} className={cn( - "h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1", + "h-12 rounded-lg border flex items-center justify-center transition-all p-2 gap-2", isActive ? "bg-[#34B27B] border-[#34B27B]" : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20", @@ -99,7 +93,7 @@ export function BlurSettingsPanel({ )} /> )} - + {t(`annotation.${shape.labelKey}`)} @@ -107,34 +101,6 @@ export function BlurSettingsPanel({ })}
-
- - -
-
-
+
- {blurRegion.blurData?.type === "mosaic" - ? t("annotation.mosaicBlockSize") - : t("annotation.blurIntensity")} + {t("annotation.mosaicBlockSize")} - {Math.round( - blurRegion.blurData?.type === "mosaic" - ? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE) - : (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity), - )} + {Math.round(blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)} px
{ onBlurDataChange({ ...DEFAULT_BLUR_DATA, ...blurRegion.blurData, - ...(blurRegion.blurData?.type === "mosaic" - ? { blockSize: values[0] } - : { intensity: values[0] }), + type: "mosaic", + blockSize: values[0], }); }} onValueCommit={() => onBlurDataCommit?.()} - min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY} - max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY} + min={MIN_BLUR_BLOCK_SIZE} + max={MAX_BLUR_BLOCK_SIZE} step={1} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" /> @@ -231,16 +187,6 @@ export function BlurSettingsPanel({ {t("annotation.deleteAnnotation")} - -
-
- - {t("annotation.shortcutsAndTips")} -
-
    -
  • {t("annotation.tipMovePlayhead")}
  • -
-
); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 5ac5979..21bb360 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,11 +1,17 @@ +import * as SliderPrimitive from "@radix-ui/react-slider"; import { Bug, + ChevronDown, Crop, Download, + FileDown, Film, Image, + LayoutPanelTop, Lock, + MousePointerClick, Palette, + SlidersHorizontal, Sparkles, Star, Trash2, @@ -13,7 +19,7 @@ import { Upload, X, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { type ComponentType, useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Accordion, @@ -22,6 +28,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -53,13 +60,24 @@ import type { CropRegion, FigureData, PlaybackSpeed, + Rotation3DPreset, WebcamLayoutPreset, WebcamMaskShape, WebcamSizePreset, ZoomDepth, + ZoomFocus, ZoomFocusMode, } from "./types"; -import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; +import { + DEFAULT_WEBCAM_SIZE_PRESET, + MAX_PLAYBACK_SPEED, + MAX_ZOOM_SCALE, + MIN_ZOOM_SCALE, + ROTATION_3D_PRESET_ORDER, + SPEED_OPTIONS, + ZOOM_DEPTH_SCALES, +} from "./types"; +import { getFocusBoundsForScale } from "./videoPlayback/focusUtils"; function CustomSpeedInput({ value, @@ -124,6 +142,58 @@ function CustomSpeedInput({ ); } +function ZoomFocusCoordInput({ + percent, + onChange, + onCommit, + disabled, + ariaLabel, +}: { + percent: number; + onChange: (nextPercent: number) => void; + onCommit?: () => void; + disabled?: boolean; + ariaLabel: string; +}) { + // While the input is focused (user is editing), show their draft text + // so partial entries like "5" or "" don't get overwritten by re-renders. + // When not focused, mirror the live prop value so external changes + // (dragging the overlay on the preview) update the displayed number in real time. + const [draft, setDraft] = useState(null); + const display = percent.toFixed(1); + + return ( + setDraft(display)} + onChange={(e) => { + const next = e.target.value; + setDraft(next); + const parsed = Number(next); + if (next !== "" && Number.isFinite(parsed)) { + const clamped = Math.min(100, Math.max(0, parsed)); + onChange(clamped); + } + }} + onBlur={() => { + setDraft(null); + onCommit?.(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + className="h-7 w-full rounded-md border border-white/10 bg-white/5 px-2 text-[11px] text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed" + /> + ); +} + const GRADIENTS = [ "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", @@ -152,15 +222,29 @@ const GRADIENTS = [ ]; interface SettingsPanelProps { + cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig; + onCursorHighlightChange?: ( + next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig, + ) => void; + // macOS only — gates the "Only on clicks" toggle (needs uiohook). + cursorHighlightSupportsClicks?: boolean; selected: string; onWallpaperChange: (path: string) => void; selectedZoomDepth?: ZoomDepth | null; onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomCustomScale?: number | null; + onZoomCustomScaleChange?: (scale: number) => void; + onZoomCustomScaleCommit?: () => void; selectedZoomFocusMode?: ZoomFocusMode | null; onZoomFocusModeChange?: (mode: ZoomFocusMode) => void; + selectedZoomFocus?: ZoomFocus | null; + onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void; + onZoomFocusCoordinateCommit?: () => void; hasCursorTelemetry?: boolean; selectedZoomId?: string | null; onZoomDelete?: (id: string) => void; + selectedZoomRotationPreset?: Rotation3DPreset | null; + onZoomRotationPresetChange?: (preset: Rotation3DPreset | null) => void; selectedTrimId?: string | null; onTrimDelete?: (id: string) => void; shadowIntensity?: number; @@ -225,6 +309,7 @@ interface SettingsPanelProps { webcamSizePreset?: WebcamSizePreset; onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; onWebcamSizePresetCommit?: () => void; + onSaveDiagnostic?: () => Promise; } export default SettingsPanel; @@ -238,16 +323,29 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; +type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export"; + export function SettingsPanel({ + cursorHighlight, + onCursorHighlightChange, + cursorHighlightSupportsClicks = false, selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, + selectedZoomCustomScale, + onZoomCustomScaleChange, + onZoomCustomScaleCommit, selectedZoomFocusMode, onZoomFocusModeChange, + selectedZoomFocus, + onZoomFocusCoordinateChange, + onZoomFocusCoordinateCommit, hasCursorTelemetry = false, selectedZoomId, onZoomDelete, + selectedZoomRotationPreset, + onZoomRotationPresetChange, selectedTrimId, onTrimDelete, shadowIntensity = 0, @@ -307,8 +405,10 @@ export function SettingsPanel({ webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, onWebcamSizePresetChange, onWebcamSizePresetCommit, + onSaveDiagnostic, }: SettingsPanelProps) { const t = useScopedT("settings"); + const [activePanelMode, setActivePanelMode] = useState("background"); // Resolved URLs are for DOM rendering only (backgroundImage). The canonical // `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted // on click — never the machine-specific file:// URL. @@ -441,6 +541,31 @@ export function SettingsPanel({ const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); + const hasTimelineSelection = Boolean(selectedZoomId || selectedTrimId || selectedSpeedId); + const panelModes: Array<{ + id: SettingsPanelMode; + label: string; + icon: ComponentType<{ className?: string }>; + disabled?: boolean; + }> = [ + { id: "background", label: t("background.title"), icon: Palette }, + { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, + { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, + { id: "cursor", label: t("effects.cursorHighlight.title"), icon: MousePointerClick }, + ]; + const exportPanelMode = { + id: "export" as const, + label: exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton"), + icon: Download, + }; + const activeModeLabel = hasTimelineSelection + ? selectedZoomId + ? t("zoom.level") + : selectedSpeedId + ? t("speed.playbackSpeed") + : t("trim.deleteRegion") + : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? + t("background.title")); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { @@ -520,6 +645,42 @@ export function SettingsPanel({ const selectedBlur = selectedBlurId ? blurRegions.find((region) => region.id === selectedBlurId) : null; + const commonFooterLinks = ( +
+ + {onSaveDiagnostic && ( + + )} + +
+ ); // If an annotation is selected, show annotation settings instead if ( @@ -530,627 +691,1057 @@ export function SettingsPanel({ onAnnotationDelete ) { return ( - onAnnotationContentChange(selectedAnnotation.id, content)} - onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} - onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} - onFigureDataChange={ - onAnnotationFigureDataChange - ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) - : undefined - } - onDuplicate={ - onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined - } - onDelete={() => onAnnotationDelete(selectedAnnotation.id)} - /> +
+
+ onAnnotationContentChange(selectedAnnotation.id, content)} + onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} + onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} + onFigureDataChange={ + onAnnotationFigureDataChange + ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) + : undefined + } + onDuplicate={ + onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined + } + onDelete={() => onAnnotationDelete(selectedAnnotation.id)} + /> +
+
+ {commonFooterLinks} +
+
); } if (selectedBlur && onBlurDataChange && onBlurDelete) { return ( - onBlurDataChange(selectedBlur.id, blurData)} - onBlurDataCommit={onBlurDataCommit} - onDelete={() => onBlurDelete(selectedBlur.id)} - /> +
+
+ onBlurDataChange(selectedBlur.id, blurData)} + onBlurDataCommit={onBlurDataCommit} + onDelete={() => onBlurDelete(selectedBlur.id)} + /> +
+
+ {commonFooterLinks} +
+
); } return ( -
-
-
-
- {t("zoom.level")} -
- {zoomEnabled && selectedZoomDepth && ( - - {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} +
+
+
+ {panelModes.map((mode) => { + const Icon = mode.icon; + const isActive = activePanelMode === mode.id && !hasTimelineSelection; + return ( + + ); + })} + + +
+
+
+ {activeModeLabel} + +
+ {zoomEnabled && ( +
+
+ + {t("zoom.level")} - )} - -
-
-
- {ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; - return ( - - ); - })} -
- {!zoomEnabled && ( -

{t("zoom.selectRegion")}

- )} - {zoomEnabled && hasCursorTelemetry && ( -
- - {t("zoom.focusMode.title")} - -
- {(["manual", "auto"] as const).map((mode) => { - const isActive = selectedZoomFocusMode === mode; + + {( + selectedZoomCustomScale ?? + (selectedZoomDepth != null + ? ZOOM_DEPTH_SCALES[selectedZoomDepth] + : MIN_ZOOM_SCALE) + ).toFixed(2)} + × + +
+
+ {ZOOM_DEPTH_OPTIONS.map((option) => { + const effectiveScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null); + const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth]; return ( ); })}
- {selectedZoomFocusMode === "auto" && ( -

- {t("zoom.focusMode.autoDescription")} -

- )} -
- )} - {zoomEnabled && ( - - )} -
- - {trimEnabled && ( -
- -
- )} - -
-
- {t("speed.playbackSpeed")} - {selectedSpeedId && selectedSpeedValue && ( - - {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? - `${selectedSpeedValue}×`} - - )} -
-
- {SPEED_OPTIONS.map((option) => { - const isActive = selectedSpeedValue === option.speed; - return ( - - ); - })} -
-
-
- - {t("speed.customPlaybackSpeed")} - - {selectedSpeedId ? ( - onSpeedChange?.(val)} - onError={() => toast.error(t("speed.maxSpeedError"))} - /> - ) : ( -
-
- -- -
- × -
- )} -
-
- {!selectedSpeedId && ( -

{t("speed.selectRegion")}

- )} - {selectedSpeedId && ( - - )} -
- - - {hasWebcam && ( - - -
- - {t("layout.title")} -
-
- -
-
- {t("layout.preset")} -
- + + + + + +
+ {MIN_ZOOM_SCALE.toFixed(1)}× + {MAX_ZOOM_SCALE.toFixed(1)}× +
- {webcamLayoutPreset === "picture-in-picture" && ( -
-
- {t("layout.webcamShape")} -
-
- {( - [ - { value: "rectangle", label: "Rect" }, - { value: "circle", label: "Circle" }, - { value: "square", label: "Square" }, - { value: "rounded", label: "Rounded" }, - ] as Array<{ value: WebcamMaskShape; label: string }> - ).map((shape) => ( - - ))} -
+ + {t(`zoom.focusMode.${mode}`)} + + + ); + })}
- )} - {webcamLayoutPreset === "picture-in-picture" && ( -
-
-
- {t("layout.webcamSize")} -
-
- {webcamSizePreset}% +
+ )} + {zoomEnabled && + selectedZoomFocusMode !== "auto" && + selectedZoomFocus && + onZoomFocusCoordinateChange && + (() => { + const effectiveZoomScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null + ? ZOOM_DEPTH_SCALES[selectedZoomDepth] + : MIN_ZOOM_SCALE); + const bounds = getFocusBoundsForScale(effectiveZoomScale); + const xRange = bounds.maxX - bounds.minX; + const yRange = bounds.maxY - bounds.minY; + const focusToPercentX = (cx: number) => + xRange <= 0 + ? 50 + : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100)); + const focusToPercentY = (cy: number) => + yRange <= 0 + ? 50 + : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100)); + const percentToFocusX = (p: number) => + xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange; + const percentToFocusY = (p: number) => + yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange; + return ( +
+ + {t("zoom.position.title")} + +
+
+ + + onZoomFocusCoordinateChange({ + cx: percentToFocusX(p), + cy: selectedZoomFocus.cy, + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+
+ + + onZoomFocusCoordinateChange({ + cx: selectedZoomFocus.cx, + cy: percentToFocusY(p), + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
- onWebcamSizePresetChange?.(values[0])} - onValueCommit={() => onWebcamSizePresetCommit?.()} - min={10} - max={50} - step={1} - className="w-full" - /> + ); + })()} + {zoomEnabled && ( +
+ + {t("zoom.threeD.title")} + +
+ {ROTATION_3D_PRESET_ORDER.map((preset) => { + const isActive = selectedZoomRotationPreset === preset; + return ( + + ); + })}
- )} - - +
+ )} + + {zoomEnabled && ( + + )} +
)} - - -
- - {t("effects.title")} -
-
- -
-
-
- {t("effects.blurBg")} -
- -
-
- -
-
-
-
- {t("effects.motionBlur")} -
- - {motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)} - -
- onMotionBlurChange?.(values[0])} - onValueCommit={() => onMotionBlurCommit?.()} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- {t("effects.shadow")} -
- - {Math.round(shadowIntensity * 100)}% - -
- onShadowChange?.(values[0])} - onValueCommit={() => onShadowCommit?.()} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- {t("effects.roundness")} -
- {borderRadius}px -
- onBorderRadiusChange?.(values[0])} - onValueCommit={() => onBorderRadiusCommit?.()} - min={0} - max={16} - step={0.5} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
- {t("effects.padding")} -
- - {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} - -
- onPaddingChange?.(values[0])} - onValueCommit={() => onPaddingCommit?.()} - min={0} - max={100} - step={1} - disabled={webcamLayoutPreset === "vertical-stack"} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
- + {trimEnabled && ( +
- - +
+ )} - - -
- - {t("background.title")} + {selectedSpeedId && ( +
+
+ + {t("speed.playbackSpeed")} + + {selectedSpeedId && selectedSpeedValue && ( + + {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? + `${selectedSpeedValue}×`} + + )}
- - - - - - {t("background.image")} - - - {t("background.color")} - - - {t("background.gradient")} - - - -
- - +
+ {SPEED_OPTIONS.map((option) => { + const isActive = selectedSpeedValue === option.speed; + return ( + ); + })} +
+
+ + {t("speed.customPlaybackSpeed")} + + {selectedSpeedId ? ( + onSpeedChange?.(val)} + onError={() => toast.error(t("speed.maxSpeedError"))} + /> + ) : ( +
+
+ -- +
+ × +
+ )} +
+ {selectedSpeedId && ( + + )} +
+ )} -
- {customImages.map((imageUrl, idx) => { - const isSelected = selected === imageUrl; - return ( -
onWallpaperChange(imageUrl)} - role="button" - > + {!hasTimelineSelection && ( + + {hasWebcam && activePanelMode === "layout" && ( + + +
+ + {t("layout.title")} +
+
+ +
+
+ {t("layout.preset")} +
+ +
+ {webcamLayoutPreset === "picture-in-picture" && ( +
+
+ {t("layout.webcamShape")} +
+
+ {( + [ + { value: "rectangle", label: "Rect" }, + { value: "circle", label: "Circle" }, + { value: "square", label: "Square" }, + { value: "rounded", label: "Rounded" }, + ] as Array<{ value: WebcamMaskShape; label: string }> + ).map((shape) => ( + ))} +
+
+ )} + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+
+ {t("layout.webcamSize")} +
+
+ {webcamSizePreset}% +
+
+ onWebcamSizePresetChange?.(values[0])} + onValueCommit={() => onWebcamSizePresetCommit?.()} + min={10} + max={50} + step={1} + className="w-full" + /> +
+ )} +
+
+ )} + + {(activePanelMode === "effects" || activePanelMode === "cursor") && ( + + +
+ {activePanelMode === "cursor" ? ( + + ) : ( + + )} + + {activePanelMode === "cursor" + ? t("effects.cursorHighlight.title") + : t("effects.title")} + +
+
+ + {activePanelMode === "effects" && ( + <> +
+
+
+ {t("effects.blurBg")} +
+ +
+
+ +
+
+
+
+ {t("effects.motionBlur")} +
+ + {motionBlurAmount === 0 + ? t("effects.off") + : motionBlurAmount.toFixed(2)} + +
+ onMotionBlurChange?.(values[0])} + onValueCommit={() => onMotionBlurCommit?.()} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.shadow")} +
+ + {Math.round(shadowIntensity * 100)}% + +
+ onShadowChange?.(values[0])} + onValueCommit={() => onShadowCommit?.()} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.roundness")} +
+ + {borderRadius}px + +
+ onBorderRadiusChange?.(values[0])} + onValueCommit={() => onBorderRadiusCommit?.()} + min={0} + max={16} + step={0.5} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.padding")} +
+ + {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} + +
+ onPaddingChange?.(values[0])} + onValueCommit={() => onPaddingCommit?.()} + min={0} + max={100} + step={1} + disabled={webcamLayoutPreset === "vertical-stack"} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+ + )} + + {activePanelMode === "cursor" && cursorHighlight && onCursorHighlightChange && ( +
+
+
+ {t("effects.cursorHighlight.title")} +
+ +
+
+ {(["dot", "ring"] as const).map((style) => ( + + ))} +
+
+
+
+ {t("effects.cursorHighlight.size")} +
+ + {cursorHighlight.sizePx}px + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + sizePx: values[0], + }) + } + min={10} + max={36} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ {cursorHighlightSupportsClicks && ( +
+
+ {t("effects.cursorHighlight.onlyOnClicks")} +
+
- ); - })} - - {WALLPAPER_PATHS.map((canonicalPath, i) => { - const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath; - const isSelected = selected === canonicalPath; - return ( -
onWallpaperChange(canonicalPath)} - role="button" - /> - ); - })} -
- - - - { - setSelectedColor(color); - onWallpaperChange(color); - }} - /> - - - -
- {GRADIENTS.map((g, idx) => ( + )}
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - /> - ))} + className={ + cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none" + } + > +
+ {t("effects.cursorHighlight.color")} +
+ + + + + + + onCursorHighlightChange({ + ...cursorHighlight, + color, + }) + } + /> + + +
+
+
+
+ {t("effects.cursorHighlight.offsetX")} +
+ + {(cursorHighlight.offsetXNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetXNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.cursorHighlight.offsetY")} +
+ + {(cursorHighlight.offsetYNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetYNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+ )} + + + )} + + {activePanelMode === "background" && ( + + +
+ + {t("background.title")}
-
-
- -
-
-
+ + + + + + {t("background.image")} + + + {t("background.color")} + + + {t("background.gradient")} + + + +
+ + + + +
+ {customImages.map((imageUrl, idx) => { + const isSelected = selected === imageUrl; + return ( +
onWallpaperChange(imageUrl)} + role="button" + > + +
+ ); + })} + + {WALLPAPER_PATHS.map((canonicalPath, i) => { + const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath; + const isSelected = selected === canonicalPath; + return ( +
onWallpaperChange(canonicalPath)} + role="button" + /> + ); + })} +
+ + + + { + setSelectedColor(color); + onWallpaperChange(color); + }} + /> + + + +
+ {GRADIENTS.map((g, idx) => ( +
{ + setGradient(g); + onWallpaperChange(g); + }} + role="button" + /> + ))} +
+ +
+ + + + )} + + )} +
{showCropModal && cropRegion && onCropChange && ( @@ -1276,172 +1867,155 @@ export function SettingsPanel({ )} -
-
- - -
- - {exportFormat === "mp4" && ( -
- - - -
- )} - - {exportFormat === "gif" && ( -
-
-
- {GIF_FRAME_RATES.map((rate) => ( - - ))} -
-
- {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( - - ))} -
+
+ {activePanelMode === "export" && !hasTimelineSelection && ( + <> +
+ +
-
- - {gifOutputDimensions.width} × {gifOutputDimensions.height}px - -
- {t("gifSettings.loop")} - + + {exportFormat === "mp4" && ( +
+ + +
-
-
+ )} + + {exportFormat === "gif" && ( +
+
+
+ {GIF_FRAME_RATES.map((rate) => ( + + ))} +
+
+ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( + + ))} +
+
+
+ + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + +
+ {t("gifSettings.loop")} + +
+
+
+ )} + + {unsavedExport && ( + + )} + + )} - {unsavedExport && ( - - )} - - -
- - -
+ {commonFooterLinks}
); diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx new file mode 100644 index 0000000..902b142 --- /dev/null +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -0,0 +1,77 @@ +import { Save, Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useScopedT } from "@/contexts/I18nContext"; + +interface UnsavedChangesDialogProps { + isOpen: boolean; + onSaveAndClose: () => void; + onDiscardAndClose: () => void; + onCancel: () => void; +} + +export function UnsavedChangesDialog({ + isOpen, + onSaveAndClose, + onDiscardAndClose, + onCancel, +}: UnsavedChangesDialogProps) { + const td = useScopedT("dialogs"); + const tc = useScopedT("common"); + + return ( + !open && onCancel()}> + + +
+ + + {td("unsavedChanges.title")} + +
+
+ +

{td("unsavedChanges.message")}

+ + {td("unsavedChanges.detail")} + + +
+ + + +
+
+
+ ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index a89c436..e1f6a60 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,7 +31,12 @@ import { import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; +import { + getExportFolder, + loadUserPreferences, + parentDirectoryOf, + saveUserPreferences, +} from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -67,13 +72,16 @@ import { DEFAULT_ZOOM_DEPTH, type FigureData, type PlaybackSpeed, + type Rotation3DPreset, type SpeedRegion, type TrimRegion, + ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus, type ZoomFocusMode, type ZoomRegion, } from "./types"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; export default function VideoEditor() { @@ -103,6 +111,7 @@ export default function VideoEditor() { webcamMaskShape, webcamSizePreset, webcamPosition, + cursorHighlight, } = editorState; // ── Non-undoable state @@ -121,6 +130,7 @@ export default function VideoEditor() { const durationRef = useRef(duration); durationRef.current = duration; const [cursorTelemetry, setCursorTelemetry] = useState([]); + const [cursorClickTimestamps, setCursorClickTimestamps] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); @@ -144,6 +154,7 @@ export default function VideoEditor() { format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); const playerContainerRef = useRef(null); const videoPlaybackRef = useRef(null); @@ -153,6 +164,12 @@ export default function VideoEditor() { const nextSpeedIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); + // Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for + // renderers while keeping the persisted value intact for round-tripping. + const effectiveCursorHighlight = useMemo( + () => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }), + [cursorHighlight, isMac], + ); const { locale, setLocale, t: rawT } = useI18n(); const t = useScopedT("editor"); const ts = useScopedT("settings"); @@ -452,6 +469,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + cursorHighlight, }; const projectData = createProjectData(currentProjectMedia, editorState); @@ -513,6 +531,7 @@ export default function VideoEditor() { videoPath, t, webcamSizePreset, + cursorHighlight, ], ); @@ -527,6 +546,28 @@ export default function VideoEditor() { return () => cleanup(); }, [saveProject]); + useEffect(() => { + const cleanup = window.electronAPI.onRequestCloseConfirm(() => { + setShowCloseConfirmDialog(true); + }); + return () => cleanup(); + }, []); + + const handleCloseConfirmSave = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("save"); + }, []); + + const handleCloseConfirmDiscard = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("discard"); + }, []); + + const handleCloseConfirmCancel = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("cancel"); + }, []); + const handleSaveProject = useCallback(async () => { await saveProject(false); }, [saveProject]); @@ -587,6 +628,7 @@ export default function VideoEditor() { if (!sourcePath) { if (mounted) { setCursorTelemetry([]); + setCursorClickTimestamps([]); } return; } @@ -595,11 +637,13 @@ export default function VideoEditor() { const result = await window.electronAPI.getCursorTelemetry(sourcePath); if (mounted) { setCursorTelemetry(result.success ? result.samples : []); + setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []); } } catch (telemetryError) { console.warn("Unable to load cursor telemetry:", telemetryError); if (mounted) { setCursorTelemetry([]); + setCursorClickTimestamps([]); } } } @@ -689,6 +733,7 @@ export default function VideoEditor() { startMs: Math.round(span.start), endMs: Math.round(span.end), depth: DEFAULT_ZOOM_DEPTH, + customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], focus: { cx: 0.5, cy: 0.5 }, }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); @@ -708,6 +753,7 @@ export default function VideoEditor() { startMs: Math.round(span.start), endMs: Math.round(span.end), depth: DEFAULT_ZOOM_DEPTH, + customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); @@ -791,6 +837,7 @@ export default function VideoEditor() { ? { ...region, depth, + customScale: ZOOM_DEPTH_SCALES[depth], focus: clampFocusToDepth(region.focus, depth), } : region, @@ -800,6 +847,24 @@ export default function VideoEditor() { [selectedZoomId, pushState], ); + const handleZoomCustomScaleChange = useCallback( + (scale: number) => { + if (!selectedZoomId) return; + const rounded = Math.round(scale * 100) / 100; + if (!Number.isFinite(rounded)) return; + updateState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === selectedZoomId ? { ...region, customScale: rounded } : region, + ), + })); + }, + [selectedZoomId, updateState], + ); + + const handleZoomCustomScaleCommit = useCallback(() => { + commitState(); + }, [commitState]); + const handleZoomFocusModeChange = useCallback( (focusMode: ZoomFocusMode) => { if (!selectedZoomId) return; @@ -824,6 +889,23 @@ export default function VideoEditor() { [selectedZoomId, pushState], ); + const handleZoomRotationPresetChange = useCallback( + (preset: Rotation3DPreset | null) => { + if (!selectedZoomId) return; + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => { + if (region.id !== selectedZoomId) return region; + if (preset === null) { + const { rotationPreset: _p, ...rest } = region; + return rest; + } + return { ...region, rotationPreset: preset }; + }), + })); + }, + [selectedZoomId, pushState], + ); + const handleTrimDelete = useCallback( (id: string) => { pushState((prev) => ({ @@ -1288,6 +1370,10 @@ export default function VideoEditor() { const handleExportSaved = useCallback( (formatLabel: "GIF" | "Video", filePath: string) => { setExportedFilePath(filePath); + const folder = parentDirectoryOf(filePath); + if (folder) { + saveUserPreferences({ exportFolder: folder }); + } toast.success( t("export.exportedSuccessfully", { format: formatLabel, @@ -1309,13 +1395,19 @@ export default function VideoEditor() { const handleSaveUnsavedExport = useCallback(async () => { if (!unsavedExport) return; try { - const saveResult = await window.electronAPI.saveExportedVideo( - unsavedExport.arrayBuffer, + const pickResult = await window.electronAPI.pickExportSavePath( unsavedExport.fileName, + getExportFolder(), ); - if (saveResult.canceled) { + if (pickResult.canceled || !pickResult.success || !pickResult.path) { toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + return; + } + const saveResult = await window.electronAPI.writeExportToPath( + unsavedExport.arrayBuffer, + pickResult.path, + ); + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path); } else { @@ -1340,6 +1432,21 @@ export default function VideoEditor() { return; } + // Ask the user where to save BEFORE starting the export. This avoids the + // post-export save dialog getting hidden behind other windows after a + // long-running export. + const isGifFormat = settings.format === "gif"; + const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`; + const pickResult = await window.electronAPI.pickExportSavePath( + targetFileName, + getExportFolder(), + ); + if (pickResult.canceled || !pickResult.success || !pickResult.path) { + setShowExportDialog(false); + return; + } + const targetPath = pickResult.path; + setIsExporting(true); setExportProgress(null); setExportError(null); @@ -1394,6 +1501,8 @@ export default function VideoEditor() { previewWidth, previewHeight, cursorTelemetry, + cursorClickTimestamps, + cursorHighlight: effectiveCursorHighlight, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1404,8 +1513,6 @@ export default function VideoEditor() { if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.gif`; if (result.warnings) { for (const warning of result.warnings) { @@ -1413,15 +1520,13 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); - if (saveResult.canceled) { - setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); - toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("GIF", saveResult.path); } else { + setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" }); setExportError(saveResult.message || "Failed to save GIF"); toast.error(saveResult.message || "Failed to save GIF"); } @@ -1437,18 +1542,19 @@ export default function VideoEditor() { let bitrate: number; if (quality === "source") { - // Use source resolution exportWidth = sourceWidth; exportHeight = sourceHeight; + // Use the source's longer dimension as the long axis of the export so + // a landscape recording can still fill a portrait target (and vice versa). + const sourceLongDim = Math.max(sourceWidth, sourceHeight); + if (aspectRatioValue === 1) { - // Square (1:1): use smaller dimension to avoid codec limits const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; exportWidth = baseDimension; exportHeight = baseDimension; } else if (aspectRatioValue > 1) { - // Landscape: find largest even dimensions that exactly match aspect ratio - const baseWidth = Math.floor(sourceWidth / 2) * 2; + const baseWidth = Math.floor(sourceLongDim / 2) * 2; let found = false; for (let w = baseWidth; w >= 100 && !found; w -= 2) { const h = Math.round(w / aspectRatioValue); @@ -1463,8 +1569,7 @@ export default function VideoEditor() { exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2; } } else { - // Portrait: find largest even dimensions that exactly match aspect ratio - const baseHeight = Math.floor(sourceHeight / 2) * 2; + const baseHeight = Math.floor(sourceLongDim / 2) * 2; let found = false; for (let h = baseHeight; h >= 100 && !found; h -= 2) { const w = Math.round(h * aspectRatioValue); @@ -1480,7 +1585,6 @@ export default function VideoEditor() { } } - // Calculate visually lossless bitrate matching screen recording optimization const totalPixels = exportWidth * exportHeight; bitrate = 30_000_000; if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { @@ -1489,14 +1593,18 @@ export default function VideoEditor() { bitrate = 80_000_000; } } else { - // Use quality-based target resolution - const targetHeight = quality === "medium" ? 720 : 1080; + // Quality presets target the SHORT side; the long side derives from the + // aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080. + const targetShortDim = quality === "medium" ? 720 : 1080; - // Calculate dimensions maintaining aspect ratio - exportHeight = Math.floor(targetHeight / 2) * 2; - exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; + if (aspectRatioValue >= 1) { + exportHeight = Math.floor(targetShortDim / 2) * 2; + exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; + } else { + exportWidth = Math.floor(targetShortDim / 2) * 2; + exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2; + } - // Adjust bitrate for lower resolutions const totalPixels = exportWidth * exportHeight; if (totalPixels <= 1280 * 720) { bitrate = 10_000_000; @@ -1534,6 +1642,8 @@ export default function VideoEditor() { previewWidth, previewHeight, cursorTelemetry, + cursorClickTimestamps, + cursorHighlight: effectiveCursorHighlight, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1544,8 +1654,6 @@ export default function VideoEditor() { if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.mp4`; if (result.warnings) { for (const warning of result.warnings) { @@ -1553,15 +1661,13 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); - if (saveResult.canceled) { - setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); - toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("Video", saveResult.path); } else { + setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" }); setExportError(saveResult.message || "Failed to save video"); toast.error(saveResult.message || "Failed to save video"); } @@ -1617,6 +1723,8 @@ export default function VideoEditor() { exportQuality, handleExportSaved, cursorTelemetry, + cursorClickTimestamps, + effectiveCursorHighlight, t, ], ); @@ -1693,6 +1801,19 @@ export default function VideoEditor() { } }, []); + const handleSaveDiagnostic = useCallback(async () => { + const result = await window.electronAPI.saveDiagnostic({ + error: exportError ?? "Manual diagnostic export", + projectState: editorState, + logs: [], + }); + if (result.success) { + toast.success("Diagnostic file saved"); + } else if (!result.canceled) { + toast.error("Failed to save diagnostic file"); + } + }, [exportError, editorState]); + if (loading) { return (
@@ -1748,7 +1869,7 @@ export default function VideoEditor() {