diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9328ea9..c1cb42c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,6 +13,18 @@ env:
CARGO_TERM_COLOR: always
jobs:
+ product-site:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - name: Check product assets, cache versions and download selection
+ run: |
+ python3 scripts/sync-site-assets.py --check
+ python3 scripts/refresh-site-versions.py --check
+ node --check site/site.js
+ node --test scripts/tests/downloads.test.mjs
+ python3 -m unittest discover -s scripts/tests -p 'test_*.py'
+
native:
runs-on: ubuntu-24.04
steps:
diff --git a/.github/workflows/desktop-preview.yml b/.github/workflows/desktop-preview.yml
new file mode 100644
index 0000000..1110449
--- /dev/null
+++ b/.github/workflows/desktop-preview.yml
@@ -0,0 +1,146 @@
+name: Desktop packages
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ['candidate/**']
+ tags: ['v*']
+ pull_request:
+ paths: ['packaging/**', 'scripts/package-desktop.py', 'scripts/tests/**', 'scripts/release-manifest.py', '.github/workflows/desktop-preview.yml', '.cargo/**', 'crates/**', 'src/**', 'assets/**', 'build.rs', 'LICENSE-*', 'THIRD_PARTY.md', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain*']
+
+permissions:
+ contents: read
+
+concurrency:
+ group: desktop-packages-${{ github.ref }}
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
+
+jobs:
+ package:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: macos-15-intel
+ platform: macos
+ arch: universal
+ target: x86_64-apple-darwin
+ - os: windows-2025
+ platform: windows
+ arch: x64
+ target: x86_64-pc-windows-msvc
+ - os: ubuntu-24.04
+ platform: linux
+ arch: x64
+ target: x86_64-unknown-linux-gnu
+ runs-on: ${{ matrix.os }}
+ env:
+ RUSTUP_TOOLCHAIN: 1.95.0
+ CARGO_TERM_COLOR: always
+ MACOSX_DEPLOYMENT_TARGET: '11.0'
+ CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS: '-C target-feature=+crt-static'
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ - name: Test package publication and release manifests on this OS
+ run: python -m unittest discover -s scripts/tests -p 'test_*.py'
+ - name: Toolchain
+ shell: bash
+ run: |
+ rustup toolchain install 1.95.0 --profile minimal
+ rustup target add ${{ matrix.target }}
+ - name: Reuse Rust dependencies and release build cache
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ with:
+ path: |
+ ~/.cargo/registry/index
+ ~/.cargo/registry/cache
+ ~/.cargo/git/db
+ target/release
+ target/${{ matrix.target }}/release
+ target/aarch64-apple-darwin/release
+ key: desktop-rust-1.95-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }}-${{ github.sha }}
+ restore-keys: |
+ desktop-rust-1.95-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }}-
+ - name: Linux dependencies
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libxcb1-dev libgtk-3-dev mesa-vulkan-drivers
+ - name: Build and check native contracts
+ shell: bash
+ run: |
+ cargo build --release --locked --target ${{ matrix.target }} --bin openpaperdesign --bin paper-rs-mcp
+ cargo test --release --locked --target ${{ matrix.target }} --lib --bin openpaperdesign --test projects -- --test-threads=1
+ - name: Restore screenshots generated by project tests
+ shell: bash
+ run: git restore --worktree -- docs/screenshots
+ - name: Build Apple Silicon slice and assemble universal executables
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ rustup target add aarch64-apple-darwin
+ cargo build --release --locked --target aarch64-apple-darwin --bin openpaperdesign --bin paper-rs-mcp
+ mkdir -p target/distribution
+ for binary in openpaperdesign paper-rs-mcp; do
+ lipo -create target/x86_64-apple-darwin/release/$binary target/aarch64-apple-darwin/release/$binary -output target/distribution/$binary
+ lipo target/distribution/$binary -verify_arch x86_64 arm64
+ done
+ - name: Stage native executables
+ if: runner.os != 'macOS'
+ shell: bash
+ run: |
+ mkdir -p target/distribution
+ suffix=''
+ if [ '${{ runner.os }}' = Windows ]; then suffix=.exe; fi
+ cp target/${{ matrix.target }}/release/openpaperdesign$suffix target/distribution/
+ cp target/${{ matrix.target }}/release/paper-rs-mcp$suffix target/distribution/
+ - name: Verify actual packaged CLI executables
+ shell: bash
+ run: |
+ suffix=''
+ if [ '${{ runner.os }}' = Windows ]; then suffix=.exe; fi
+ ./target/distribution/openpaperdesign$suffix --help
+ ./target/distribution/paper-rs-mcp$suffix --url
+ - name: Package exact clean source and checksums
+ run: python scripts/package-desktop.py --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --bin-dir target/distribution --output dist --require-clean
+ - name: Keep exact packages for review
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: OpenPaperDesign-${{ matrix.platform }}-${{ matrix.arch }}
+ path: dist/*
+ if-no-files-found: error
+ retention-days: 14
+
+ draft-release:
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ needs: package
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Require a reviewed main-branch release source
+ shell: bash
+ run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ pattern: OpenPaperDesign-*
+ merge-multiple: true
+ path: dist
+ - name: Verify packages and generate manifest
+ run: python scripts/release-manifest.py dist --repository arcitai/openpaperdesign --tag "$GITHUB_REF_NAME" --revision "$GITHUB_SHA"
+ - name: Create a draft for review, never publish automatically
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ if gh release view "$GITHUB_REF_NAME" --repo arcitai/openpaperdesign --json isDraft >/dev/null 2>&1; then
+ echo 'Release already exists; leave its reviewed assets unchanged.' >&2
+ exit 1
+ fi
+ gh release create "$GITHUB_REF_NAME" dist/* --repo arcitai/openpaperdesign --target "$GITHUB_SHA" --draft --prerelease --title "OpenPaperDesign $GITHUB_REF_NAME" --notes 'Desktop review packages. macOS is ad-hoc signed and not notarized. See packaged BUILD.json, licences and checksums. Publication is a separate deliberate action.'
+ test "$(gh release view "$GITHUB_REF_NAME" --repo arcitai/openpaperdesign --json isDraft --jq .isDraft)" = true
diff --git a/.gitignore b/.gitignore
index 6411ea6..af1e550 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,7 @@ target/
*.asar
app.asar.unpacked/
.toolchain/
+dist/
+site/downloads/
+site/releases.preview.json
+__pycache__/
diff --git a/Cargo.toml b/Cargo.toml
index 76f6b51..c92a3ca 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,6 +27,10 @@ path = "src/main.rs"
name = "paper-rs-mcp"
path = "src/bin/paper-rs-mcp.rs"
+[[bin]]
+name = "openpaperdesign"
+path = "packaging/native.rs"
+
[dependencies]
egui = "0.36"
egui-wgpu = "0.36"
diff --git a/README.md b/README.md
index 111dae2..b7d422c 100644
--- a/README.md
+++ b/README.md
@@ -1,378 +1,67 @@
-# openpaperdesign
+
+
+
+
-An independent fork of [michaelshimeles/paper-rs](https://github.com/michaelshimeles/paper-rs),
-not affiliated with or endorsed by Paper (paper.design). The first slice is a local Codex
-plugin with a loopback editable canvas, deliberately bound to one design project inside the
-workspace. See [standalone plugin setup](docs/openpaperdesign/plugin-b2b.md#install-on-a-mac-operator-steps)
-for local-build setup and limits. Installed model editing and browser save/conflict/reopen
-passed on macOS with Codex 0.162.1 at `0078487` (2026-10-13). Distribution is source-only /
-local build, with no packaged installer or public marketplace listing. See
-[repository Releases](https://github.com/arcitai/openpaperdesign/releases) for delivery status.
-There is no protection against concurrent native or external writers, and no multiplayer
-or cloud service. The upstream README follows unchanged.
+# OpenPaperDesign
-## Why I built this
-
-I wanted an infinite, editable canvas inside my AI agent’s workspace, where we could work through architecture diagrams, systems and interface designs together without leaving the conversation.
-
-OpenPaperDesign is built on paper-rs by [Michael Shimeles](https://github.com/michaelshimeles). I reuse its editor, follow open standards where they exist and add only the integration the agent workspace needs. That keeps the extra code and maintenance small.
-
-In Codex, I can sketch a system and refine a layout with the agent. Project files stay local and the canvas stays editable. I’m also exploring how AI-generated images can become part of that workflow.
-
----
-
-# paper-rs
-
-A native Rust infinite canvas for planning, working and sketching. It is modeled on the
-behavior of [Paper](https://paper.design) and built clean-room on `wgpu` + `winit` + `egui`.
-
-
-
-A local-first design and whiteboard tool. Projects are folders of pages; each page is an
-infinite canvas with frames, groups, components and instances, auto layout and constraints,
-vector paths and booleans, gradients and image fills, rich text, smart guides, and
-pressure-sensitive ink — plus a whiteboard mode: hand-drawn (rough.js-style) shapes, arrows that
-bind to shapes and re-route, elbow connectors, sticky notes, a laser pointer and a shape
-library. Everything autosaves, and exports to PNG, SVG and PDF; Excalidraw files and SVG import.
-
-
-
-> **Clean-room note.** `analysis/PAPER_ARCHITECTURE.md` documents how Paper Desktop 0.5.15 is
-> put together, from a licensed install. The Electron package turned out to be a thin shell
-> around the hosted web editor. paper-rs re-implements *behavior* only. No Paper code,
-> shaders, icons, fonts or assets are used or committed. The UI and canvas text use
-> [Inter](https://rsms.me/inter/) 4.1 (SIL OFL 1.1), bundled in `assets/fonts/`.
-
-## Reverse-engineered origin
-
-paper-rs is an independent, clean-room-style reimplementation. It was reverse engineered from
-[Paper](https://paper.design) by studying the behavior of the Paper desktop app with
-[REA](https://github.com/morluto/rea) and Claude Code. It is not affiliated with or endorsed by
-Paper. No original Paper code or assets are included in this repository.
-
-## Projects
-
-paper-rs opens on a **home screen** of your projects (recents grid with thumbnails, New, Import,
-and Rename / Duplicate / Move to Trash from each card's ⋯ menu or right-click; restore from
-Trash). Each project is a plain folder in `~/Documents/paper-rs//` holding `project.json`
-and one JSON file per page in `pages/`. Work is autosaved about a second after each change with
-crash-safe atomic writes; files are versioned and older versions migrate on load. Set
-`PAPER_RS_HOME` to use another folder.
-
-A project has any number of **pages** (Pages list at the top of the left sidebar: + to add,
-double-click to rename, right-click to duplicate or delete, drag to reorder). Each page is its
-own infinite canvas with its own undo history and view. Copy and paste works between pages and
-between projects.
-
-## Build and run
-
-You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`; the latest stable is recommended). Install it with [rustup](https://rustup.rs).
-
-```sh
-cargo build --release
-./target/release/paper-rs # projects home screen
-./target/release/paper-rs --demo # sample planning board (not saved)
-./target/release/paper-rs ~/Documents/paper-rs/Plan # open a project folder
-./target/release/paper-rs board.paper-rs # open a paper-rs 0.1 document
-```
-
-### macOS
-
-```sh
-xcode-select --install # command line tools (linker)
-curl https://sh.rustup.rs -sSf | sh
-cargo build --release && ./target/release/paper-rs --demo
-```
-
-It uses Metal through wgpu. Shortcuts use ⌘, and trackpad pinch zooms. The macOS target is
-type-checked in CI-style (`cargo check --target aarch64-apple-darwin`) but has not been run on
-Mac hardware yet (see Known gaps).
-
-### Linux
-
-```sh
-# Debian/Ubuntu runtime libraries (usually already present on desktops)
-sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers
-cargo build --release && ./target/release/paper-rs --demo
-```
-
-It runs on X11 and Wayland through Vulkan, or GL as a fallback. Shortcuts use Ctrl. File
-dialogs use the XDG desktop portal.
+A free, open-source canvas for ideas, diagrams, interfaces and visual work. Work on your own, or connect an AI agent over MCP.
-### Headless (servers, CI)
+
-No display is needed. wgpu renders offscreen, through Mesa lavapipe when there is no GPU:
+Built on **paper-rs by [Michael Shimeles (Rasmic)](https://github.com/michaelshimeles)**, now developed as [Sketch](https://github.com/michaelshimeles/sketch). The core editor is his work. OpenPaperDesign adds agent-host integration, design methods and desktop packaging. This is an independent fork, not affiliated with Paper (paper.design).
-```sh
-paper-rs --screenshot ui.png --demo --size 1440x900 --scale 2 # full UI
-paper-rs --screenshot home.png --home # projects home screen
-paper-rs --export-png out.png PROJECT_DIR --page 0 --scale 2 [--transparent]
-paper-rs --export-svg out.svg PROJECT_DIR
-paper-rs --export-pdf out.pdf PROJECT_DIR --frames # one PDF page per frame
-paper-rs --export out_dir PROJECT_DIR --format png --scope frames # a folder of PNGs
-paper-rs --import board.excalidraw # new project, prints its folder
-```
+The product images use the new UI/UX source snapshot from PR #9. They are development previews; they are not screenshots of an already released desktop package.
-## AI agents (MCP)
+## Desktop preview
-
+[Downloads and releases](https://github.com/arcitai/openpaperdesign/releases) · [Installation and platform status](docs/openpaperdesign/desktop.md)
-paper-rs serves the [Model Context Protocol](https://modelcontextprotocol.io) while it runs, so
-agents such as Claude Code and Codex can read and design on the open canvas, the way Paper's
-own MCP server works (behaviour notes: `analysis/paper-mcp.md`). Every tool call runs through
-the editor like a user edit: one undo step per call, and the window repaints immediately.
+The existing **v0.1.0 release is source code**, not an installer. Desktop packaging is being verified in the desktop packaging work. Do not substitute an upstream Sketch download for an OpenPaperDesign app.
-* **Streamable HTTP** (built into the app): `http://127.0.0.1:29980/mcp`. Paper uses 29979, so
- both can run. Loopback only; requests from browsers (with an `Origin` header) and foreign
- `Host` headers are rejected. `PAPER_RS_MCP_PORT=NNNN` changes the port; `PAPER_RS_MCP=0`
- turns the server off.
-* **stdio**: `paper-rs-mcp` (built next to `paper-rs`) relays stdio to the running app. It
- lists the tools even when the app is closed, and tells the agent to start paper-rs if a
- call can't reach it.
+| Platform | Current evidence |
+| --- | --- |
+| macOS, Apple Silicon | Integrated new-UI preview opens, edits, saves and reopens on Apple M4; ad-hoc signed, not notarized. Downloaded first launch remains unqualified. |
+| Windows, x64 | Release build and ZIP packaging passed in CI; native desktop journey pending. |
+| Linux, x64 | Release build, CLI smoke and tarball packaging passed in CI; native desktop journey pending. |
-### Claude Code
+## What you can do
-```sh
-# HTTP (the app must be running)
-claude mcp add --transport http --scope user paper-rs http://127.0.0.1:29980/mcp
-# or stdio through the bridge
-claude mcp add --scope user paper-rs -- /path/to/paper-rs/target/release/paper-rs-mcp
-```
+- Work through systems with editable shapes, text and bound arrows.
+- Sketch with hand-drawn shapes, ink and sticky notes in six colours.
+- Design screens with frames, auto layout, components, vectors and image fills.
+- Keep projects locally as JSON pages and assets. Export PNG, SVG or PDF; import SVG and Excalidraw.
+- Connect a compatible MCP client, including Codex or Claude Code, when you want agent help.
-### Codex
+The native desktop app and the Codex plugin reuse the same editor, but have different host interfaces. The newer Codex Home/workspace UI is still developed separately in [PR #9](https://github.com/arcitai/openpaperdesign/pull/9). A desktop build does not imply that branch has been accepted or released. Avoid editing the same project simultaneously in the native app and the plugin; the plugin's checked writer path does not protect against external native writers.
-```sh
-# HTTP
-codex mcp add paper-rs --url http://127.0.0.1:29980/mcp
-# or stdio
-codex mcp add paper-rs -- /path/to/paper-rs/target/release/paper-rs-mcp
-```
-
-which writes this to `~/.codex/config.toml` (either form works):
-
-```toml
-[mcp_servers.paper-rs]
-url = "http://127.0.0.1:29980/mcp"
-# command = "/path/to/paper-rs/target/release/paper-rs-mcp"
-```
-
-Then ask the agent something like *"In paper-rs, create a 390×844 mobile artboard and design a
-sign-in screen"*, or *"draw a flowchart of our deploy process on a new page, hand-drawn
-style"*.
-
-### Tools
-
-Names and arguments follow Paper's MCP server where paper-rs has an equivalent, so prompts
-written for Paper work here too.
+## Why I built this
-| Group | Tools |
-|---|---|
-| Projects & pages | `list_resources`, `open_file`, `create_file`, `create_page`, `rename_pages` |
-| Read | `get_basic_info`, `get_selection`, `get_node_info`, `get_children`, `get_tree_summary`, `get_computed_styles`, `get_jsx`, `find_nodes`, `get_fill_image`, `get_font_family_info`, `get_guide` |
-| See | `get_screenshot` (PNG image content of a node or the page), `export` (PNG/SVG/PDF files), `export_combined_pdf` |
-| Write | `create_artboard`, `write_html`, `update_styles`, `set_text_content`, `rename_nodes`, `duplicate_nodes`, `move_nodes`, `delete_nodes`, `finish_working_on_nodes` (saves) |
-| paper-rs extras | `create_shape` (rectangles, ellipses, diamonds, stars, stickies, text, lines, arrows bound to shapes), `select_nodes`, `undo`, `redo` |
+I wanted somewhere to sketch systems and design interfaces with an AI agent, without another paid design-tool subscription. Michael's paper-rs gave me a native, editable canvas to build on. I keep that foundation and add the integrations and methods I use in my own work.
-`write_html` takes HTML with inline styles: flexbox (`display:flex`, direction, gap, padding,
-alignment, `flex-grow`, wrap) and block stacking map onto auto layout with CSS's stretch rules;
-text gets size, weight, italic, color, line height, letter spacing, alignment and rich-text
-runs (``, ``, ``); boxes get backgrounds (colors, gradients, images),
-borders, radii, opacity and blend modes; `` takes local paths or `data:` URIs and `')).replace(/.*?<\/title>/,'');
+ const icon=`${body}`;
+ await fs.writeFile(path.join(root,'packaging/icons/icon.svg'),icon+'\n');
+ await sharp(Buffer.from(icon)).resize(1024,1024).png().toFile(path.join(root,'packaging/icons/icon-1024.png'));
+ for(const [target,source] of [['logo.svg',light],['composer-icon.svg',light],['logo-dark.svg',dark],['composer-icon-dark.svg',dark]]){
+ await fs.writeFile(path.join(root,'plugin/openpaperdesign/assets',target),source);
+ }
+}
+render().catch(error=>{console.error(error);process.exitCode=1;});
diff --git a/scripts/sync-site-assets.py b/scripts/sync-site-assets.py
new file mode 100644
index 0000000..5aa0f8e
--- /dev/null
+++ b/scripts/sync-site-assets.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""Copy/check pinned repository-owned marketing assets. Never modifies source assets."""
+import argparse,hashlib,json
+from pathlib import Path
+root=Path(__file__).resolve().parents[1];site=root/'site'
+parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--check',action='store_true');args=parser.parse_args()
+manifest=json.loads((site/'assets-sources.json').read_text())
+if manifest['repository']!='arcitai/openpaperdesign':raise ValueError('Wrong asset repository')
+for asset in manifest['assets']:
+ source=(root/asset['path']).resolve();target=(site/asset['output']).resolve()
+ if not source.is_relative_to(root) or not target.is_relative_to(site/'assets'):raise ValueError('Asset path leaves the repository or site/assets')
+ data=source.read_bytes()
+ if hashlib.sha256(data).hexdigest()!=asset['sha256']:raise ValueError(f"Source hash differs: {asset['path']}")
+ if args.check:
+ if not target.is_file() or target.read_bytes()!=data:raise ValueError(f"Site asset differs: {target.name}")
+ elif source!=target:
+ target.parent.mkdir(parents=True,exist_ok=True);target.write_bytes(data)
+ print(asset['output'])
diff --git a/scripts/tests/downloads.test.mjs b/scripts/tests/downloads.test.mjs
new file mode 100644
index 0000000..03912b6
--- /dev/null
+++ b/scripts/tests/downloads.test.mjs
@@ -0,0 +1,71 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {detectPlatform,selectAsset,allowedDownload,appDownloadCount,publishedManifest,downloadChoice} from '../../site/downloads.js';
+test('mobile never receives a desktop installer, including iPad desktop mode',()=>{
+ assert.equal(detectPlatform('MacIntel','iPhone',1),'mobile');
+ assert.equal(detectPlatform('MacIntel','Mozilla Safari',5),'mobile');
+ assert.equal(detectPlatform('Linux armv8','Android',5),'mobile');
+ assert.equal(selectAsset({platforms:{macos:{url:'/downloads/app.zip',architecture:'arm64',status:'preview'}}},'mobile'),null);
+});
+test('download totals count app archives across releases, not sources, checksums or drafts',()=>{
+ const asset=(name,download_count)=>({name,download_count});
+ assert.equal(appDownloadCount([{assets:[asset('OpenPaperDesign-0.1.0-macos-arm64.zip',3),asset('OpenPaperDesign-0.1.0-macos-arm64.zip.sha256',90),asset('source.tar.gz',88)]},{assets:[asset('OpenPaperDesign-0.1.1-linux-x64.tar.gz',5)]},{draft:true,assets:[asset('OpenPaperDesign-0.1.0-windows-x64.zip',50)]}]),8);
+ assert.throws(()=>appDownloadCount([{assets:[asset('OpenPaperDesign-0.1.0-macos-arm64.zip',-1)]}]));
+ assert.throws(()=>appDownloadCount({error:'rate limit'}));
+ assert.equal(appDownloadCount([{assets:[asset('source.tar.gz',88)]}]),null);
+ assert.equal(appDownloadCount([{assets:[asset('OpenPaperDesign-0.1.0-windows-x64.zip',0)]}]),0);
+});
+test('desktop OS detection has an honest unknown fallback',()=>{
+ assert.equal(detectPlatform('MacIntel','Safari',0),'macos');
+ assert.equal(detectPlatform('Win32'),'windows');assert.equal(detectPlatform('Linux x86_64'),'linux');assert.equal(detectPlatform('Other'),'unknown');
+});
+test('only reviewed release or local-preview assets become download actions',()=>{
+ assert.equal(allowedDownload('https://github.com/arcitai/openpaperdesign/releases/download/v1/app.zip'),true);
+ for(const u of ['javascript:alert(1)','https://github.com/other/repo/releases/download/v1/a','https://github.com.evil.test/arcitai/openpaperdesign/releases/download/v1/a','/downloads/../secret','https://evil.test/app.zip'])assert.equal(allowedDownload(u),false);
+ assert.equal(selectAsset({platforms:{windows:{url:'https://evil.test/app.zip',architecture:'x64',status:'built'}}},'windows'),null);
+ assert.equal(selectAsset({},'linux'),null);
+ assert.equal(allowedDownload('/downloads/app.zip'),false);
+ assert.equal(allowedDownload('/downloads/app.zip',true),true);
+});
+
+test('only a complete published three-platform release with checksum assets supplies downloads',()=>{
+ const tag='v0.1.0-desktop.1';const base=`https://github.com/arcitai/openpaperdesign/releases/download/${tag}/`;
+ const assets=['macos-universal.zip','windows-x64.zip','linux-x64.tar.gz'].flatMap(target=>{
+ const name=`OpenPaperDesign-0.1.0-${target}`;
+ return [{name,size:500,browser_download_url:base+name},{name:name+'.sha256',size:100,browser_download_url:base+name+'.sha256'}];
+ });
+ const release={tag_name:tag,assets};
+ assert.equal(publishedManifest([release]).platforms.macos.architecture,'Intel + Apple Silicon');
+ assert.match(downloadChoice(publishedManifest([release]),'macos').meta,/Apple notarization not verified/);
+ assert.match(publishedManifest([release]).notice,/notarization is not verified/);
+ assert.equal(publishedManifest([{...release,draft:true}]),null);
+ assert.equal(publishedManifest([{...release,tag_name:'candidate-abc'}]),null);
+ assert.equal(publishedManifest([{...release,assets:assets.slice(0,-1)}]),null);
+ assert.equal(publishedManifest([{...release,assets:assets.map((a,i)=>i===0?{...a,browser_download_url:'https://evil.test/app.zip'}:a)}]),null);
+ assert.equal(appDownloadCount([{assets:[{name:'OpenPaperDesign-0.1.0-macos-universal.zip',download_count:4}]}]),4);
+});
+
+test('the primary action directly downloads the validated file for the detected desktop OS',()=>{
+ const manifest={platforms:Object.fromEntries(['macos','windows','linux'].map(os=>[os,{url:`/downloads/${os}.zip`,architecture:os==='macos'?'Intel + Apple Silicon':'x64',status:'Preview · not notarized'}]))};
+ for(const os of ['macos','windows','linux']){
+ const choice=downloadChoice(manifest,os,true);
+ assert.equal(choice.href,`/downloads/${os}.zip`);
+ assert.ok(choice.asset);
+ assert.match(choice.label,/^Download for /);
+ }
+ assert.match(downloadChoice(manifest,'macos',true).meta,/Not Apple notarized/);
+ const unknown={platforms:{macos:{...manifest.platforms.macos,status:'Preview'}}};
+ assert.match(downloadChoice(unknown,'macos',true).meta,/Apple notarization not verified/);
+ unknown.platforms.macos.notarized=true;
+ assert.doesNotMatch(downloadChoice(unknown,'macos',true).meta,/notarization not verified/);
+ for(const os of ['mobile','unknown']){
+ assert.equal(downloadChoice(manifest,os,true).href,'#download');
+ assert.equal(downloadChoice(manifest,os,true).asset,null);
+ }
+ assert.equal(downloadChoice(manifest,'macos',false).asset,null,'local files never become public downloads');
+ assert.equal(downloadChoice({platforms:{}},'windows').href,'#download');
+});
+test('unsupported desktop hardware goes to platform choices rather than an x64 installer',()=>{
+ for(const [platform,ua] of [['Linux aarch64','Firefox'],['Linux armv7l','Firefox'],['Win32','Windows NT 10.0; ARM64'],['Linux x86_64','Mozilla/5.0 (X11; CrOS x86_64 1)']])assert.equal(detectPlatform(platform,ua),'unknown');
+ assert.equal(detectPlatform('MacIntel','Mozilla Safari',0),'macos','the universal Mac app needs no CPU guess');
+});
diff --git a/scripts/tests/test_package_publication.py b/scripts/tests/test_package_publication.py
new file mode 100644
index 0000000..fefd601
--- /dev/null
+++ b/scripts/tests/test_package_publication.py
@@ -0,0 +1,274 @@
+"""Desktop package publication: exact files, no replacement, safe failure, overlapping runs.
+
+Linux and Windows packages are built from stand-in executables on any host: only the host
+check is patched, the archives, notices and file system publication are real. The macOS cases
+also run the real sips/iconutil/codesign/ditto and need a Mac (lipo is skipped for stand-ins).
+"""
+import contextlib
+import errno
+import hashlib
+import importlib.util
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import threading
+import unittest
+import zipfile
+from pathlib import Path
+from unittest import mock
+
+SCRIPTS = Path(__file__).parents[1]
+
+
+def load(name, filename):
+ spec = importlib.util.spec_from_file_location(name, SCRIPTS/filename)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+packager = load('package_desktop', 'package-desktop.py')
+release_manifest = load('release_manifest', 'release-manifest.py')
+VERSION = json.loads((SCRIPTS.parent/'packaging/desktop.json').read_text(encoding='utf-8'))['version']
+HOSTS = {'linux': 'Linux', 'windows': 'Windows'}
+WRITERS = {'linux': tarfile.TarFile, 'windows': zipfile.ZipFile}
+MAC_TOOLS = sys.platform == 'darwin' and all(map(shutil.which, ('sips', 'iconutil', 'codesign', 'ditto')))
+
+
+@contextlib.contextmanager
+def after_archive(platform, hook, commands=None):
+ """Call hook once a run has completely written its archive, before anything else happens.
+ On macOS, commands (a list) receives every command the run executes."""
+ if platform == 'macos':
+ run = subprocess.run
+ def written(command, *args, **kwargs):
+ if commands is not None: commands.append(command)
+ if command[0] == 'lipo': # stand-in executables are not the requested slices
+ return subprocess.CompletedProcess(command, 0)
+ result = run(command, *args, **kwargs)
+ if command[0] == 'ditto': hook()
+ return result
+ with mock.patch.object(packager.subprocess, 'run', written): yield
+ return
+ close = WRITERS[platform].close
+ def written(self):
+ close(self)
+ if self.mode == 'w' and not getattr(self, 'hooked', False): # ZipFile.__del__ closes again
+ self.hooked = True
+ hook()
+ with mock.patch.object(WRITERS[platform], 'close', written): yield
+
+
+class PackagePublication(unittest.TestCase):
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.output = self.root/'dist'
+
+ @contextlib.contextmanager
+ def host(self, platform):
+ # Patched once around all runs: the platform module is shared by every thread.
+ with mock.patch.object(packager.host, 'system', return_value=HOSTS[platform]), \
+ mock.patch.object(packager.host, 'machine', return_value='x86_64'):
+ yield
+
+ def run_package(self, platform, label, arch='x64'):
+ """Package the stand-in executables named after label, creating them on first use."""
+ executables = self.root/label
+ if not executables.exists():
+ executables.mkdir()
+ for name in ('openpaperdesign', 'paper-rs-mcp'):
+ (executables/(name+('.exe' if platform == 'windows' else ''))).write_bytes(f'{label} {name}\n'.encode()*512)
+ return packager.package(platform, arch, executables, self.output)
+
+ def mac_executables(self, label):
+ """Signable stand-ins: copies of /usr/bin/