diff --git a/.github/workflows/build-fork-macos.yml b/.github/workflows/build-fork-macos.yml
index 1b0d57105..b926348d6 100644
--- a/.github/workflows/build-fork-macos.yml
+++ b/.github/workflows/build-fork-macos.yml
@@ -1,4 +1,4 @@
-name: Build OpenScreen Subscription for macOS
+name: Build PurplePrint Studio for macOS
on:
workflow_dispatch:
@@ -98,8 +98,8 @@ jobs:
run: |
set -euo pipefail
VERSION="$(node -p 'require("./package.json").version')"
- APP="$(find "release/${VERSION}" -maxdepth 3 -name 'OpenScreen Subscription.app' -type d -print -quit)"
- [ -n "$APP" ] || { echo "::error::OpenScreen Subscription.app was not packaged"; exit 1; }
+ APP="$(find "release/${VERSION}" -maxdepth 3 -name 'PurplePrint Studio.app' -type d -print -quit)"
+ [ -n "$APP" ] || { echo "::error::PurplePrint Studio.app was not packaged"; exit 1; }
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "path=${APP}" >> "$GITHUB_OUTPUT"
@@ -114,7 +114,7 @@ jobs:
run: |
set -euo pipefail
APP="${{ steps.app.outputs.path }}"
- BIN="${APP}/Contents/MacOS/OpenScreen Subscription"
+ BIN="${APP}/Contents/MacOS/PurplePrint Studio"
RESOURCES="${APP}/Contents/Resources"
NATIVE="${RESOURCES}/electron/native/bin/darwin-${{ matrix.arch }}"
@@ -147,9 +147,9 @@ jobs:
- name: Smoke-test packaged CLI boot
run: |
set -euo pipefail
- BIN="${{ steps.app.outputs.path }}/Contents/MacOS/OpenScreen Subscription"
+ BIN="${{ steps.app.outputs.path }}/Contents/MacOS/PurplePrint Studio"
"$BIN" help > cli-help.txt
- grep -q "OpenScreen CLI" cli-help.txt
+ grep -Fq "PurplePrint Studio CLI (openscreen command)" cli-help.txt
- name: Create DMG
id: dmg
@@ -160,7 +160,7 @@ jobs:
arm64) LABEL="Apple-Silicon" ;;
x64) LABEL="Intel" ;;
esac
- OUT="release/${VERSION}/OpenScreen-Subscription-${VERSION}-macOS-${LABEL}.dmg"
+ OUT="release/${VERSION}/PurplePrint-Studio-${VERSION}-macOS-${LABEL}.dmg"
STAGING="release/${VERSION}/dmg-${{ matrix.arch }}"
rm -rf "$STAGING" "$OUT"
mkdir -p "$STAGING"
@@ -168,7 +168,7 @@ jobs:
ln -s /Applications "$STAGING/Applications"
hdiutil create \
-srcfolder "$STAGING" \
- -volname "OpenScreen Subscription" \
+ -volname "PurplePrint Studio" \
-fs HFS+ \
-format UDBZ \
"$OUT"
@@ -185,7 +185,7 @@ jobs:
}
trap cleanup EXIT
hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT" "${{ steps.dmg.outputs.path }}" >/dev/null
- APP="${MOUNT}/OpenScreen Subscription.app"
+ APP="${MOUNT}/PurplePrint Studio.app"
test -d "$APP"
test -L "${MOUNT}/Applications"
codesign --verify --deep --strict "$APP"
@@ -204,7 +204,7 @@ jobs:
import { readFileSync, writeFileSync } from "node:fs";
const bytes = readFileSync(process.env.DMG);
const report = {
- product: "OpenScreen Subscription",
+ product: "PurplePrint Studio",
version: process.env.VERSION,
architecture: process.env.ARCH,
bundleId: "io.github.purpleprintai.openscreen-subscription",
diff --git a/.github/workflows/build-fork-windows.yml b/.github/workflows/build-fork-windows.yml
new file mode 100644
index 000000000..0adeb7d01
--- /dev/null
+++ b/.github/workflows/build-fork-windows.yml
@@ -0,0 +1,93 @@
+name: Validate PurplePrint Studio for Windows
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: ["fork/main"]
+ paths:
+ - ".github/workflows/build-fork-windows.yml"
+ - "build/fork-speech-payload.json"
+ - "crates/**"
+ - "electron/**"
+ - "scripts/**"
+ - "src/**"
+ - "package.json"
+ - "package-lock.json"
+ - "electron-builder.fork.json"
+ - "electron-builder.json5"
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ package:
+ name: Windows x64 clean package
+ runs-on: windows-latest
+ timeout-minutes: 90
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v7
+
+ - name: Setup Node.js
+ uses: ./.github/actions/setup
+
+ - name: Check pinned LLVM toolchain
+ shell: pwsh
+ run: |
+ $clang = 'C:\Program Files\LLVM\bin\libclang.dll'
+ if (-not (Test-Path -LiteralPath $clang)) {
+ throw "libclang missing at $clang"
+ }
+
+ - name: Build native helpers, renderer and unsigned NSIS package
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ run: npm run build:win:fork
+
+ - name: Smoke-test packaged CLI
+ shell: pwsh
+ run: |
+ $version = (Get-Content package.json -Raw | ConvertFrom-Json).version
+ $exe = Join-Path "release/$version/win-unpacked" 'PurplePrint Studio.exe'
+ $process = Start-Process -FilePath $exe -ArgumentList 'help' -WindowStyle Hidden -Wait -PassThru -RedirectStandardOutput cli-help.txt -RedirectStandardError cli-stderr.txt
+ if ($process.ExitCode -ne 0) { throw "Packaged CLI exited $($process.ExitCode)" }
+ if (-not (Select-String -LiteralPath cli-help.txt -SimpleMatch 'PurplePrint Studio CLI (openscreen command)' -Quiet)) {
+ throw 'Packaged CLI banner missing'
+ }
+
+ - name: Verify package identity, payload and checksums
+ shell: pwsh
+ env:
+ GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: node scripts/validate-fork-windows-package.mjs
+
+ - name: Confirm candidate is unsigned
+ shell: pwsh
+ run: |
+ $version = (Get-Content package.json -Raw | ConvertFrom-Json).version
+ $setup = "release/$version/PurplePrint-Studio-$version-Windows-x64-Setup.exe"
+ $status = (Get-AuthenticodeSignature -LiteralPath $setup).Status
+ if ($status -ne 'NotSigned') { throw "Unexpected installer signature status: $status" }
+
+ - name: Read candidate version
+ id: version
+ shell: pwsh
+ run: |
+ $version = (Get-Content package.json -Raw | ConvertFrom-Json).version
+ "value=$version" >> $env:GITHUB_OUTPUT
+
+ - name: Upload validation-only Windows package
+ uses: actions/upload-artifact@v7
+ with:
+ name: purpleprint-studio-windows-x64
+ path: |
+ release/${{ steps.version.outputs.value }}/PurplePrint-Studio-${{ steps.version.outputs.value }}-Windows-x64-Setup.exe
+ release/${{ steps.version.outputs.value }}/PurplePrint-Studio-${{ steps.version.outputs.value }}-Windows-x64-Setup.exe.blockmap
+ release/${{ steps.version.outputs.value }}/latest.yml
+ release/${{ steps.version.outputs.value }}/windows-validation.json
+ release/${{ steps.version.outputs.value }}/SHA256SUMS-Windows.txt
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
index a1822ee05..7e99ab72f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,8 @@ release/**
# Playwright
test-results
playwright-report/
+/.playwright-cli/
+/output/playwright/
# Vitest browser mode screenshots
diff --git a/FORK-CHANGELOG.md b/FORK-CHANGELOG.md
index 08a7597bb..b2150d33f 100644
--- a/FORK-CHANGELOG.md
+++ b/FORK-CHANGELOG.md
@@ -1,4 +1,4 @@
-# OpenScreen Subscription fork changelog
+# PurplePrint Studio fork changelog
This file tracks changes made by the unofficial
[PurplePrintAI/openscreen-subscription](https://github.com/PurplePrintAI/openscreen-subscription)
@@ -17,6 +17,84 @@ see [FORK.md](FORK.md).
For the dated review of newer upstream changes, see
[FORK-UPSTREAM-REVIEW.md](FORK-UPSTREAM-REVIEW.md).
+## Unreleased — PurplePrint Studio identity (`1.10.0-subscription.11` candidate)
+
+- Adopts the PurplePrint GraphOS family's neutral surfaces, restrained violet
+ accents, typography hierarchy and product mark across the editor and capture
+ entry surfaces. Recording and semantic status colors retain their meanings.
+- Renames visible app, installer, Start menu and macOS package labels to
+ PurplePrint Studio. The technical app ID, data profile, `.openscreen` format,
+ CLI command and GitHub update origin stay stable for compatibility.
+- Keeps upstream attribution explicit. This work does not add Design Coach/MCP
+ integration or claim GraphOS runtime integration.
+- Before live installation, the unsigned Windows NSIS package was smoke-tested
+ with an isolated profile. Packaged H.264 import, editor launch and MP4 export
+ passed with synthetic media.
+- An isolated profile created by the installed `.7` fork retained its project,
+ media clip, light theme and preferences when opened by the Studio package;
+ the old project exported a decodable MP4. A packaged Windows WGC window
+ capture of a generated, non-user-content target also reopened and exported.
+ This exercised native capture with automated control. On the installed `.8`
+ app, the owner separately confirmed that a physical mouse click on the HUD's
+ Screen button opened the source selector. The owner then selected a source,
+ recorded six seconds, stopped from the HUD and opened the saved recording in
+ Studio. A user-driven export remains unverified.
+- Fixes the packaged audio-demuxer WASM URL used by automatic transcription.
+ The installed `.8` opened a silent recording but showed a transient
+ "Failed to fetch" transcription error because this URL resolved outside the
+ packaged `dist/wasm` directory. After the `.9` upgrade, the owner recorded
+ another silent clip without the error toast; its new project persisted a
+ `no-audio` verdict and the owner exported an MP4 successfully.
+- With the owner's approval, the `.8` NSIS installer upgraded the existing
+ per-user `.7` registration. It removed the old executable, installed Studio
+ in a product-named child folder beneath the prior custom location and replaced
+ the Start-menu shortcut. A local backup was verified before installation;
+ three existing project files and the AI configuration retained their hashes.
+ Studio launched using the existing locale. The installed `.8` binary loaded
+ an isolated copy of all three real project documents (three assets and three
+ clips) and opened the editor without emitting their contents. The live
+ profile was not used for this project-loading test; private footage was not
+ exported.
+- Builds Apple Silicon and Intel macOS DMGs in
+ [the fork's validation workflow](https://github.com/PurplePrintAI/openscreen-subscription/actions/runs/36320426373).
+ Both passed architecture, native payload, bundle ID, ad-hoc signature, CLI
+ boot and mounted-DMG checks. The bundled installation guide uses the Studio
+ name and links Apple's current per-app security procedure. A real Mac record →
+ edit → export pass, Developer ID signing and notarization remain release
+ gates. No Studio release has been published.
+- Advances the unpublished candidate to `1.10.0-subscription.9`. The `.8`
+ candidate refreshed the Nix dependency hash and checked the rebranded CLI
+ banner in macOS packaging. The unsigned `.9` Windows NSIS installer was built
+ and its packaged WASM path checked, then installed on the owner's PC after a
+ verified backup. Nine installed payload files matched the `.9` build, the
+ registered version was `.9`, and the Start-menu shortcut kept its target.
+ This developer PC
+ lacked `libclang.dll` for a fresh compositor rebuild, so packaging reused the
+ verified `.8` compositor binary with the same SHA-256; the WGC helper was
+ rebuilt. [macOS `.9` CI](https://github.com/PurplePrintAI/openscreen-subscription/actions/runs/36358715037)
+ built Apple Silicon and Intel DMGs and passed payload, bundle identity,
+ architecture, ad-hoc signature structure, CLI boot and mounted-DMG checks.
+ A real Mac record → edit → export pass and notarization remain unverified.
+- Advances the Windows source candidate to `1.10.0-subscription.10`. Update
+ discovery now reads published releases in the installed `subscription`
+ prerelease channel; GitHub's `/releases/latest` endpoint returned 404 while
+ the fork had only prereleases. Drafts and unrelated channels are ignored,
+ versions are compared semantically, and release URLs remain restricted to
+ this fork. A clean Windows packaging workflow rebuilds the native helpers
+ and verifies the installer without publishing it. The Windows speech runtime
+ is pinned to a checksum-verified artifact built by this fork rather than an
+ upstream workflow artifact.
+- The `.10` clean Windows package passed CI and was installed locally after a
+ verified `.9` backup. The owner reported a first recording attempt where the
+ three-second countdown ended but native capture had not yet acknowledged
+ startup; a second click received "already running". Retrying later recorded
+ and exported MP4. The `.11` candidate blocks overlapping start requests and
+ shows a localized "Starting…" spinner while waiting for the native helper.
+ The clean `.11` Windows package passed CI and was installed on the owner's PC
+ after a verified `.10` backup. The owner confirmed a successful test recording,
+ MP4 export and an installed-app "up to date" check. A real future-version
+ updater download/install remains unverified; `.11` is not published.
+
## 1.10.0-subscription.7 — 2026-09-27
### GPT-6 model catalog
diff --git a/FORK.md b/FORK.md
index 18b52eb9a..dcf72d3bc 100644
--- a/FORK.md
+++ b/FORK.md
@@ -1,6 +1,7 @@
-# OpenScreen Subscription — independent fork
+# PurplePrint Studio — independent OpenScreen fork
-This is an **unofficial, independently maintained fork** of
+PurplePrint Studio (formerly OpenScreen Subscription) is an **unofficial,
+independently maintained fork** of
[getopenscreen/openscreen](https://github.com/getopenscreen/openscreen), initially
based on upstream **v1.10.0** (`dbdadb7d27fe3287d994fb741d6f264a31066648`).
It is not an official OpenScreen release and is not endorsed by the upstream
@@ -18,8 +19,8 @@ architecture, policy and release notes behind those changes.
- Retain API-key providers, editor tool validation and checkpoints.
- Separate installation identity, user data and update downloads from upstream.
-The app remains free. "Subscription" names the optional use of the user's own
-eligible ChatGPT plan; this fork does not sell or require its own plan. Codex CLI
+The app remains free. The former "Subscription" name described optional use of the
+user's own eligible ChatGPT plan; Studio does not sell or require its own plan. Codex CLI
is a user-installed dependency, not bundled in the installer. Tokens, API keys,
user settings, recordings and diagnostic transcripts must never be committed or
uploaded as release assets.
@@ -55,7 +56,8 @@ are different actions. Do not represent a PR as accepted without actual review.
2. Build the WGC helper and compositor from this checkout. Keep native-payload
and freshness checks enabled. Do not bypass them or retimestamp old binaries.
3. Fetch the pinned, checksum-verified LGPL FFmpeg SDK. Stage speech helpers from
- a recorded upstream build; retain provenance and hashes.
+ the recorded fork build in `build/fork-speech-payload.json`; retain provenance
+ and hashes.
4. Run app/test typechecks, relevant tests, the full unit suite, i18n checks and
native smoke tests. Resolve new failures; never relabel a failed check as passed.
5. Package with `electron-builder.fork.json` and `--publish never`. The original
@@ -74,6 +76,13 @@ npm run build-vite
npx electron-builder --win nsis --x64 --config electron-builder.fork.json --config.npmRebuild=false --publish never
```
+For a clean Windows runner build, use
+[`build-fork-windows.yml`](.github/workflows/build-fork-windows.yml). Relevant
+fork PR changes trigger it; after the workflow is on the default branch, it
+can also be dispatched manually. It rebuilds the native helpers and unsigned
+installer, checks the packaged payload against the build outputs, and uploads
+validation-only artifacts without publishing a GitHub Release.
+
The installer is unsigned unless an independently authorized signing identity is
configured. No upstream publisher identity is borrowed. Windows security prompts
must be handled by the user, not disabled by this fork or its build scripts.
diff --git a/MACOS-INSTALLATION.md b/MACOS-INSTALLATION.md
index 37a0bc9ff..43f9bb654 100644
--- a/MACOS-INSTALLATION.md
+++ b/MACOS-INSTALLATION.md
@@ -1,8 +1,13 @@
-# OpenScreen Subscription on macOS
+# PurplePrint Studio on macOS
-OpenScreen Subscription is an unofficial fork. The current macOS packages are
-validation prereleases with the fork bundle identifier and an ad-hoc hardened-runtime
-signature. They are **not notarized by Apple**.
+> This guide covers PurplePrint Studio validation builds, including the
+> unpublished `1.10.0-subscription.8` candidate. It is not a stable consumer release.
+
+PurplePrint Studio is an unofficial OpenScreen fork. The current macOS packages
+carry the fork bundle identifier and an ad-hoc hardened-runtime signature. They
+are **not Developer ID signed or notarized by Apple**. Apple warns that running
+software without those checks may put the Mac and its data at risk; use these
+packages only for controlled testing from a source you trust.
## Choose the package
@@ -15,11 +20,14 @@ preview and export would all run through translation.
## Install and open
-1. Download the matching `.dmg` from this fork's GitHub prerelease.
-2. Open the DMG and drag **OpenScreen Subscription** to **Applications**.
-3. In Applications, Control-click **OpenScreen Subscription**, choose **Open**, and
- confirm the first launch. This is the per-app Gatekeeper path for an unnotarized
- validation build. Do not disable Gatekeeper globally.
+1. Obtain the matching `.dmg` from the fork's validation workflow or an approved
+ prerelease. Compare its SHA-256 with the accompanying `SHA256SUMS-macOS.txt`.
+2. Open the DMG and drag **PurplePrint Studio** to **Applications**.
+3. Try opening **PurplePrint Studio** from Applications. If macOS blocks it and
+ you independently decide to test this unnotarized build despite the warning,
+ use Apple's [Privacy & Security → Open Anyway procedure](https://support.apple.com/en-au/102445)
+ after that attempt. Do not disable Gatekeeper globally or remove quarantine
+ attributes by command line.
4. Grant only the permissions needed for the features you use. macOS may request
Screen & System Audio Recording, Accessibility, Microphone or Camera access.
5. Fully quit and reopen the app after changing a privacy permission.
@@ -34,14 +42,14 @@ Codex CLI and Claude Code are optional external dependencies. They are never bun
in the DMG.
- ChatGPT subscription: install the official Codex CLI, then connect the account in
- OpenScreen's AI settings.
+ Studio's AI settings.
- Claude subscription: install the official Claude Code CLI and run
- `claude auth login` in Terminal before checking the connection in OpenScreen.
+ `claude auth login` in Terminal before checking the connection in Studio.
The packaged app checks the inherited PATH and common Finder-safe locations:
`~/.local/bin`, `~/.npm-global/bin`, `~/.volta/bin`, `~/Library/pnpm`,
`/opt/homebrew/bin` and `/usr/local/bin`. The corresponding command should work in
-Terminal (`codex --version` or `claude --version`) before troubleshooting OpenScreen.
+Terminal (`codex --version` or `claude --version`) before troubleshooting Studio.
## Known transcription limitation
diff --git a/README.md b/README.md
index 6916c4806..85483b288 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,20 @@
-> **Unofficial fork: OpenScreen Subscription.** Adds local ChatGPT/Codex and Claude Code subscription routing, AI-session UI improvements, and a Windows Unicode recording-path fix. This is not an upstream release or endorsement. See [FORK.md](FORK.md) for attribution, policy and publication boundaries, and [FORK-CHANGELOG.md](FORK-CHANGELOG.md) for the versioned differences from upstream v1.10.0. Use this fork's release artifacts, not the upstream installers linked in the original documentation below.
+> **PurplePrint Studio is an independent, unofficial fork of [OpenScreen](https://github.com/getopenscreen/openscreen).** This working rebrand is not an upstream release or endorsement. Published `1.10.0-subscription.*` installers still use the former OpenScreen Subscription name until a Studio build passes release validation. See [FORK.md](FORK.md) for provenance and [FORK-CHANGELOG.md](FORK-CHANGELOG.md) for changes. Use [this fork's releases](https://github.com/PurplePrintAI/openscreen-subscription/releases), not the upstream installers described later on this page.
-
+
-#
OpenScreen
+#
PurplePrint Studio
-
A free, open-source desktop app for recording your screen and turning the result into polished product demos and walkthroughs.
+
Agentic Film Editor for product demos and GTM video content.
+
+PurplePrint Studio remains free to use. It records and edits locally; optional AI editing uses the user's own configured provider or local Codex/Claude Code runtime. The existing `.openscreen` project format and `openscreen` CLI command remain unchanged for compatibility. Design Coach handoff and GraphOS integration are not included in this rebrand. See the [Studio identity and release gates](docs/purpleprint-studio-identity.md).
+
+## OpenScreen upstream documentation — reference only
+
+The feature overview and installation sections below describe the upstream OpenScreen project. Its Store listings, signing claims and release links **do not apply to this fork**. [FORK.md](FORK.md) describes fork release boundaries; [MACOS-INSTALLATION.md](MACOS-INSTALLATION.md) currently documents the published packages under their former name. Studio packages still need validation.
+
+### OpenScreen
diff --git a/build/fork-speech-payload.json b/build/fork-speech-payload.json
index 4e4d4ac06..2d97e17d1 100644
--- a/build/fork-speech-payload.json
+++ b/build/fork-speech-payload.json
@@ -1,37 +1,37 @@
{
- "repository": "getopenscreen/openscreen",
- "runId": 32717547572,
- "headSha": "ed316fae986394e355959b3ac8e4318f1d07796e",
+ "repository": "PurplePrintAI/openscreen-subscription",
+ "runId": 36294782126,
+ "headSha": "93fce11ee2ae73b774063ed81e3b517b14087b31",
"artifact": "whisper-stt-win32-x64",
- "archiveSha256": "B60AB2C1531A23E990565E069EADAAE6511D299A91E20B1685A33378CB145136",
+ "archiveSha256": "92D6E9F8DE808BDDC0CC4B4D1799FDD495E1434EF5CD133BAAA05DDF96583EF1",
"files": [
{
"name": "ggml-base.dll",
- "sha256": "4D67BEB0C5BC5A9FD746A0296BE1E11862755F95F229B28B077D36E969E559E0"
+ "sha256": "CCB20A9E14F54EEC24D799A0A750637894FDD93C783A9FCA32AD7D877D430CD9"
},
{
"name": "ggml-cpu.dll",
- "sha256": "5CB48891BCC872484636D966D90E0C0FC82DE0E64228FA47D47321DB28712C1D"
+ "sha256": "75B3C517BA1CA27E38E2C6719F10469895E578BF729B914376EAFDD278C63B21"
},
{
"name": "ggml-vulkan.dll",
- "sha256": "B1F61BB9FA3B9D78980AAB0C55E11CF2B65DA8A95ED5F6EB8E9D94664BAAF11D"
+ "sha256": "67B0419B768F05CE81084E1153094EFCBF7168CC03A031E4F4350985D159A1D9"
},
{
"name": "ggml.dll",
- "sha256": "F718C3472B34D97E29FD82199DE72538ED6DD9439AFFB8A0F2514067F32AED6D"
+ "sha256": "63DC8A8AFE4D4FDCA9346F25D6EEE2908190C8C465D0D3867C681C2DA82A625C"
},
{
"name": "parakeet.dll",
- "sha256": "3C5DF100C4041474E092E5BE35084A80DD10C6ED1B4ED9334F45BFFC7B71C230"
+ "sha256": "22AA903E520053EEE49438F6A5B1AE5002CF46857BDC7607AEB8E6A1AF39246C"
},
{
"name": "whisper-stt-server.exe",
- "sha256": "52865EEAF2C99D2CE8A412366D81AF41104C7F07DAD47C18BEAC9A5F476F679B"
+ "sha256": "14F746233966955E9F2E5742A46087F17E6775F86AD55B1C97A541587D334249"
},
{
"name": "whisper.dll",
- "sha256": "EFB7376BC5EB5D3FF904816EEC9A49768FD46B5E149D8C2A96989D4061843060"
+ "sha256": "0B7BBCB193CCEDCBFB9CAC6A3854E4E732C2C144918931CB3C3C13FD1C37197F"
}
]
}
diff --git a/docs/purpleprint-studio-identity.md b/docs/purpleprint-studio-identity.md
new file mode 100644
index 000000000..42a263fca
--- /dev/null
+++ b/docs/purpleprint-studio-identity.md
@@ -0,0 +1,63 @@
+# PurplePrint Studio identity
+
+Status: unsigned `.11` Windows candidate installed on the owner's PC. The owner
+confirmed recording, MP4 export and an "up to date" check in the installed app.
+No Studio release has been published.
+
+PurplePrint Studio is the agentic film editor intended as a video-production
+companion to PurplePrint Design Coach's GTM and operating phases. GraphOS is the
+developing top-level PurplePrint product. This fork does **not** currently share
+GraphOS runtime state or consume Design Coach/MCP project context; that handoff
+belongs to the Design Coach/MCP workstream, not this frontend identity change.
+
+## Visual contract
+
+- Neutral dark surfaces are the default. Light mode remains available and an
+ existing explicit light preference is respected.
+- Violet identifies the PurplePrint family, focus, selected tools and primary
+ actions. It does not replace the functional colors for recording, progress,
+ success, warnings or errors.
+- Borders and small elevation steps carry panel hierarchy. Overlays can be more
+ prominent, while the editing surface and footage remain the visual focus.
+- The editor's minimum 800px desktop width retains mode and export controls;
+ chat and the inspector start collapsed when their width would crowd the stage.
+
+The implementation lives in `src/styles/design-tokens.css` and the active editor
+and capture surfaces. The checked-in Studio icon assets are generated from
+`public/purpleprint-studio.svg` geometry by
+`scripts/generate-studio-icons.py` (Pillow is needed only to regenerate them).
+
+## Compatibility and provenance
+
+- Visible product name: **PurplePrint Studio**. Descriptor: **Agentic Film Editor**.
+- The fork's technical app ID, package/data profile name, GitHub update origin,
+ `.openscreen` project extension and `openscreen` CLI command remain unchanged.
+ Test these boundaries in a packaged upgrade before a public release.
+- Preserve the upstream MIT license, copyright notices and independent-fork
+ disclosure. Do not imply OpenScreen maintainer endorsement.
+- Do not add a paywall to this editor as part of the Design Coach bundle.
+
+## Release gate
+
+The `.11` clean Windows package and owner-driven recording → MP4 export passed
+from the product-named installation directory. Its manual update check reports
+"up to date"; a real future-version updater download/install cannot be tested
+until a later release exists. Decide how to handle the unsigned Windows
+installer before public publication.
+A separate macOS release still needs a real Mac record → edit → export pass.
+A stable Mac consumer release additionally needs Developer ID signing,
+notarization and a privacy-permission review. Design Coach/MCP linkage is a
+separate workstream.
+
+| Gate | Current evidence |
+| --- | --- |
+| Windows package identity | The `.11` unsigned NSIS package was built cleanly on a Windows CI runner with libclang. Owner-approved installation reused the existing per-user location without another nested folder. Uninstall registration and installed ASAR report `.11`; the Start-menu shortcut points to the installed executable. Seven installed speech runtime files match the fork-pinned manifest. |
+| Profile continuity | Installed OpenScreen Subscription `.7` created an isolated-profile project with synthetic H.264 media. Studio `.8` reopened its clip, light theme and preferences, then exported a decodable MP4. On the owner's live profile, three project documents and the AI configuration were hash-identical to the verified pre-install backup. The installed `.8` binary separately loaded copies of all three real project documents and opened the editor without outputting their contents; private footage was not exported. |
+| Windows native capture | Packaged WGC recorded a generated QA window with microphone, system audio and camera disabled. The saved project reopened and exported a decodable MP4. Control was automated below the OS hit-test. Installed `.8` and `.9` were also recorded by the owner with a physical mouse; `.9` exported MP4 successfully. On installed `.10`, the owner reported that the first attempt showed "already running" after clicking Record again while the native helper had not acknowledged startup; retrying recorded and exported MP4. `.11` blocks this overlap and shows "Starting…" while the helper starts. The owner reported a successful `.11` test recording and MP4 export; visibility of the brief starting label was not separately confirmed. |
+| Silent-recording transcription | The `.8` recording contained video but no audio stream. Automatic transcription showed "Failed to fetch" because its WASM URL resolved outside packaged `dist/wasm`. Installed `.9` corrected the URL: another video-only recording produced no error toast, and its saved asset carries the expected `no-audio` verdict. |
+| Installation upgrade | Owner-approved `.8` NSIS upgrade reused the per-user uninstall registration and created a product-named child folder under the previous custom root. Subsequent `.9`, `.10` and `.11` upgrades reused that exact folder. Before `.11`, 2,868 profile files, 188 installed files and a `.10` rollback installer were backed up and verified; six critical project/recording/AI-config hashes remained unchanged after upgrade. `.10` fixed release discovery for the fork's `subscription` prereleases; the installed `.11` manual update check reports "up to date". A real future-version updater download/install remains unverified. |
+| macOS | [CI run 36358715037](https://github.com/PurplePrintAI/openscreen-subscription/actions/runs/36358715037) built `.9` Apple Silicon and Intel DMGs from commit `f81a8b9d`. Both passed bundle ID, architecture, native payload, ad-hoc signature structure, CLI boot and mounted-DMG checks; downloaded SHA-256 values matched the workflow report. Real Mac recording/edit/export, Developer ID signing and notarization remain unverified. |
+
+The source selector failed to list the QA window on the first automated attempt
+after it appeared, then found it on retry. Treat that as a transient observation,
+not evidence that source discovery is reliable on every launch.
diff --git a/electron-builder.fork.json b/electron-builder.fork.json
index 8fbb6aa8a..42c65e600 100644
--- a/electron-builder.fork.json
+++ b/electron-builder.fork.json
@@ -1,10 +1,10 @@
{
"extends": "./electron-builder.json5",
"appId": "io.github.purpleprintai.openscreen-subscription",
- "productName": "OpenScreen Subscription",
+ "productName": "PurplePrint Studio",
"extraMetadata": {
"name": "openscreen-subscription",
- "description": "Unofficial OpenScreen fork with optional ChatGPT and local Claude integration",
+ "description": "PurplePrint Studio, an agentic film editor based on OpenScreen",
"author": { "name": "PurplePrintAI", "url": "https://github.com/PurplePrintAI" },
"homepage": "https://github.com/PurplePrintAI/openscreen-subscription",
"repository": {
@@ -15,18 +15,18 @@
"publish": { "provider": "github", "owner": "PurplePrintAI", "repo": "openscreen-subscription" },
"win": {
"target": ["nsis"],
- "artifactName": "OpenScreen-Subscription-${version}-Windows-x64-Setup.${ext}"
+ "artifactName": "PurplePrint-Studio-${version}-Windows-x64-Setup.${ext}"
},
"mac": {
"target": ["dmg"],
- "artifactName": "OpenScreen-Subscription-${version}-macOS-${arch}.${ext}"
+ "artifactName": "PurplePrint-Studio-${version}-macOS-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createStartMenuShortcut": true,
"createDesktopShortcut": false,
- "shortcutName": "OpenScreen Subscription"
+ "shortcutName": "PurplePrint Studio"
},
"extraResources": [
{ "from": "FORK.md", "to": "FORK.md" },
diff --git a/electron/about.test.ts b/electron/about.test.ts
index 96e401630..d7b271f2e 100644
--- a/electron/about.test.ts
+++ b/electron/about.test.ts
@@ -4,6 +4,7 @@ import {
type AboutFacts,
COPYRIGHT,
formatAboutDetail,
+ PRODUCT_NAME,
usesNativeAboutPanel,
WEBSITE_URL,
} from "./about";
@@ -22,6 +23,11 @@ function facts(overrides: Partial = {}): AboutFacts {
}
describe("formatAboutDetail", () => {
+ it("uses the Studio display identity without dropping upstream provenance", () => {
+ expect(PRODUCT_NAME).toBe("PurplePrint Studio");
+ expect(formatAboutDetail(facts())).toContain("Unofficial fork of getopenscreen/openscreen");
+ });
+
it("lays the runtime, the install and the project out one per line", () => {
expect(formatAboutDetail(facts())).toBe(
[
diff --git a/electron/about.ts b/electron/about.ts
index 897748851..6231495a4 100644
--- a/electron/about.ts
+++ b/electron/about.ts
@@ -9,10 +9,9 @@
import type { InstallChannel } from "./install-channel";
export const WEBSITE_URL = "https://github.com/PurplePrintAI/openscreen-subscription";
-/** The brand spelling, for the surfaces we render ourselves. NOT `app.name`: that resolves to
- * electron-builder's `productName` ("Openscreen") when packaged and to package.json's `name`
- * ("openscreen") in dev, so the About box would disagree with its own title bar. */
-export const PRODUCT_NAME = "OpenScreen Subscription";
+/** The brand spelling for our own surfaces. `app.name` can resolve differently in dev and
+ * packaged builds, so the About box must not derive its title from that value. */
+export const PRODUCT_NAME = "PurplePrint Studio";
/** The collective form, and deliberately NOT the whole of LICENSE. LICENSE carries two holders:
* Siddharth Vaddem, who created the project — MIT obliges us to keep that notice on a codebase
* that still contains his code — and the contributors collectively. This is the line every user
@@ -45,7 +44,7 @@ export interface AboutFacts {
node: string;
}
-/** The block under "Openscreen ". Untranslated on purpose: every line is a version
+/** The block under "PurplePrint Studio ". Untranslated on purpose: every line is a version
* number, a platform identifier or a URL, and a pasted bug report reads the same whatever
* locale the reporter runs.
*
diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts
index 5a0966398..ef4c58188 100644
--- a/electron/ai-edition/agent-tools.ts
+++ b/electron/ai-edition/agent-tools.ts
@@ -1149,7 +1149,7 @@ export function executeAgentTool(
assetId: load.assetId,
note:
"Checked: this asset has no cursor-telemetry sidecar. That normally means it " +
- "was imported rather than recorded with OpenScreen's cursor recorder. This is " +
+ "was imported rather than recorded with PurplePrint Studio's cursor recorder. This is " +
"a fact about the asset, not a limit of yours.",
}),
};
diff --git a/electron/ai-edition/claude/cli.ts b/electron/ai-edition/claude/cli.ts
index a8524f3eb..ebc8f29a3 100644
--- a/electron/ai-edition/claude/cli.ts
+++ b/electron/ai-edition/claude/cli.ts
@@ -368,12 +368,12 @@ export class ClaudeCli {
)
) {
throw new Error(
- "Claude enabled tools outside the OpenScreen editor. Check your CLI managed configuration.",
+ "Claude enabled tools outside the PurplePrint Studio editor. Check your CLI managed configuration.",
);
}
if (expectedTools.some((name) => !(event.tools as unknown[]).includes(name))) {
throw new Error(
- "Claude could not load the OpenScreen editor tools. Check your CLI MCP policy and connection.",
+ "Claude could not load the PurplePrint Studio editor tools. Check your CLI MCP policy and connection.",
);
}
}
diff --git a/electron/ai-edition/codex/app-server.ts b/electron/ai-edition/codex/app-server.ts
index 6b4a5ecb7..a2a2a78e2 100644
--- a/electron/ai-edition/codex/app-server.ts
+++ b/electron/ai-edition/codex/app-server.ts
@@ -177,7 +177,7 @@ export class CodexAppServer {
});
try {
await this.request("initialize", {
- clientInfo: { name: "openscreen_subscription", title: "OpenScreen Subscription", version },
+ clientInfo: { name: "openscreen_subscription", title: "PurplePrint Studio", version },
capabilities: { experimentalApi: true },
});
this.send({ method: "initialized", params: {} });
@@ -226,7 +226,7 @@ export class CodexAppServer {
if (payload.id !== undefined) {
if (payload.method === "item/tool/call" && turn) {
const tool = turn.tools.get(String(params.tool));
- let text = "Tool is not available in this OpenScreen turn.";
+ let text = "Tool is not available in this PurplePrint Studio turn.";
let success = false;
if (tool && ++turn.toolCalls <= 1000) {
try {
@@ -245,7 +245,7 @@ export class CodexAppServer {
// No shell, file edits, remote MCP approvals or other vendor tools are approved here.
this.send({
id: payload.id,
- error: { code: -32601, message: "Only OpenScreen editor tools are supported." },
+ error: { code: -32601, message: "Only PurplePrint Studio editor tools are supported." },
});
}
return;
@@ -288,7 +288,7 @@ export class CodexAppServer {
child?.kill();
}
close(): void {
- this.fail(new Error("OpenScreen closed the Codex connection."));
+ this.fail(new Error("PurplePrint Studio closed the Codex connection."));
}
async status(): Promise {
diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts
index b1a15c908..2dd4dcce1 100644
--- a/electron/ai-edition/deep-agent/service.ts
+++ b/electron/ai-edition/deep-agent/service.ts
@@ -100,7 +100,7 @@ const CONSENT_PROMPT_BLOCK = [
// string and NOTHING else — the deepagents regression was invisible precisely
// because the middlewares appended their prompts downstream of this constant.
const BASE_SYSTEM_PROMPT = [
- "You are an AI video editor working inside OpenScreen. The user is editing a recording.",
+ "You are an AI video editor working inside PurplePrint Studio. The user is editing a recording.",
"Help them cut silences, tighten pacing, add captions, and rewrite titles.",
"Be concise, action-oriented, and reference the timeline or transcript by time when relevant.",
"You can call the tools below against the live document snapshot; the runtime executes each edit and feeds the result back into the loop.",
@@ -121,7 +121,7 @@ const BASE_SYSTEM_PROMPT = [
"- Deleting is a first-class action, not a workaround: removeTrim, removeModifier, removeClip. Never fake a deletion by re-adding an element or zeroing it out (span 0, speed 1×) — that leaves it in the document and misreports what you did.",
"If nothing in the list does what was asked, say so; do not approximate it with a bigger tool.",
"",
- "Cursor telemetry: while the screen was captured, OpenScreen recorded where the pointer went. assets[].hasCursorTelemetry in getCurrentDocument says which assets carry it, and getCursorTrack returns the recorded track for one of them — positions over time, and the pointer shape at each moment. What it means is yours to read.",
+ "Cursor telemetry: while the screen was captured, PurplePrint Studio recorded where the pointer went. assets[].hasCursorTelemetry in getCurrentDocument says which assets carry it, and getCursorTrack returns the recorded track for one of them — positions over time, and the pointer shape at each moment. What it means is yours to read.",
"Blindness is not evidence. When a tool reports it could not read something (reason 'unavailable'), that is a limit of your runtime, not a fact about the project. Only an explicit negative — reason 'no-sidecar', or a false flag — supports telling the user the data is not there.",
"",
"Honesty rules: if a request has NO matching tool (e.g. deleting an asset/recording, exporting), say so plainly — do not substitute a different edit and report it as the requested one. After your edits, if you are at all unsure the document ended up as intended, call getCurrentDocument and reconcile what you claim with the real state; each tool result already tells you exactly what it did, so never report a change the results don't support.",
diff --git a/electron/cli/args.ts b/electron/cli/args.ts
index d9e33819e..67e7ed3f2 100644
--- a/electron/cli/args.ts
+++ b/electron/cli/args.ts
@@ -61,7 +61,7 @@ const SUBCOMMANDS = new Set([
"-h",
]);
-export const CLI_USAGE = `OpenScreen CLI
+export const CLI_USAGE = `PurplePrint Studio CLI (openscreen command)
Usage:
openscreen export [options] Render a project to MP4/GIF
diff --git a/electron/fork-release.test.ts b/electron/fork-release.test.ts
index 0a9adc623..711d93593 100644
--- a/electron/fork-release.test.ts
+++ b/electron/fork-release.test.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import path from "node:path";
import { getConfig } from "app-builder-lib/out/util/config/config";
import { describe, expect, it } from "vitest";
@@ -5,7 +6,9 @@ import { describe, expect, it } from "vitest";
describe("fork release identity", () => {
it("keeps installation and update identity separate from upstream", async () => {
const config = await getConfig(process.cwd(), path.resolve("electron-builder.fork.json"), null);
- expect(config.appId).not.toBe("com.etiennelescot.openscreen");
+ // The technical ID and profile name stay stable across the visible rebrand.
+ expect(config.appId).toBe("io.github.purpleprintai.openscreen-subscription");
+ expect(config.productName).toBe("PurplePrint Studio");
expect(config.extraMetadata?.name).toBe("openscreen-subscription");
expect(config.extraMetadata?.author).toEqual({
name: "PurplePrintAI",
@@ -18,6 +21,7 @@ describe("fork release identity", () => {
repo: "openscreen-subscription",
});
expect(config.nsis?.createStartMenuShortcut).toBe(true);
+ expect(config.nsis?.shortcutName).toBe("PurplePrint Studio");
const targets = (config.extraResources as Array<{ to: string }>).map((entry) => entry.to);
// Duplicate resource targets race in copyFile on Windows.
expect(new Set(targets).size).toBe(targets.length);
@@ -29,5 +33,9 @@ describe("fork release identity", () => {
"FORK-UPSTREAM-REVIEW.md",
]),
);
+ const macGuide = fs.readFileSync(path.resolve("MACOS-INSTALLATION.md"), "utf8");
+ expect(macGuide).toContain(`# ${config.productName} on macOS`);
+ expect(macGuide).toContain(`drag **${config.productName}** to **Applications**`);
+ expect(macGuide).not.toContain("OpenScreen Subscription");
});
});
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 958ef6d96..aaa6febfe 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -1920,7 +1920,7 @@ export function registerIpcHandlers(
const detail =
access.status === "missing-helper"
? "The cursor helper couldn't be found in this build, so the editable cursor can't be enabled. Rebuild the native helper (npm run build:native:mac) or switch the HUD cursor mode to system."
- : "Allow OpenScreen under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown.";
+ : "Allow PurplePrint Studio under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown.";
const messageOptions = {
type: "warning",
buttons: ["Open Accessibility Settings", "Cancel"],
@@ -1968,7 +1968,7 @@ export function registerIpcHandlers(
cancelId: 1,
message: "Screen Recording permission is required",
detail:
- "Allow OpenScreen in macOS System Settings, then come back and choose a screen or window.",
+ "Allow PurplePrint Studio in macOS System Settings, then come back and choose a screen or window.",
} satisfies Electron.MessageBoxOptions;
const result =
mainWin && !mainWin.isDestroyed()
@@ -3962,7 +3962,7 @@ export function registerIpcHandlers(
}
// Validate extension and readability
if (path.extname(filePath).toLowerCase() !== `.${PROJECT_FILE_EXTENSION}`) {
- return { success: false, message: "Not an Openscreen project file" };
+ return { success: false, message: "Not a .openscreen project file" };
}
const stats = await fs.stat(filePath).catch(() => null);
if (!stats?.isFile()) {
diff --git a/electron/main.ts b/electron/main.ts
index a85629bf3..162c57978 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -132,7 +132,7 @@ const isMac = process.platform === "darwin";
const trayIconSize = isMac ? 16 : 24;
// Tray Icons
-const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
+const defaultTrayIcon = getTrayIcon("purpleprint-studio.png", trayIconSize);
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
function createWindow() {
@@ -209,11 +209,11 @@ function setupApplicationMenu() {
if (isMac) {
template.push({
- label: app.name,
+ label: PRODUCT_NAME,
submenu: [
{
role: "about",
- label: mainT("common", "actions.about") || "About OpenScreen",
+ label: mainT("common", "actions.about") || "About PurplePrint Studio",
},
{ type: "separator" as const },
{
@@ -239,7 +239,7 @@ function setupApplicationMenu() {
{ type: "separator" },
{
role: "hide",
- label: mainT("common", "actions.hide") || "Hide OpenScreen",
+ label: mainT("common", "actions.hide") || "Hide PurplePrint Studio",
},
{
role: "hideOthers",
@@ -375,7 +375,7 @@ function setupApplicationMenu() {
]
: []),
{
- label: mainT("common", "actions.about") || "About OpenScreen",
+ label: mainT("common", "actions.about") || "About PurplePrint Studio",
click: runAboutDialog,
},
{ type: "separator" as const },
@@ -504,7 +504,7 @@ async function presentAboutDialog() {
const heading = `${PRODUCT_NAME} ${facts.version}`;
const choice = await showMessageBox({
type: "info",
- title: mainT("common", "actions.about") || "About OpenScreen",
+ title: mainT("common", "actions.about") || "About PurplePrint Studio",
message: heading,
detail,
buttons: [
@@ -779,10 +779,10 @@ function updateTrayMenu(recording: boolean = false) {
isMac
? {
role: "about" as const,
- label: mainT("common", "actions.about") || "About OpenScreen",
+ label: mainT("common", "actions.about") || "About PurplePrint Studio",
}
: {
- label: mainT("common", "actions.about") || "About OpenScreen",
+ label: mainT("common", "actions.about") || "About PurplePrint Studio",
click: runAboutDialog,
},
// Right next to About, and reachable without opening any window: this is the
diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts
index 4393dc592..30a882cf4 100644
--- a/electron/native-bridge/services/aiEditionService.ts
+++ b/electron/native-bridge/services/aiEditionService.ts
@@ -243,7 +243,7 @@ export class AiEditionService {
return {
success: false,
error:
- "Manage Claude authentication in the official CLI. OpenScreen does not store its credentials.",
+ "Manage Claude authentication in the official CLI. PurplePrint Studio does not store its credentials.",
};
if (providerId === "codex-subscription")
return { success: false, error: "Use ChatGPT sign-in, not an API key." };
diff --git a/electron/update-checker.test.ts b/electron/update-checker.test.ts
index 3fbd65e35..ff6528a25 100644
--- a/electron/update-checker.test.ts
+++ b/electron/update-checker.test.ts
@@ -9,6 +9,22 @@ function releaseResponse(payload: unknown, status = 200) {
};
}
+const RELEASES_API =
+ "https://api.github.com/repos/PurplePrintAI/openscreen-subscription/releases?per_page=100";
+
+function release(
+ tag: string,
+ overrides: Partial<{ html_url: string; draft: boolean; prerelease: boolean }> = {},
+) {
+ return {
+ tag_name: tag,
+ html_url: `https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/${tag}`,
+ draft: false,
+ prerelease: tag.includes("-"),
+ ...overrides,
+ };
+}
+
describe("compareVersions", () => {
it("implements semantic version ordering for stable and prerelease builds", () => {
expect(compareVersions("v2.0.0", "1.9.9")).toBeGreaterThan(0);
@@ -32,123 +48,113 @@ describe("compareVersions", () => {
});
describe("checkLatestRelease", () => {
- it("reports a newer official stable release", async () => {
- const fetchLatest = vi.fn().mockResolvedValue(
- releaseResponse({
- tag_name: "v1.10.0",
- html_url: "https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v1.10.0",
- draft: false,
- prerelease: false,
- }),
- );
+ it("selects the highest published release in the installed prerelease channel", async () => {
+ const fetchLatest = vi
+ .fn()
+ .mockResolvedValue(
+ releaseResponse([
+ release("v1.10.0-subscription.6"),
+ release("v1.10.0-beta.20"),
+ release("v1.10.0-subscription.10", { draft: true }),
+ release("v1.10.0-subscription.9"),
+ release("v1.10.0-subscription.8"),
+ release("v1.10.0"),
+ ]),
+ );
- await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).resolves.toEqual({
+ await expect(
+ checkLatestRelease({ currentVersion: "1.10.0-subscription.7", fetchLatest }),
+ ).resolves.toEqual({
kind: "available",
- currentVersion: "1.9.0",
- latestVersion: "1.10.0",
- releaseUrl: "https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v1.10.0",
+ currentVersion: "1.10.0-subscription.7",
+ latestVersion: "1.10.0-subscription.9",
+ releaseUrl:
+ "https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v1.10.0-subscription.9",
});
expect(fetchLatest).toHaveBeenCalledWith(
- "https://api.github.com/repos/PurplePrintAI/openscreen-subscription/releases/latest",
+ RELEASES_API,
expect.objectContaining({
headers: expect.objectContaining({ Accept: "application/vnd.github+json" }),
}),
);
});
- it("reports current when the installed version is equal or newer", async () => {
- const fetchLatest = vi.fn().mockResolvedValue(
- releaseResponse({
- tag_name: "v1.9.0",
- html_url: "https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v1.9.0",
- draft: false,
- prerelease: false,
- }),
- );
+ it("reports current when only older subscription releases exist", async () => {
+ const fetchLatest = vi
+ .fn()
+ .mockResolvedValue(releaseResponse([release("v1.10.0-subscription.7")]));
+ await expect(
+ checkLatestRelease({ currentVersion: "1.10.0-subscription.9", fetchLatest }),
+ ).resolves.toEqual({
+ kind: "current",
+ currentVersion: "1.10.0-subscription.9",
+ latestVersion: "1.10.0-subscription.7",
+ });
+ });
- await expect(checkLatestRelease({ currentVersion: "1.9.1", fetchLatest })).resolves.toEqual({
+ it("reports current when the fork has no published releases", async () => {
+ const fetchLatest = vi.fn().mockResolvedValue(releaseResponse([]));
+ await expect(
+ checkLatestRelease({ currentVersion: "1.10.0-subscription.9", fetchLatest }),
+ ).resolves.toEqual({
kind: "current",
- currentVersion: "1.9.1",
- latestVersion: "1.9.0",
+ currentVersion: "1.10.0-subscription.9",
+ latestVersion: "1.10.0-subscription.9",
});
+ });
+
+ it("keeps stable installs on the stable channel", async () => {
+ const fetchLatest = vi
+ .fn()
+ .mockResolvedValue(releaseResponse([release("v1.11.0-subscription.2"), release("v1.10.0")]));
await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).resolves.toEqual({
- kind: "current",
+ kind: "available",
currentVersion: "1.9.0",
- latestVersion: "1.9.0",
+ latestVersion: "1.10.0",
+ releaseUrl: "https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v1.10.0",
});
});
+ it("ignores drafts, other channels, and malformed entries", async () => {
+ const fetchLatest = vi
+ .fn()
+ .mockResolvedValue(
+ releaseResponse([
+ release("v1.10.0-subscription.12", { draft: true }),
+ { tag_name: "not a version", html_url: "https://example.com", draft: false },
+ release("v1.10.0-beta.11"),
+ release("v1.10.0-subscription.8"),
+ ]),
+ );
+ await expect(
+ checkLatestRelease({ currentVersion: "1.10.0-subscription.7", fetchLatest }),
+ ).resolves.toMatchObject({ kind: "available", latestVersion: "1.10.0-subscription.8" });
+ });
+
it("forwards the cancellation signal", async () => {
- const fetchLatest = vi.fn().mockResolvedValue(
- releaseResponse({
- tag_name: "v1.9.0",
- html_url: "https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v1.9.0",
- draft: false,
- prerelease: false,
- }),
- );
+ const fetchLatest = vi.fn().mockResolvedValue(releaseResponse([]));
const controller = new AbortController();
-
await checkLatestRelease({
- currentVersion: "1.9.0",
+ currentVersion: "1.10.0-subscription.9",
fetchLatest,
signal: controller.signal,
});
-
expect(fetchLatest).toHaveBeenCalledWith(
- "https://api.github.com/repos/PurplePrintAI/openscreen-subscription/releases/latest",
+ RELEASES_API,
expect.objectContaining({ signal: controller.signal }),
);
});
it.each([
- { draft: true, prerelease: false },
- { draft: false, prerelease: true },
- ])("rejects draft and prerelease payloads: %o", async ({ draft, prerelease }) => {
- const fetchLatest = vi.fn().mockResolvedValue(
- releaseResponse({
- tag_name: "v2.0.0",
- html_url: "https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v2.0.0",
- draft,
- prerelease,
- }),
- );
-
- await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).rejects.toThrow(
- "invalid GitHub release response",
- );
- });
-
- it("rejects a release URL outside the official repository", async () => {
- const fetchLatest = vi.fn().mockResolvedValue(
- releaseResponse({
- tag_name: "v9.9.9",
- html_url: "https://example.com/openscreen-9.9.9.exe",
- draft: false,
- prerelease: false,
- }),
- );
-
- await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).rejects.toThrow(
- "untrusted release URL",
- );
- });
-
- it.each([
+ "https://example.com/openscreen-9.9.9.exe",
"https://github.com/PurplePrintAI/openscreen-subscription/releases/download/v9.9.9/app.zip",
"https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v9.9.8",
"https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v9.9.9?download=1",
"https://github.com/PurplePrintAI/openscreen-subscription/releases/tag/v9.9.9#notes",
- ])("rejects an invalid official-repository URL: %s", async (htmlUrl) => {
- const fetchLatest = vi.fn().mockResolvedValue(
- releaseResponse({
- tag_name: "v9.9.9",
- html_url: htmlUrl,
- draft: false,
- prerelease: false,
- }),
- );
-
+ ])("rejects an untrusted candidate release URL: %s", async (htmlUrl) => {
+ const fetchLatest = vi
+ .fn()
+ .mockResolvedValue(releaseResponse([release("v9.9.9", { html_url: htmlUrl })]));
await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).rejects.toThrow(
"untrusted release URL",
);
@@ -159,7 +165,6 @@ describe("checkLatestRelease", () => {
await expect(
checkLatestRelease({ currentVersion: "1.9.0", fetchLatest: unavailable }),
).rejects.toThrow("GitHub release check failed (503)");
-
const malformed = vi.fn().mockResolvedValue(releaseResponse({ tag_name: "v2.0.0" }));
await expect(
checkLatestRelease({ currentVersion: "1.9.0", fetchLatest: malformed }),
diff --git a/electron/update-checker.ts b/electron/update-checker.ts
index 7ea9070b8..ca201f09a 100644
--- a/electron/update-checker.ts
+++ b/electron/update-checker.ts
@@ -1,5 +1,8 @@
-const LATEST_RELEASE_API =
- "https://api.github.com/repos/PurplePrintAI/openscreen-subscription/releases/latest";
+// GitHub's /releases/latest omits prereleases. This fork currently publishes
+// subscription.N builds as prereleases, so that endpoint returns 404 even when
+// the app has an update feed. Inspect the published releases instead.
+const RELEASES_API =
+ "https://api.github.com/repos/PurplePrintAI/openscreen-subscription/releases?per_page=100";
const OFFICIAL_RELEASE_PREFIX = "/PurplePrintAI/openscreen-subscription/releases/tag/";
interface ReleaseResponse {
@@ -124,7 +127,7 @@ export async function checkLatestRelease(options: {
fetchLatest: FetchLatestRelease;
signal?: AbortSignal;
}): Promise {
- const response = await options.fetchLatest(LATEST_RELEASE_API, {
+ const response = await options.fetchLatest(RELEASES_API, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -134,32 +137,38 @@ export async function checkLatestRelease(options: {
if (!response.ok) throw new Error(`GitHub release check failed (${response.status})`);
const payload = await response.json();
- if (
- typeof payload !== "object" ||
- payload === null ||
- typeof (payload as Record).tag_name !== "string" ||
- typeof (payload as Record).html_url !== "string" ||
- (payload as Record).draft !== false ||
- (payload as Record).prerelease !== false
- ) {
- throw new Error("invalid GitHub release response");
- }
- const release = payload as { tag_name: string; html_url: string };
+ if (!Array.isArray(payload)) throw new Error("invalid GitHub release response");
const current = parseVersion(options.currentVersion);
- const latest = parseVersion(release.tag_name);
- const comparison = compareVersions(latest.normalized, current.normalized);
- if (comparison <= 0) {
+ const channel = current.prerelease[0] ?? null;
+ let latest: { version: string; tag: string; url: string } | null = null;
+ for (const entry of payload) {
+ if (typeof entry !== "object" || entry === null || entry.draft !== false) continue;
+ if (typeof entry.tag_name !== "string" || typeof entry.html_url !== "string") continue;
+ let version: ParsedVersion;
+ try {
+ version = parseVersion(entry.tag_name);
+ } catch {
+ continue;
+ }
+ // Match electron-updater's GitHub channel: stable installs stay stable;
+ // subscription prereleases only advance within the subscription channel.
+ if ((version.prerelease[0] ?? null) !== channel) continue;
+ if (!latest || compareVersions(version.normalized, latest.version) > 0) {
+ latest = { version: version.normalized, tag: entry.tag_name, url: entry.html_url };
+ }
+ }
+ if (!latest || compareVersions(latest.version, current.normalized) <= 0) {
return {
kind: "current",
currentVersion: current.normalized,
- latestVersion: latest.normalized,
+ latestVersion: latest?.version ?? current.normalized,
};
}
return {
kind: "available",
currentVersion: current.normalized,
- latestVersion: latest.normalized,
- releaseUrl: officialReleaseUrl(release.html_url, release.tag_name),
+ latestVersion: latest.version,
+ releaseUrl: officialReleaseUrl(latest.url, latest.tag),
};
}
diff --git a/icons/icons/mac/icon.icns b/icons/icons/mac/icon.icns
index 02de106cf..9a376a3f3 100644
Binary files a/icons/icons/mac/icon.icns and b/icons/icons/mac/icon.icns differ
diff --git a/icons/icons/png/1024x1024.png b/icons/icons/png/1024x1024.png
index 447f2d147..3c567976b 100644
Binary files a/icons/icons/png/1024x1024.png and b/icons/icons/png/1024x1024.png differ
diff --git a/icons/icons/png/128x128.png b/icons/icons/png/128x128.png
index a9d9acf24..fadb14dfc 100644
Binary files a/icons/icons/png/128x128.png and b/icons/icons/png/128x128.png differ
diff --git a/icons/icons/png/16x16.png b/icons/icons/png/16x16.png
index 4b8fe811a..194f657a2 100644
Binary files a/icons/icons/png/16x16.png and b/icons/icons/png/16x16.png differ
diff --git a/icons/icons/png/24x24.png b/icons/icons/png/24x24.png
index ebffa992c..0296ff095 100644
Binary files a/icons/icons/png/24x24.png and b/icons/icons/png/24x24.png differ
diff --git a/icons/icons/png/256x256.png b/icons/icons/png/256x256.png
index 727d064c6..b96680f76 100644
Binary files a/icons/icons/png/256x256.png and b/icons/icons/png/256x256.png differ
diff --git a/icons/icons/png/32x32.png b/icons/icons/png/32x32.png
index 3f04dd210..d5ab783fc 100644
Binary files a/icons/icons/png/32x32.png and b/icons/icons/png/32x32.png differ
diff --git a/icons/icons/png/48x48.png b/icons/icons/png/48x48.png
index 4814b0525..cfbb82ba7 100644
Binary files a/icons/icons/png/48x48.png and b/icons/icons/png/48x48.png differ
diff --git a/icons/icons/png/512x512.png b/icons/icons/png/512x512.png
index 7ec2a0b4c..d15169965 100644
Binary files a/icons/icons/png/512x512.png and b/icons/icons/png/512x512.png differ
diff --git a/icons/icons/png/64x64.png b/icons/icons/png/64x64.png
index be64b4dbc..e05e8ceab 100644
Binary files a/icons/icons/png/64x64.png and b/icons/icons/png/64x64.png differ
diff --git a/icons/icons/win/icon.ico b/icons/icons/win/icon.ico
index 57df136d6..86daae6aa 100644
Binary files a/icons/icons/win/icon.ico and b/icons/icons/win/icon.ico differ
diff --git a/index.html b/index.html
index a5d20360c..eded762b0 100644
--- a/index.html
+++ b/index.html
@@ -2,25 +2,29 @@
-
+
-
+ PurplePrint Studio
-
+
diff --git a/nix/package.nix b/nix/package.nix
index 31f8710bc..8859f5051 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -51,7 +51,7 @@ buildNpmPackage {
);
};
- npmDepsHash = "sha256-XnI/b4OSUAhY3IeiHZNhurqJfpJ6H10AwR04ZGBLad4=";
+ npmDepsHash = "sha256-MNNWND3d3cZLGvCjU04ZgPPrvnZPtfjD/K9B/mYQA4Q=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
diff --git a/package-lock.json b/package-lock.json
index 94739c7d3..bdf64d5ce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openscreen",
- "version": "1.10.0-subscription.7",
+ "version": "1.10.0-subscription.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
- "version": "1.10.0-subscription.7",
+ "version": "1.10.0-subscription.11",
"license": "MIT",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
diff --git a/package.json b/package.json
index 423c9efdf..25e95fba7 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
- "version": "1.10.0-subscription.7",
+ "version": "1.10.0-subscription.11",
"description": "Record your screen and polish the demo",
"homepage": "https://getopenscreen.com/",
"license": "MIT",
diff --git a/public/purpleprint-studio.png b/public/purpleprint-studio.png
new file mode 100644
index 000000000..e05e8ceab
Binary files /dev/null and b/public/purpleprint-studio.png differ
diff --git a/public/purpleprint-studio.svg b/public/purpleprint-studio.svg
new file mode 100644
index 000000000..509fc4070
--- /dev/null
+++ b/public/purpleprint-studio.svg
@@ -0,0 +1,5 @@
+
diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs
index 8c2f0ed83..76709d659 100644
--- a/scripts/fetch-ffmpeg.mjs
+++ b/scripts/fetch-ffmpeg.mjs
@@ -315,27 +315,95 @@ function ffmpegSdkDest() {
*/
function vendorFfmpegSdk(tmp, dest) {
// BtbN nests bin/ include/ lib/ under one versioned dir; find it by its headers.
+ console.log(`Locating FFmpeg SDK in verified archive for ${dest}`);
const root = findDirContaining(tmp, "include");
if (!root) {
throw new Error(
"No include/ directory inside the shared archive — cannot vendor the ffmpeg SDK.",
);
}
- // A dev machine has this as a junction; rm unlinks it rather than eating the
- // target, and we only get here on --force or when it is genuinely absent.
- fs.rmSync(dest, { recursive: true, force: true });
+ // Never write through a developer junction or a path outside this checkout.
+ // The destination name contains the pinned FFmpeg version, so an interrupted
+ // copy can be completed in place without deleting the whole SDK directory.
+ const thirdpartyPath = path.join(ROOT, "crates", "thirdparty");
+ fs.mkdirSync(thirdpartyPath, { recursive: true });
+ const thirdparty = fs.realpathSync.native(thirdpartyPath);
+ if (fs.lstatSync(dest, { throwIfNoEntry: false })?.isSymbolicLink()) {
+ throw new Error(`Refusing to write FFmpeg SDK through link: ${dest}`);
+ }
+ const resolvedDest = fs.existsSync(dest) ? fs.realpathSync.native(dest) : path.resolve(dest);
+ if (!samePath(path.dirname(resolvedDest), thirdparty)) {
+ throw new Error(`Refusing to replace FFmpeg SDK outside ${thirdparty}: ${resolvedDest}`);
+ }
fs.mkdirSync(path.dirname(dest), { recursive: true });
- // `verbatimSymlinks` matters on Linux, where BtbN ships lib/libavcodec.so ->
- // libavcodec.so.62.28.102. WITHOUT it, cpSync RESOLVES each link and writes an
- // absolute one pointing back into the extraction temp dir — which this function's
- // caller deletes immediately after, leaving every dev symlink dangling. The
- // headers then satisfy build.rs's assert while `-lavcodec` fails at link time,
- // which is exactly how this presented. Windows has no symlinks here, so the flag
- // is a no-op there.
- fs.cpSync(root, dest, { recursive: true, verbatimSymlinks: true });
+ console.log(`Copying verified FFmpeg SDK from ${root}`);
+ // Copy only build inputs, not the archive's executable and documentation. Walk
+ // files explicitly: Windows can terminate a bulk cpSync of this extracted tree
+ // without surfacing an exception, leaving an empty SDK directory that a simple
+ // existsSync check incorrectly treats as complete on the next run.
+ fs.mkdirSync(dest, { recursive: true });
+ for (const part of ["include", "lib"]) {
+ const source = path.join(root, part);
+ if (!fs.existsSync(source)) throw new Error(`FFmpeg SDK archive missing ${part}/`);
+ copySdkTree(source, path.join(dest, part));
+ }
+ console.log(`FFmpeg SDK copy finished: ${dest}`);
+ if (!fs.existsSync(path.join(dest, "include", "libavformat", "avformat.h"))) {
+ throw new Error(`FFmpeg SDK copy incomplete: ${dest}`);
+ }
console.log(`Vendored ffmpeg SDK (include/ + lib/) -> ${dest}`);
}
+function copySdkTree(source, dest) {
+ if (fs.lstatSync(dest, { throwIfNoEntry: false })?.isSymbolicLink()) {
+ throw new Error(`Refusing to write FFmpeg SDK through link: ${dest}`);
+ }
+ fs.mkdirSync(dest, { recursive: true });
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
+ const from = path.join(source, entry.name);
+ const to = path.join(dest, entry.name);
+ if (entry.isDirectory()) {
+ copySdkTree(from, to);
+ } else if (entry.isSymbolicLink()) {
+ // Preserve Linux's relative libav*.so links; never point at the temporary
+ // extraction tree, which disappears after staging.
+ const link = fs.readlinkSync(from);
+ if (fs.existsSync(to) || fs.lstatSync(to, { throwIfNoEntry: false })) {
+ if (fs.lstatSync(to).isSymbolicLink() && fs.readlinkSync(to) === link) continue;
+ throw new Error(`Refusing to replace a different FFmpeg SDK link: ${to}`);
+ }
+ fs.symlinkSync(link, to);
+ } else if (entry.isFile()) {
+ if (fs.lstatSync(to, { throwIfNoEntry: false })?.isSymbolicLink()) {
+ throw new Error(`Refusing to overwrite FFmpeg SDK link with file: ${to}`);
+ }
+ fs.copyFileSync(from, to);
+ } else {
+ throw new Error(`Unexpected FFmpeg SDK member: ${from}`);
+ }
+ }
+}
+
+function samePath(left, right) {
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
+}
+
+function cleanupFfmpegExtraction(tmp) {
+ const tempRoot = fs.realpathSync.native(os.tmpdir());
+ const resolved = fs.realpathSync.native(tmp);
+ if (
+ !samePath(path.dirname(resolved), tempRoot) ||
+ !path.basename(resolved).startsWith("openscreen-ffmpeg-")
+ ) {
+ throw new Error(`Refusing to remove unexpected FFmpeg extraction: ${resolved}`);
+ }
+ if (process.env.OPENSCREEN_KEEP_FFMPEG_TMP === "1") {
+ console.log(`Keeping FFmpeg extraction for diagnosis: ${tmp}`);
+ return;
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+}
+
/** First directory at or under `dir` that has a child named `name`. */
function findDirContaining(dir, name) {
if (fs.existsSync(path.join(dir, name))) return dir;
@@ -418,7 +486,11 @@ async function fetchSharedDlls(tag, binDir) {
// DLLs but not the SDK must still re-download — otherwise we skip here and
// the compositor build fails afterwards on the missing FFMPEG_DIR.
const sdkDest = ffmpegSdkDest();
- const sdkPresent = sdkDest == null || fs.existsSync(sdkDest);
+ const sdkPresent =
+ sdkDest == null ||
+ (fs.existsSync(path.join(sdkDest, "include", "libavformat", "avformat.h")) &&
+ fs.existsSync(path.join(sdkDest, "lib")) &&
+ fs.readdirSync(path.join(sdkDest, "lib")).some((name) => /^(lib)?avformat/i.test(name)));
if (alreadyVendored && sdkPresent && !process.argv.includes("--force")) {
console.log(
`\nShared ffmpeg libraries already present in ${binDir}. Use --force to re-vendor.`,
@@ -490,7 +562,7 @@ async function fetchSharedDlls(tag, binDir) {
if (sdkDest) vendorFfmpegSdk(tmp, sdkDest);
console.log("LGPL verified: safe to ship with an MIT app.");
} finally {
- fs.rmSync(tmp, { recursive: true, force: true });
+ cleanupFfmpegExtraction(tmp);
}
}
@@ -563,7 +635,7 @@ async function main() {
console.log(`\nVendored -> ${dest}`);
console.log("LGPL verified: safe to ship with an MIT app.");
} finally {
- fs.rmSync(tmp, { recursive: true, force: true });
+ cleanupFfmpegExtraction(tmp);
}
}
diff --git a/scripts/generate-studio-icons.py b/scripts/generate-studio-icons.py
new file mode 100644
index 000000000..be38e2cd4
--- /dev/null
+++ b/scripts/generate-studio-icons.py
@@ -0,0 +1,74 @@
+"""Regenerate PurplePrint Studio's checked-in app icons (requires Pillow).
+
+The geometry mirrors public/purpleprint-studio.svg. Run only when the mark
+changes; normal application and release builds consume the checked-in files.
+"""
+
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+
+
+ROOT = Path(__file__).resolve().parents[1]
+ICON_ROOT = ROOT / "icons" / "icons"
+PNG_SIZES = (16, 24, 32, 48, 64, 128, 256, 512, 1024)
+MASTER_SIZE = 4096
+
+
+def points(coords: tuple[tuple[float, float], ...]) -> list[tuple[float, float]]:
+ scale = MASTER_SIZE / 64
+ return [(x * scale, y * scale) for x, y in coords]
+
+
+def render_master() -> Image.Image:
+ image = Image.new("RGBA", (MASTER_SIZE, MASTER_SIZE), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(image)
+ draw.rounded_rectangle(
+ (0, 0, MASTER_SIZE - 1, MASTER_SIZE - 1),
+ radius=15 * MASTER_SIZE / 64,
+ fill="#8B5CF6",
+ )
+ draw.polygon(
+ points(
+ (
+ (29.5, 11.5), (33.8, 25), (47.5, 29.3), (33.8, 33.7),
+ (29.5, 47.3), (25.1, 33.7), (11.5, 29.3), (25.1, 25),
+ )
+ ),
+ fill="#FFFFFF",
+ )
+ draw.polygon(
+ points(
+ (
+ (47.5, 39), (49.2, 44.3), (54.5, 46), (49.2, 47.7),
+ (47.5, 53), (45.8, 47.7), (40.5, 46), (45.8, 44.3),
+ )
+ ),
+ fill=(255, 255, 255, 230),
+ )
+ return image
+
+
+def main() -> None:
+ master = render_master()
+ png_dir = ICON_ROOT / "png"
+ png_dir.mkdir(parents=True, exist_ok=True)
+ for size in PNG_SIZES:
+ master.resize((size, size), Image.Resampling.LANCZOS).save(
+ png_dir / f"{size}x{size}.png", format="PNG"
+ )
+
+ icon_1024 = master.resize((1024, 1024), Image.Resampling.LANCZOS)
+ win_dir = ICON_ROOT / "win"
+ win_dir.mkdir(parents=True, exist_ok=True)
+ icon_1024.save(
+ win_dir / "icon.ico", format="ICO", sizes=[(s, s) for s in (16, 24, 32, 48, 64, 128, 256)]
+ )
+
+ mac_dir = ICON_ROOT / "mac"
+ mac_dir.mkdir(parents=True, exist_ok=True)
+ icon_1024.save(mac_dir / "icon.icns", format="ICNS")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/validate-fork-windows-package.mjs b/scripts/validate-fork-windows-package.mjs
new file mode 100644
index 000000000..987508b63
--- /dev/null
+++ b/scripts/validate-fork-windows-package.mjs
@@ -0,0 +1,142 @@
+import { createHash } from "node:crypto";
+import {
+ createReadStream,
+ existsSync,
+ readdirSync,
+ readFileSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import path from "node:path";
+import { extractFile, listPackage } from "@electron/asar";
+
+const root = path.resolve(import.meta.dirname, "..");
+const { version } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
+const release = path.join(root, "release", version);
+const unpacked = path.join(release, "win-unpacked");
+const resources = path.join(unpacked, "resources");
+const installerName = `PurplePrint-Studio-${version}-Windows-x64-Setup.exe`;
+const installer = path.join(release, installerName);
+const blockmap = `${installer}.blockmap`;
+const feed = path.join(release, "latest.yml");
+const asar = path.join(resources, "app.asar");
+const nativeDir = path.join(resources, "electron", "native", "bin", "win32-x64");
+
+function requireFile(file) {
+ if (!existsSync(file) || !statSync(file).isFile()) {
+ throw new Error(`Missing packaged file: ${path.relative(root, file)}`);
+ }
+ return file;
+}
+
+async function sha256(file) {
+ const hash = createHash("sha256");
+ for await (const chunk of createReadStream(requireFile(file))) hash.update(chunk);
+ return hash.digest("hex");
+}
+
+for (const file of [
+ installer,
+ blockmap,
+ feed,
+ path.join(unpacked, "PurplePrint Studio.exe"),
+ asar,
+ ...[
+ "LICENSE",
+ "THIRD-PARTY-NOTICES.md",
+ "FORK.md",
+ "FORK-CHANGELOG.md",
+ "FORK-UPSTREAM-REVIEW.md",
+ ].map((name) => path.join(resources, name)),
+ ...[
+ "compositor_view.node",
+ "wgc-capture.exe",
+ "cursor-sampler.exe",
+ "whisper-stt-server.exe",
+ ].map((name) => path.join(nativeDir, name)),
+]) {
+ requireFile(file);
+}
+
+for (const family of ["avcodec", "avformat", "avutil"]) {
+ if (!readdirSync(nativeDir).some((name) => new RegExp(`^${family}-\\d+\\.dll$`).test(name))) {
+ throw new Error(`Missing packaged ${family} DLL`);
+ }
+}
+
+const updateConfig = readFileSync(path.join(resources, "app-update.yml"), "utf8");
+for (const line of ["owner: PurplePrintAI", "repo: openscreen-subscription", "provider: github"]) {
+ if (!updateConfig.split(/\r?\n/).includes(line))
+ throw new Error(`Unexpected update feed: ${line}`);
+}
+const latestYaml = readFileSync(feed, "utf8");
+if (!latestYaml.includes(`version: ${version}`) || !latestYaml.includes(installerName)) {
+ throw new Error("Windows update feed does not match the installer version/name");
+}
+
+const packagedFiles = new Set(
+ listPackage(asar).map((name) => name.replaceAll("\\", "/").replace(/^\/+/, "")),
+);
+if (!packagedFiles.has("dist/wasm/web-demuxer.wasm")) {
+ throw new Error("Packaged transcription WASM is missing");
+}
+const packagedManifest = JSON.parse(extractFile(asar, "package.json").toString());
+if (packagedManifest.version !== version || packagedManifest.name !== "openscreen-subscription") {
+ throw new Error("Packaged version or technical identity is wrong");
+}
+const transcribeChunk = [...packagedFiles].find((name) =>
+ /^dist\/assets\/transcribe-[^/]+\.js$/.test(name),
+);
+if (!transcribeChunk) throw new Error("Packaged transcription chunk is missing");
+const source = extractFile(asar, transcribeChunk.replaceAll("/", "\\")).toString();
+if (
+ !source.includes("./wasm/web-demuxer.wasm") ||
+ source.includes("../exporter/wasm/web-demuxer.wasm")
+) {
+ throw new Error("Packaged transcription WASM URL is incorrect");
+}
+
+const compositor = path.join(nativeDir, "compositor_view.node");
+const buildOutputCompositor = path.join(root, "crates", "target", "release", "compositor_view.dll");
+if ((await sha256(compositor)) !== (await sha256(buildOutputCompositor))) {
+ throw new Error("Packaged compositor differs from the native build output");
+}
+const wgc = path.join(nativeDir, "wgc-capture.exe");
+if (
+ (await sha256(wgc)) !==
+ (await sha256(path.join(root, "electron", "native", "bin", "win32-x64", "wgc-capture.exe")))
+) {
+ throw new Error("Packaged WGC helper differs from the native build output");
+}
+
+const installerSha = await sha256(installer);
+const blockmapSha = await sha256(blockmap);
+const feedSha = await sha256(feed);
+const validation = {
+ product: "PurplePrint Studio",
+ version,
+ architecture: "x64",
+ appId: "io.github.purpleprintai.openscreen-subscription",
+ sourceCommit: process.env.GITHUB_SHA ?? null,
+ prHeadCommit: process.env.GITHUB_HEAD_SHA || null,
+ checks: {
+ forkUpdateFeed: true,
+ packageIdentity: true,
+ attribution: true,
+ nativePayloadPresent: true,
+ compositorMatchesBuildOutput: true,
+ wgcMatchesFreshBuild: true,
+ transcriptionWasm: true,
+ windowsUpdateFeed: true,
+ },
+ installer: { name: installerName, bytes: statSync(installer).size, sha256: installerSha },
+};
+writeFileSync(
+ path.join(release, "windows-validation.json"),
+ `${JSON.stringify(validation, null, 2)}\n`,
+);
+writeFileSync(
+ path.join(release, "SHA256SUMS-Windows.txt"),
+ `${installerSha} ${installerName}\n${blockmapSha} ${path.basename(blockmap)}\n${feedSha} latest.yml\n`,
+);
+console.log(`Validated PurplePrint Studio ${version} Windows x64 package`);
diff --git a/src/App.tsx b/src/App.tsx
index aa7619d26..7369ef015 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -154,7 +154,7 @@ export default function App() {
return (
-
Openscreen
+
PurplePrint Studio
);
diff --git a/src/components/ai-edition/AnnotationOverlay.tsx b/src/components/ai-edition/AnnotationOverlay.tsx
index 81f8c2592..5c7685608 100644
--- a/src/components/ai-edition/AnnotationOverlay.tsx
+++ b/src/components/ai-edition/AnnotationOverlay.tsx
@@ -132,17 +132,16 @@ export function AnnotationOverlay({
"cursor-move",
isSelected &&
annotation.type !== "blur" &&
- "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
+ "ring-2 ring-[var(--accent)] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex,
pointerEvents: isSelected ? "auto" : "none",
- border:
- isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
+ border: isSelected && annotation.type !== "blur" ? "2px solid var(--accent)" : "none",
backgroundColor:
- isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
+ isSelected && annotation.type !== "blur" ? "var(--accent-soft)" : "transparent",
boxShadow:
- isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
+ isSelected && annotation.type !== "blur" ? "0 0 0 1px var(--accent-ring)" : "none",
}}
// Un flou en tracé libre se déplace et se redimensionne comme les autres : ce qui le
// bloquait, c'était la zone de saisie du tracé qui capturait le pointeur — et elle est
@@ -154,7 +153,7 @@ export function AnnotationOverlay({
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
- border: isSelected ? "2px solid #34B27B" : "none",
+ border: isSelected ? "2px solid var(--accent)" : "none",
borderRadius: "50%",
left: "-6px",
top: "-6px",
@@ -164,7 +163,7 @@ export function AnnotationOverlay({
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
- border: isSelected ? "2px solid #34B27B" : "none",
+ border: isSelected ? "2px solid var(--accent)" : "none",
borderRadius: "50%",
right: "-6px",
top: "-6px",
@@ -174,7 +173,7 @@ export function AnnotationOverlay({
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
- border: isSelected ? "2px solid #34B27B" : "none",
+ border: isSelected ? "2px solid var(--accent)" : "none",
borderRadius: "50%",
left: "-6px",
bottom: "-6px",
@@ -184,7 +183,7 @@ export function AnnotationOverlay({
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
- border: isSelected ? "2px solid #34B27B" : "none",
+ border: isSelected ? "2px solid var(--accent)" : "none",
borderRadius: "50%",
right: "-6px",
bottom: "-6px",
@@ -204,7 +203,7 @@ export function AnnotationOverlay({
est censé cacher — sans ce liseré il n'aurait AUCUN retour de sélection. */}
{isSelected && annotation.type === "blur" ? (
) : null}
diff --git a/src/components/ai-edition/EditorEmptyState.tsx b/src/components/ai-edition/EditorEmptyState.tsx
index 226171dca..9e2f7a399 100644
--- a/src/components/ai-edition/EditorEmptyState.tsx
+++ b/src/components/ai-edition/EditorEmptyState.tsx
@@ -10,7 +10,7 @@
// deprecated editor and its own `EditorEmptyState` were deleted in 1320121d,
// so this is the single render path for the feature.
-import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
+import { AlertCircle, Film, FolderOpen, Sparkles, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
@@ -174,7 +174,7 @@ export function EditorEmptyState({
-
+
+
+
{hasProject ? t("emptyState.titleHasAsset") : t("emptyState.title")}
diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx
index c661933f1..b5f13ef0b 100644
--- a/src/components/ai-edition/ExportDialog.tsx
+++ b/src/components/ai-edition/ExportDialog.tsx
@@ -249,7 +249,7 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
return;
}
- const safeName = (document.project.title || "OpenScreen")
+ const safeName = (document.project.title || "PurplePrint Studio")
.replace(/[^a-z0-9-_]+/gi, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 60);
diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css
index 90a4568d6..1c0e754e4 100644
--- a/src/components/ai-edition/NewEditorShell.module.css
+++ b/src/components/ai-edition/NewEditorShell.module.css
@@ -532,20 +532,20 @@
align-items: center;
justify-content: center;
gap: var(--sp-2);
- border: 2px dashed var(--primary, #34B27B);
- background: color-mix(in srgb, var(--primary, #34B27B) 10%, transparent);
+ border: 2px dashed var(--primary, #8B5CF6);
+ background: color-mix(in srgb, var(--primary, #8B5CF6) 10%, transparent);
border-radius: var(--r-sm);
pointer-events: none;
}
.previewEmptyDropIcon {
width: 40px;
height: 40px;
- color: var(--primary, #34B27B);
+ color: var(--primary, #8B5CF6);
}
.previewEmptyDropLabel {
font-size: var(--fs-app);
font-weight: 600;
- color: var(--primary, #34B27B);
+ color: var(--primary, #8B5CF6);
}
.previewEmptyInner {
display: flex;
@@ -559,8 +559,12 @@
.previewEmptyLogo {
width: 64px;
height: 64px;
- border-radius: var(--r-md);
- opacity: 0.9;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--r-lg);
+ background: var(--accent);
+ color: #fff;
}
.previewEmptyHeading {
display: flex;
@@ -593,7 +597,7 @@
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-sm);
- background: var(--primary, #34B27B);
+ background: var(--primary, #8B5CF6);
color: var(--accent-on);
border: none;
font-weight: 500;
@@ -2458,7 +2462,7 @@
color: var(--fg-2);
}
.chatWelcomeFeatures li + li { margin-top: 6px; }
-.chatWelcomeFeatures li::marker { color: var(--accent); }
+.chatWelcomeFeatures li::marker { color: var(--muted); }
.chatWelcomeCta {
display: inline-flex;
@@ -2468,14 +2472,14 @@
width: 100%;
height: 34px;
border-radius: var(--r-md);
- background: var(--accent);
- color: var(--accent-on);
- border: 1px solid var(--accent);
+ background: var(--surface-2);
+ color: var(--fg-2);
+ border: 1px solid var(--border-hi);
font: 600 12.5px var(--font-body);
cursor: pointer;
- transition: filter 120ms ease;
+ transition: background-color 120ms ease;
}
-.chatWelcomeCta:hover { filter: brightness(0.95); }
+.chatWelcomeCta:hover { background: var(--surface-3); }
.chatWelcomeCta:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 64987dea9..943312844 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -110,14 +110,26 @@ export function NewEditorShell() {
// v4 shell: three modes (Media / Edit / Rec), a collapsible agent (chat)
// column, and a floating facet inspector over the stage.
const [mode, setMode] = useState("edit");
- const [chatOpen, setChatOpen] = useState(true);
+ const [chatOpen, setChatOpen] = useState(() => window.innerWidth >= 900);
const [chatWidthPx, setChatWidthPx] = useState(
() => Number(localStorage.getItem("os-editor-chat-width")) || 392,
);
- const [timelineHeightPx, setTimelineHeightPx] = useState(
- () => Number(localStorage.getItem("os-editor-timeline-height")) || 308,
+ const [timelineHeightPx, setTimelineHeightPx] = useState(() =>
+ Math.min(
+ Number(localStorage.getItem("os-editor-timeline-height")) || 308,
+ window.innerHeight < 700 ? 220 : 480,
+ ),
);
- const [inspectorOpen, setInspectorOpen] = useState(true);
+ const [inspectorOpen, setInspectorOpen] = useState(() => window.innerWidth >= 1100);
+ useEffect(() => {
+ const collapseWhenNarrow = () => {
+ if (window.innerWidth < 900) setChatOpen(false);
+ if (window.innerWidth < 1100) setInspectorOpen(false);
+ if (window.innerHeight < 700) setTimelineHeightPx((height) => Math.min(height, 220));
+ };
+ window.addEventListener("resize", collapseWhenNarrow);
+ return () => window.removeEventListener("resize", collapseWhenNarrow);
+ }, []);
const [facet, setFacet] = useState("effects");
const [openProjectOpen, setOpenProjectOpen] = useState(false);
const [newProjectOpen, setNewProjectOpen] = useState(false);
diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx
index ba7a1769c..447dad41e 100644
--- a/src/components/ai-edition/ProviderSettings.test.tsx
+++ b/src/components/ai-edition/ProviderSettings.test.tsx
@@ -97,7 +97,7 @@ function renderEditorChrome(locale: string, mode: EditorMode = "edit") {
/** Open the app menu (the wordmark) and click its AI settings row. */
function openAiSettingsFromAppMenu() {
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
fireEvent.click(screen.getByRole("menuitem", { name: /ai settings/i }));
}
@@ -324,7 +324,7 @@ describe("ProviderSettings, reached from the app menu", () => {
// something else after a copy edit, and the menu would start lying about where it goes.
// Compared as text rather than asserted against a literal, so a copy edit moves both.
renderEditorChrome("en");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
const rowLabel = screen.getByRole("menuitem", { name: /ai settings/i }).textContent;
fireEvent.click(screen.getByRole("menuitem", { name: /ai settings/i }));
@@ -343,7 +343,7 @@ describe("ProviderSettings, reached from the app menu", () => {
it("translates the row with the dialog, not separately", () => {
renderEditorChrome("fr");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
const row = screen.getByRole("menuitem", { name: /paramètres ia/i });
fireEvent.click(row);
diff --git a/src/components/ai-edition/TranscriptionStatus.tsx b/src/components/ai-edition/TranscriptionStatus.tsx
index bae6a0976..ca5f52bac 100644
--- a/src/components/ai-edition/TranscriptionStatus.tsx
+++ b/src/components/ai-edition/TranscriptionStatus.tsx
@@ -67,7 +67,7 @@ export function useTranscriptionLabel(): (view: AssetTranscriptionView) => strin
const DOT_COLOR: Record = {
ready: { fill: "var(--success)", halo: "0 0 0 3px var(--success-soft)" },
queued: { fill: "#f59e0b", halo: "0 0 0 3px rgba(245, 158, 11, 0.2)" },
- running: { fill: "var(--accent)", halo: "0 0 0 3px rgba(16, 185, 129, 0.2)" },
+ running: { fill: "var(--warn)", halo: "0 0 0 3px var(--warn-soft)" },
// A silent media is not a bug — it just has nothing to say. Amber, not red.
empty: { fill: "#f59e0b", halo: "0 0 0 3px rgba(245, 158, 11, 0.2)" },
failed: { fill: "var(--danger)", halo: "0 0 0 3px rgba(239, 68, 68, 0.2)" },
diff --git a/src/components/ai-edition/ZoomFocusOverlay.module.css b/src/components/ai-edition/ZoomFocusOverlay.module.css
index f0f549bfc..92eb264fb 100644
--- a/src/components/ai-edition/ZoomFocusOverlay.module.css
+++ b/src/components/ai-edition/ZoomFocusOverlay.module.css
@@ -8,8 +8,8 @@
.indicator {
position: absolute;
- border: 1px solid rgba(52, 178, 123, 0.8);
- background: rgba(52, 178, 123, 0.2);
+ border: 1px solid var(--accent);
+ background: var(--accent-soft);
border-radius: 6px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 1e313e34c..272039e06 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -1,5 +1,5 @@
-/* OpenScreen Editor v4 shell — Media / Edit / Rec modes.
- * Ported 1:1 from design/OpenScreen Editor v4.dc.html. All colors read the
+/* PurplePrint Studio editor shell — Media / Edit / Rec modes.
+ * Based on design/OpenScreen Editor v4.dc.html. All colors read the
* global --* design tokens (src/styles/design-tokens.css); dark is the hero
* look, light is a full token swap handled by the token layer. */
@@ -83,22 +83,31 @@
gap: 9px;
flex-shrink: 0;
}
-/* Transparent aperture mark: no plate behind it, so it needs a touch more box
- than a filled tile would to hold the same optical weight next to the wordmark. */
-.brand img {
+/* GraphOS family tile: neutral chrome, violet only at the product identity. */
+.brandGlyph {
width: 24px;
height: 24px;
- object-fit: contain;
- display: block;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
flex-shrink: 0;
- user-select: none;
+ border-radius: 6px;
+ background: var(--accent);
+ color: #fff;
}
.brand .name {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 5px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--fg-emphasis);
}
+.productName {
+ font-weight: 500;
+ color: var(--fg);
+}
/* The brand doubles as the app-menu trigger, so it has to read as a button on hover without
gaining any width: .modeSwitch is the only flex-shrink:1 element in the bar, so every pixel
a new control costs is paid out of the mode labels in the most verbose of 13 locales. The
@@ -298,8 +307,8 @@
width: 6px;
height: 6px;
border-radius: 50%;
- background: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-soft);
+ background: var(--success);
+ box-shadow: 0 0 0 3px var(--success-soft);
}
/* One box for every short locale label — see --topbar-lang-label-w. Without it
@@ -402,7 +411,10 @@
background: var(--brand-lo);
}
.exportBtn:disabled {
- opacity: 0.5;
+ background: var(--surface-2);
+ border-color: var(--border);
+ color: var(--muted);
+ box-shadow: none;
cursor: not-allowed;
}
@@ -1756,3 +1768,34 @@
pointer-events: none;
z-index: 14;
}
+
+/* Desktop windows can be narrowed to 800px. Keep the editor modes and export
+ reachable without moving core actions into an undiscoverable overflow menu. */
+@media (max-width: 1100px) {
+ .topbar {
+ --topbar-gap: 8px;
+ --topbar-project-w: 110px;
+ padding-left: calc(10px + var(--titlebar-inset-left, 0px));
+ padding-right: calc(10px + 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px));
+ }
+ .familyName {
+ display: none;
+ }
+ .modeSwitch button {
+ padding-inline: 8px;
+ }
+}
+@media (max-width: 900px) {
+ .topbar {
+ --topbar-gap: 5px;
+ --topbar-project-w: 80px;
+ --topbar-lang-label-w: 26px;
+ }
+ .brand .name,
+ .saved {
+ display: none;
+ }
+ .modeSwitch button {
+ padding-inline: 5px;
+ }
+}
diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx
index 0b3b40db1..3bec66615 100644
--- a/src/components/ai-edition/v4/EditorTopBar.test.tsx
+++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx
@@ -145,7 +145,7 @@ describe("ProjectNameField (issue #180)", () => {
describe("AppMenu", () => {
it("hangs the menu on the brand rather than adding a control to the bar", () => {
renderTopBar("Demo Project");
- const trigger = screen.getByRole("button", { name: /OpenScreen/ });
+ const trigger = screen.getByRole("button", { name: /PurplePrint Studio/ });
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
expect(trigger).toHaveAttribute("aria-expanded", "false");
// The whole point of the wordmark-as-trigger: no menu until asked for.
@@ -158,14 +158,14 @@ describe("AppMenu", () => {
// on the window-drag region and the OS eats the click — the #180 failure, one control
// over. Same reason `all: unset` is banned here.
renderTopBar("Demo Project");
- const trigger = screen.getByRole("button", { name: /OpenScreen/ });
+ const trigger = screen.getByRole("button", { name: /PurplePrint Studio/ });
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("style") ?? "").not.toMatch(/all\s*:\s*unset/);
});
it("opens on click and offers shortcuts, AI settings and about", () => {
renderTopBar("Demo Project");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
expect(screen.getByRole("menu")).toBeInTheDocument();
// Exact names: the translator echoes keys, and both settings rows are labelled with a
// `…title` key, so a /title/ match would hit two items and pin neither.
@@ -179,7 +179,7 @@ describe("AppMenu", () => {
// in App.tsx, above every mode — so the menu does not lie in Media and Rec.
it("opens the AI settings dialog and closes behind itself", () => {
const { onOpenProviderSettings, onOpenSettings } = renderTopBar("Demo Project");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "providerSettings.title" }));
expect(onOpenProviderSettings).toHaveBeenCalledTimes(1);
// Distinct from the shortcuts row above it, which is the dialog it would be confused with.
@@ -189,7 +189,7 @@ describe("AppMenu", () => {
it("routes About to the main process and closes behind itself", () => {
const { onShowAbout } = renderTopBar("Demo Project");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
fireEvent.click(screen.getByRole("menuitem", { name: /actions\.about/ }));
expect(onShowAbout).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
@@ -197,7 +197,7 @@ describe("AppMenu", () => {
it("closes on Escape", () => {
renderTopBar("Demo Project");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
@@ -207,7 +207,7 @@ describe("AppMenu", () => {
// build answering false, and as a check refused mid-take: no item, rather than a button
// that silently does nothing.
renderTopBar("Demo Project");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
expect(
screen.queryByRole("menuitem", { name: /actions\.checkForUpdates/ }),
).not.toBeInTheDocument();
@@ -217,7 +217,7 @@ describe("AppMenu", () => {
const restore = stubElectronAPI({ version: "9.9.9", canCheckForUpdates: true });
try {
const { onCheckForUpdates } = renderTopBar("Demo Project");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
const item = await screen.findByRole("menuitem", { name: /actions\.checkForUpdates/ });
fireEvent.click(item);
expect(onCheckForUpdates).toHaveBeenCalledTimes(1);
@@ -230,7 +230,7 @@ describe("AppMenu", () => {
const restore = stubElectronAPI({ version: "9.9.9", canCheckForUpdates: false });
try {
renderTopBar("Demo Project");
- fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
+ fireEvent.click(screen.getByRole("button", { name: /PurplePrint Studio/ }));
expect(await screen.findByText("9.9.9")).toBeInTheDocument();
} finally {
restore();
diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx
index 58de1288c..02f06b8e0 100644
--- a/src/components/ai-edition/v4/EditorTopBar.tsx
+++ b/src/components/ai-edition/v4/EditorTopBar.tsx
@@ -14,7 +14,6 @@ import {
Sun,
} from "lucide-react";
import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from "react";
-import logoMark from "@/assets/openscreen-mark.png";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { useTheme } from "@/hooks/useTheme";
import { getAvailableLocales, getLocaleName, getLocaleShort } from "@/i18n/loader";
@@ -382,14 +381,20 @@ function AppMenu({ actions }: { actions: TopBarActions }) {
ref={triggerRef}
type="button"
className={`${styles.brand} ${styles.brandBtn}`}
+ aria-label="PurplePrint Studio"
+ title="PurplePrint Studio"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
- {/* Decorative: the wordmark beside it already names the app — and, being the
- button's only text, is also its accessible name. */}
-
- OpenScreen Subscription
+ {/* The GraphOS-family brand tile is decorative; aria-label names the menu. */}
+
+
+
+
+ PurplePrint
+ Studio
+
{open ? (
diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx
index 360499c79..0dccc0599 100644
--- a/src/components/launch/HudControls.tsx
+++ b/src/components/launch/HudControls.tsx
@@ -269,7 +269,7 @@ export const HudCursorButton = memo(function HudCursorButton({
data-testid="launch-cursor-mode-button"
className={`flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[10px] border-0 cursor-pointer transition-all duration-150 active:scale-95 ${hudDisabledClasses} ${styles.electronNoDrag} ${
editableOverlay
- ? "bg-[#10b981] text-[#08090d] hover:bg-[#0e9e6e]"
+ ? "bg-[#8b5cf6] text-white hover:bg-[#7c3aed]"
: "bg-transparent text-[#828c99] hover:bg-[#1a1e25] hover:text-[#f5f7fa]"
}`}
onClick={onClick}
@@ -285,6 +285,7 @@ export const HudRecordButton = memo(function HudRecordButton({
recording,
paused,
saving,
+ starting,
elapsedSeconds,
label,
savingLabel,
@@ -293,18 +294,20 @@ export const HudRecordButton = memo(function HudRecordButton({
recording: boolean;
paused: boolean;
saving: boolean;
+ starting: boolean;
elapsedSeconds: number;
label: string;
savingLabel: string;
onClick: () => void;
}) {
+ const busy = saving || starting;
return (