diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b158d87 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,271 @@ +name: Release + +# Builds, (optionally) signs, and publishes Capsule installers for +# macOS (.dmg), Linux (.tar.gz), and Windows (.zip) whenever a tag of the +# form v*.*.* is pushed. The resulting artifacts are attached to a new +# GitHub Release along with SHA-256 checksums and minisign signatures. +# +# Code-signing is optional and driven entirely by repository secrets. +# With no secrets configured, unsigned artifacts are still produced and +# published — end users will see OS warnings but the flow works for the +# project's V1 reference implementation. + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + # ------------------------------------------------------------------ + # macOS: build Capsule.app, sign it, wrap it in a notarized DMG + # ------------------------------------------------------------------ + macos: + runs-on: macos-latest + outputs: + version: ${{ steps.meta.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - id: meta + run: | + VERSION="${GITHUB_REF_NAME#v}" + [ -z "$VERSION" ] && VERSION="0.0.0-dev" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "CAPSULE_VERSION=$VERSION" >> "$GITHUB_ENV" + + # --- Optional: import Apple Developer ID certificate from secrets --- + - name: Import codesign keychain + if: env.APPLE_CERT_P12 != '' + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: github-actions + run: | + echo "$APPLE_CERT_P12" | base64 --decode > /tmp/cert.p12 + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security import /tmp/cert.p12 -k build.keychain \ + -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" build.keychain + rm /tmp/cert.p12 + + - name: Build Capsule.app + run: ./installers/macos/build.sh + + - name: Build DMG + env: + APPLE_DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: ./installers/macos/dmg.sh + + - name: Compute checksums + working-directory: installers/macos/build + run: | + shasum -a 256 Capsule-*.dmg > SHA256SUMS.txt + cat SHA256SUMS.txt + + # --- Optional: minisign signature for independently verifiable provenance --- + - name: Minisign DMG + if: env.MINISIGN_SECRET_KEY != '' + working-directory: installers/macos/build + env: + MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: | + brew install minisign >/dev/null + echo "$MINISIGN_SECRET_KEY" > /tmp/minisign.key + for f in Capsule-*.dmg; do + minisign -S -s /tmp/minisign.key -m "$f" -W "$MINISIGN_PASSWORD" + done + rm /tmp/minisign.key + + - uses: actions/upload-artifact@v4 + with: + name: capsule-macos + path: | + installers/macos/build/Capsule-*.dmg + installers/macos/build/Capsule-*.dmg.sha256 + installers/macos/build/Capsule-*.dmg.minisig + installers/macos/build/SHA256SUMS.txt + + # ------------------------------------------------------------------ + # Linux: tarball containing runtime + install.sh + # ------------------------------------------------------------------ + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm -r build + + - name: Stage runtime + run: | + VERSION="${GITHUB_REF_NAME#v}" + [ -z "$VERSION" ] && VERSION="0.0.0-dev" + STAGE="capsule-${VERSION}-linux" + mkdir -p "$STAGE/runtime/packages" + for pkg in capsule-core capsule-runtime capsule-cli; do + mkdir -p "$STAGE/runtime/packages/$pkg" + cp -R "packages/$pkg/dist" "$STAGE/runtime/packages/$pkg/dist" + if [ -d "packages/$pkg/bin" ]; then + cp -R "packages/$pkg/bin" "$STAGE/runtime/packages/$pkg/bin" + fi + cp "packages/$pkg/package.json" "$STAGE/runtime/packages/$pkg/package.json" + done + cat > "$STAGE/runtime/package.json" <<'JSON' + {"name":"capsule-bundle","private":true,"version":"0.0.0","type":"module","workspaces":["packages/*"]} + JSON + node installers/scripts/rewrite-workspace-deps.mjs "$STAGE/runtime" + (cd "$STAGE/runtime" && npm install --omit=dev --no-audit --no-fund --silent) + mkdir -p "$STAGE/assets" + cp installers/assets/capsule.png "$STAGE/assets/" + cp installers/linux/capsule.desktop installers/linux/capsule-launcher.sh \ + installers/linux/install.sh installers/linux/uninstall.sh "$STAGE/" + tar -czf "$STAGE.tar.gz" "$STAGE" + shasum -a 256 "$STAGE.tar.gz" > "$STAGE.tar.gz.sha256" + + - name: Minisign tarball + if: env.MINISIGN_SECRET_KEY != '' + env: + MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: | + sudo apt-get update && sudo apt-get install -y minisign + echo "$MINISIGN_SECRET_KEY" > /tmp/minisign.key + for f in capsule-*-linux.tar.gz; do + minisign -S -s /tmp/minisign.key -m "$f" -W "$MINISIGN_PASSWORD" + done + rm /tmp/minisign.key + + - uses: actions/upload-artifact@v4 + with: + name: capsule-linux + path: | + capsule-*-linux.tar.gz + capsule-*-linux.tar.gz.sha256 + capsule-*-linux.tar.gz.minisig + + # ------------------------------------------------------------------ + # Windows: zip containing runtime + install.ps1, optionally Authenticode-signed + # ------------------------------------------------------------------ + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm -r build + + - name: Stage runtime + shell: pwsh + run: | + $version = "${{ github.ref_name }}".TrimStart("v") + if (-not $version) { $version = "0.0.0-dev" } + $stage = "capsule-$version-windows" + New-Item -ItemType Directory -Force -Path "$stage\runtime\packages" | Out-Null + foreach ($pkg in @("capsule-core","capsule-runtime","capsule-cli")) { + New-Item -ItemType Directory -Force -Path "$stage\runtime\packages\$pkg" | Out-Null + Copy-Item -Recurse "packages\$pkg\dist" "$stage\runtime\packages\$pkg\dist" + if (Test-Path "packages\$pkg\bin") { + Copy-Item -Recurse "packages\$pkg\bin" "$stage\runtime\packages\$pkg\bin" + } + Copy-Item "packages\$pkg\package.json" "$stage\runtime\packages\$pkg\package.json" + } + '{"name":"capsule-bundle","private":true,"version":"0.0.0","type":"module","workspaces":["packages/*"]}' | + Set-Content -Encoding UTF8 "$stage\runtime\package.json" + node installers\scripts\rewrite-workspace-deps.mjs "$stage\runtime" + Push-Location "$stage\runtime"; npm install --omit=dev --no-audit --no-fund --silent | Out-Null; Pop-Location + New-Item -ItemType Directory -Force -Path "$stage\assets" | Out-Null + Copy-Item installers\assets\capsule.ico "$stage\assets\" + Copy-Item installers\windows\install.ps1,installers\windows\uninstall.ps1 "$stage\" + Compress-Archive -Path "$stage" -DestinationPath "$stage.zip" -Force + (Get-FileHash "$stage.zip" -Algorithm SHA256).Hash | Set-Content "$stage.zip.sha256" + + - name: Authenticode sign installer scripts + if: env.WINDOWS_CERT_P12 != '' + shell: pwsh + env: + WINDOWS_CERT_P12: ${{ secrets.WINDOWS_CERT_P12 }} + WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} + run: | + $cert = [Convert]::FromBase64String($env:WINDOWS_CERT_P12) + [IO.File]::WriteAllBytes("$env:TEMP\cert.pfx", $cert) + $signtool = "C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe" + if (-not (Test-Path $signtool)) { + $signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe | Select-Object -First 1).FullName + } + Get-ChildItem -Directory capsule-*-windows | ForEach-Object { + & $signtool sign /f "$env:TEMP\cert.pfx" /p $env:WINDOWS_CERT_PASSWORD /tr http://timestamp.digicert.com /td sha256 /fd sha256 ` + (Join-Path $_.FullName "install.ps1") ` + (Join-Path $_.FullName "uninstall.ps1") + Compress-Archive -Path $_.FullName -DestinationPath "$($_.FullName).zip" -Force + (Get-FileHash "$($_.FullName).zip" -Algorithm SHA256).Hash | Set-Content "$($_.FullName).zip.sha256" + } + Remove-Item "$env:TEMP\cert.pfx" + + - uses: actions/upload-artifact@v4 + with: + name: capsule-windows + path: | + capsule-*-windows.zip + capsule-*-windows.zip.sha256 + + # ------------------------------------------------------------------ + # Collect everything and publish a release + # ------------------------------------------------------------------ + release: + needs: [macos, linux, windows] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: dist + + - name: Flatten artifacts + run: | + mkdir -p release + find dist -type f \( -name 'Capsule-*' -o -name 'capsule-*' -o -name 'SHA256SUMS.txt' \) -exec cp {} release/ \; + (cd release && shasum -a 256 * | grep -v SHA256SUMS.txt | tee SHA256SUMS.txt) + ls -lh release + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release/* + generate_release_notes: true + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + body: | + Installers for macOS, Linux, and Windows. + + Each artifact ships with a SHA-256 checksum file. When the release + was built with signing secrets configured, you will also see + `*.minisig` (public-key signatures) and the macOS `.dmg` will be + notarized by Apple. See [`docs/RELEASE.md`](docs/RELEASE.md) for + how to verify the downloads. diff --git a/.gitignore b/.gitignore index 4cf3e5a..6cee19f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,9 @@ receipts-local/ # Generated smoke/playground directories /playground/ /hello/ + +# Working source backups (keep only the cropped versions) +docs/assets/*-original.* + +# Private working notes (demo scripts, drafts, todo lists) +.notes/ diff --git a/README.md b/README.md index 56c15db..5bb7118 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # Capsule -**A single-file format for tiny apps you can open, inspect, fork, and share.** -Portable like PDFs. Interactive like web apps. Sandboxed by default. +**An open file format for portable, sandboxed interactive documents.** +Spec + reference implementation. Like PDF — but interactive. [![CI](https://github.com/ImJustRicky/capsule/actions/workflows/ci.yml/badge.svg)](https://github.com/ImJustRicky/capsule/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) @@ -15,7 +15,7 @@ Portable like PDFs. Interactive like web apps. Sandboxed by default. [![Status: V1 draft](https://img.shields.io/badge/status-V1%20draft-orange)](PLAN.md#milestones) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md) -[Quick start](#quick-start) · [How it works](#how-it-works) · [Security model](#security-model-v1) · [Spec](docs/CAPSULE-1.0-DRAFT.md) · [Examples](examples) · [Contributing](CONTRIBUTING.md) +[Quick start](#quick-start) · [How it works](#how-it-works) · [Security model](#security-model-v1) · [Spec](docs/CAPSULE-1.0-DRAFT.md) · [Examples](examples) · [Installers](installers) · [Contributing](CONTRIBUTING.md) @@ -115,6 +115,7 @@ node packages/capsule-cli/bin/capsule.mjs run hello.capsule The [`examples/`](examples) directory contains reference capsules that exercise the format and capability model. Each is a plain directory you can `capsule pack` and `capsule run`. +- [`pocket-notes`](examples/pocket-notes) — polished markdown notepad with live preview, persists drafts via `storage.local` - [`offline-checklist`](examples/offline-checklist) — local-only checklist that uses `storage.local` - [`mortgage-calculator`](examples/mortgage-calculator) — pure-compute capsule, no capabilities - [`poster-maker`](examples/poster-maker) — canvas drawing, no network @@ -135,6 +136,7 @@ The format is documented as a standards draft in [`docs/`](docs/). The V1 implem | [`RUNTIME-CONFORMANCE.md`](docs/RUNTIME-CONFORMANCE.md) | What a compatible runtime must do | | [`OS-INTEGRATION.md`](docs/OS-INTEGRATION.md) | File extension, MIME, icons | | [`AUTHORING-GUIDE.md`](docs/AUTHORING-GUIDE.md) | How to make safe capsules | +| [`RELEASE.md`](docs/RELEASE.md) | How releases are built, signed, and verified | ## Making capsules diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..937de3f --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,135 @@ +# Releasing Capsule + +This doc explains how to cut a release, what gets signed, and how end users +can verify the downloads they grab from the +[GitHub Releases page](https://github.com/ImJustRicky/capsule/releases). + +## Cut a release + +```bash +# 1. bump the version in packages/capsule-cli/package.json (and others) +# 2. commit, tag, push: +git tag v0.1.0 +git push --tags +``` + +The `Release` workflow ([`release.yml`](../.github/workflows/release.yml)) +fires automatically on any `v*.*.*` tag. It builds, signs (if configured), +and publishes: + +| Artifact | Platform | Built by | +| --- | --- | --- | +| `Capsule-.dmg` | macOS | `installers/macos/build.sh` → `installers/macos/dmg.sh` | +| `capsule--linux.tar.gz` | Linux | `release.yml` linux job | +| `capsule--windows.zip` | Windows | `release.yml` windows job | +| `SHA256SUMS.txt` | all | release job | +| `*.minisig` | all (optional) | release.yml when minisign secret set | + +## Signing model + +Capsule uses **two independent layers** of signing: + +1. **OS-native code signing** so end users don't see scary warnings when they + double-click an installer: + - macOS: Apple Developer ID + notarization (Gatekeeper) + - Windows: Authenticode certificate (SmartScreen) +2. **Detached, OS-independent signatures** with [minisign](https://jedisct1.github.io/minisign/) + so anyone can verify they got the exact bytes the maintainer published, + without trusting Apple, Microsoft, or GitHub. + +OS signing requires paid certs. Minisign requires nothing — generate a key +once, publish the public half in this repo, sign every release with the +private half. **Do both** for shipping releases; **either** alone is better +than nothing. + +## Required GitHub secrets + +All are **optional**. Missing secrets just skip the corresponding signing +step — the workflow still produces unsigned artifacts. + +### macOS (Apple) + +| Secret | What it is | +| --- | --- | +| `APPLE_CERT_P12` | Base64 of your `Developer ID Application` certificate `.p12`, exported from Keychain Access | +| `APPLE_CERT_PASSWORD` | The password you set when exporting the `.p12` | +| `APPLE_DEVELOPER_ID` | Your Developer ID identity, e.g. `Developer ID Application: Jane Doe (TEAMID1234)` | +| `APPLE_ID` | Your Apple ID email | +| `APPLE_TEAM_ID` | The 10-character team ID from your Apple developer account | +| `APPLE_APP_PASSWORD` | An app-specific password for `notarytool`, generated at appleid.apple.com | + +To produce `APPLE_CERT_P12`: + +```bash +# In Keychain Access, export your "Developer ID Application: …" cert as a .p12, +# then base64-encode it (single line) for the GitHub secret value: +base64 -i Developer-ID.p12 | pbcopy +``` + +### Windows (Authenticode) + +| Secret | What it is | +| --- | --- | +| `WINDOWS_CERT_P12` | Base64 of your code-signing `.pfx` | +| `WINDOWS_CERT_PASSWORD` | The PFX password | + +A self-signed Authenticode cert avoids SmartScreen warnings only after +extensive reputation building; for real shipping use a cert from +DigiCert/Sectigo/SSL.com. + +### Minisign (recommended for everyone) + +```bash +# 1. Generate a key pair locally: +minisign -G -p capsule-minisign.pub -s capsule-minisign.key + +# 2. Commit the .pub to the repo: +mv capsule-minisign.pub docs/keys/ +git add docs/keys/capsule-minisign.pub +git commit -m "Publish minisign release-signing public key" + +# 3. Set GitHub secrets: +# MINISIGN_SECRET_KEY = +# MINISIGN_PASSWORD = +``` + +Anyone who pulls a release can then verify with: + +```bash +minisign -V -p docs/keys/capsule-minisign.pub \ + -m Capsule-0.1.0.dmg +``` + +## Verifying a download (end user instructions) + +1. **Checksum.** Compare the published `SHA256SUMS.txt` line for your + download with what you compute locally: + + ```bash + shasum -a 256 Capsule-0.1.0.dmg + ``` + +2. **Public-key signature.** If a `.minisig` exists alongside your file, + verify it against this repo's public key: + + ```bash + minisign -V -p capsule-minisign.pub -m Capsule-0.1.0.dmg + ``` + +3. **OS verification.** + - macOS: `spctl -a -t exec -vv /Applications/Capsule.app` should report + "accepted" with the Developer ID. `xcrun stapler validate Capsule.dmg` + should report a stapled notarization ticket. + - Windows: right-click the `.zip` → Properties → Digital Signatures + should list a valid signature with a verified timestamp. + +If any of those fail, the file was modified after release. Don't run it — +report it on the issue tracker. + +## What is *not* signed + +- The contents of individual `.capsule` files. Capsule capsules can be + signed with their own author keys ([`SIGNING-AND-INTEGRITY.md`](SIGNING-AND-INTEGRITY.md)) + — that signature is independent of the runtime release signature. +- Source-only installs (`pnpm install && pnpm -r build`). If you ran the + build yourself you already know what you ran. diff --git a/docs/keys/README.md b/docs/keys/README.md new file mode 100644 index 0000000..9a8b6bb --- /dev/null +++ b/docs/keys/README.md @@ -0,0 +1,22 @@ +# Release-signing public keys + +This folder holds the public halves of the keys the project uses to sign +distributed installers. Commit **only public keys** here. Secret keys belong +in GitHub Actions secrets, never in the repo. + +## Minisign + +Replace `capsule-minisign.pub` with your own public key generated via +`minisign -G` (see `docs/RELEASE.md`). + +Verifying a release artifact: + +```bash +minisign -V -p docs/keys/capsule-minisign.pub -m Capsule-.dmg +``` + +## Apple Developer ID / Windows Authenticode + +These are OS-managed; there is no public key to commit. Verification happens +through Gatekeeper (macOS) and SmartScreen (Windows) automatically when a +user opens the installer. diff --git a/examples/pocket-notes/capsule.json b/examples/pocket-notes/capsule.json new file mode 100644 index 0000000..91d78b2 --- /dev/null +++ b/examples/pocket-notes/capsule.json @@ -0,0 +1,25 @@ +{ + "capsule_version": "1.0", + "name": "Pocket Notes", + "slug": "pocket-notes", + "version": "0.1.0", + "description": "A polished markdown notepad with live preview. Saves drafts locally, scoped to this capsule. No network.", + "entry": "content/index.html", + "author": { "name": "Capsule Examples" }, + "permissions": [ + { + "capability": "storage.local", + "scope": "capsule", + "reason": "Keep your draft between sessions, on this device only." + } + ], + "network": { "default": "deny", "allow": [] }, + "privacy": { + "summary": "Drafts live in capsule-private storage on this device. Nothing is sent over the network.", + "data_stored": ["current draft"], + "data_shared": [] + }, + "display": { + "preferred_size": { "width": 1100, "height": 720 } + } +} diff --git a/examples/pocket-notes/content/app.js b/examples/pocket-notes/content/app.js new file mode 100644 index 0000000..34db9bb --- /dev/null +++ b/examples/pocket-notes/content/app.js @@ -0,0 +1,233 @@ +const STORAGE_KEY = "draft"; +const SAMPLE = `# Pocket Notes + +A tiny markdown notepad that lives in **one file**. + +## What this demonstrates + +- Real interactivity inside the Capsule sandbox +- Local storage **scoped to this capsule** — no other capsule can read it +- Zero network access (the Open Screen showed you that) + +> Quote blocks render too. So do \`inline code\` and code fences: + +\`\`\`js +function hello(name) { + return \`hi, \${name}\`; +} +\`\`\` + +### Try it + +1. Edit anything on the left +2. Watch the preview update on the right +3. Close the window — your draft is still here next time +`; + +const editor = document.getElementById("editor"); +const preview = document.getElementById("preview"); +const status = document.getElementById("status"); +const counts = document.getElementById("counts"); +const sampleBtn = document.getElementById("sample"); +const clearBtn = document.getElementById("clear"); + +let saveTimer = null; +const bridge = window.capsule; + +init(); + +async function init() { + const initial = await loadDraft(); + editor.value = initial ?? ""; + render(); + editor.addEventListener("input", onInput); + sampleBtn.addEventListener("click", () => { + editor.value = SAMPLE; + onInput(); + editor.focus(); + }); + clearBtn.addEventListener("click", () => { + if (!editor.value || confirm("Clear the current draft?")) { + editor.value = ""; + onInput(); + editor.focus(); + } + }); + editor.focus(); +} + +function onInput() { + render(); + markDirty(); + scheduleSave(); +} + +function render() { + const md = editor.value; + if (!md.trim()) { + preview.innerHTML = `

Your preview will appear here.

`; + counts.textContent = "0 words"; + return; + } + preview.innerHTML = renderMarkdown(md); + const words = md.trim().split(/\s+/).filter(Boolean).length; + counts.textContent = `${words} ${words === 1 ? "word" : "words"}`; +} + +function markDirty() { + status.textContent = "editing…"; + status.classList.add("dirty"); +} + +function markSaved() { + status.textContent = "saved"; + status.classList.remove("dirty"); +} + +function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(saveDraft, 350); +} + +async function loadDraft() { + if (!bridge) return null; + try { + return await bridge.request("storage.local", "get", { key: STORAGE_KEY }); + } catch { + return null; + } +} + +async function saveDraft() { + if (!bridge) { + markSaved(); + return; + } + try { + await bridge.request("storage.local", "set", { + key: STORAGE_KEY, + value: editor.value, + }); + markSaved(); + } catch { + status.textContent = "save failed"; + } +} + +// ---- Tiny markdown renderer (no deps, sandbox-safe) ---- + +function renderMarkdown(src) { + const lines = src.replace(/\r\n/g, "\n").split("\n"); + const out = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (/^```/.test(line)) { + const lang = line.slice(3).trim(); + const code = []; + i++; + while (i < lines.length && !/^```/.test(lines[i])) { + code.push(lines[i]); + i++; + } + i++; + out.push( + `
${escapeHtml(
+          code.join("\n"),
+        )}
`, + ); + continue; + } + + const heading = /^(#{1,3})\s+(.*)$/.exec(line); + if (heading) { + const level = heading[1].length; + out.push(`${inline(heading[2])}`); + i++; + continue; + } + + if (/^\s*---\s*$/.test(line)) { + out.push("
"); + i++; + continue; + } + + if (/^\s*>\s?/.test(line)) { + const block = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + block.push(lines[i].replace(/^\s*>\s?/, "")); + i++; + } + out.push(`
${inline(block.join(" "))}
`); + continue; + } + + const ulMatch = /^\s*[-*]\s+(.*)$/.exec(line); + if (ulMatch) { + const items = []; + while (i < lines.length) { + const m = /^\s*[-*]\s+(.*)$/.exec(lines[i]); + if (!m) break; + items.push(`
  • ${inline(m[1])}
  • `); + i++; + } + out.push(`
      ${items.join("")}
    `); + continue; + } + + const olMatch = /^\s*\d+\.\s+(.*)$/.exec(line); + if (olMatch) { + const items = []; + while (i < lines.length) { + const m = /^\s*\d+\.\s+(.*)$/.exec(lines[i]); + if (!m) break; + items.push(`
  • ${inline(m[1])}
  • `); + i++; + } + out.push(`
      ${items.join("")}
    `); + continue; + } + + if (line.trim() === "") { + i++; + continue; + } + + const para = [line]; + i++; + while ( + i < lines.length && + lines[i].trim() !== "" && + !/^(#{1,3}\s|\s*[-*]\s|\s*\d+\.\s|>\s|---|```)/.test(lines[i]) + ) { + para.push(lines[i]); + i++; + } + out.push(`

    ${inline(para.join(" "))}

    `); + } + return out.join("\n"); +} + +function inline(text) { + let s = escapeHtml(text); + s = s.replace(/`([^`]+)`/g, (_, c) => `${c}`); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/(^|\W)_([^_]+)_(?=\W|$)/g, "$1$2"); + s = s.replace(/\*([^*]+)\*/g, "$1"); + s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, label, href) => { + return `${label}`; + }); + return s; +} + +function escapeHtml(s) { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/examples/pocket-notes/content/index.html b/examples/pocket-notes/content/index.html new file mode 100644 index 0000000..a62d8a4 --- /dev/null +++ b/examples/pocket-notes/content/index.html @@ -0,0 +1,44 @@ + + + + + + Pocket Notes + + + + +
    +
    + + Pocket Notes +
    +
    + saved + · + 0 words +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + +
    +
    +
    + + diff --git a/examples/pocket-notes/content/style.css b/examples/pocket-notes/content/style.css new file mode 100644 index 0000000..9bf145a --- /dev/null +++ b/examples/pocket-notes/content/style.css @@ -0,0 +1,283 @@ +:root { + --bg: #0e1116; + --panel: #161b22; + --panel-2: #1c232c; + --line: #232b36; + --fg: #e6edf3; + --muted: #8b96a3; + --accent: #7ee7c5; + --accent-2: #f3b562; + --danger: #ff8585; + --radius: 12px; + --shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 12px 32px rgba(0, 0, 0, 0.35); + font-synthesis-weight: none; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, "Helvetica Neue", + Arial, sans-serif; + font-size: 15px; + line-height: 1.55; + color: var(--fg); + background: + radial-gradient(1200px 600px at 20% -10%, rgba(126, 231, 197, 0.08), transparent 60%), + radial-gradient(900px 500px at 110% 20%, rgba(243, 181, 98, 0.07), transparent 60%), + var(--bg); + display: grid; + grid-template-rows: auto 1fr; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.topbar { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 16px; + padding: 14px 20px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 600; + letter-spacing: 0.2px; +} + +.brand .dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + box-shadow: 0 0 0 3px rgba(126, 231, 197, 0.12); +} + +.brand .title { + font-size: 14px; +} + +.meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.status { + padding: 2px 8px; + border-radius: 999px; + background: rgba(126, 231, 197, 0.1); + color: var(--accent); + font-weight: 500; + transition: background 200ms ease, color 200ms ease; +} + +.status.dirty { + background: rgba(243, 181, 98, 0.12); + color: var(--accent-2); +} + +.dot-sep { + opacity: 0.5; +} + +.actions { + display: inline-flex; + gap: 8px; +} + +button.ghost { + appearance: none; + border: 1px solid var(--line); + background: var(--panel); + color: var(--fg); + padding: 7px 12px; + border-radius: 8px; + font: inherit; + font-size: 12px; + cursor: pointer; + transition: + background 150ms ease, + border-color 150ms ease, + transform 80ms ease; +} + +button.ghost:hover { + background: var(--panel-2); + border-color: #2c3744; +} + +button.ghost:active { + transform: translateY(1px); +} + +button.ghost.danger { + color: var(--danger); +} + +button.ghost.danger:hover { + border-color: rgba(255, 133, 133, 0.4); +} + +.split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + min-height: 0; +} + +.pane { + display: grid; + grid-template-rows: auto 1fr; + min-height: 0; + background: var(--panel); +} + +.pane + .pane { + border-left: 1px solid var(--line); + background: var(--panel-2); +} + +.pane-label { + padding: 10px 18px; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--line); + user-select: none; +} + +#editor { + margin: 0; + border: 0; + background: transparent; + color: var(--fg); + resize: none; + outline: none; + padding: 22px 26px 28px; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + font-size: 14px; + line-height: 1.65; + tab-size: 2; + caret-color: var(--accent); +} + +#editor::placeholder { + color: #4f5b6a; +} + +.preview { + margin: 0; + padding: 22px 26px 32px; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.preview h1, +.preview h2, +.preview h3 { + margin: 1.2em 0 0.4em; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.preview h1 { + font-size: 1.7rem; + border-bottom: 1px solid var(--line); + padding-bottom: 0.3em; +} + +.preview h2 { + font-size: 1.3rem; +} + +.preview h3 { + font-size: 1.05rem; + color: var(--accent); +} + +.preview p { + margin: 0.6em 0; +} + +.preview a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px dashed rgba(126, 231, 197, 0.4); +} + +.preview ul, +.preview ol { + padding-left: 1.4em; +} + +.preview li { + margin: 0.2em 0; +} + +.preview code { + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; + font-size: 0.9em; +} + +.preview pre { + background: #0a0e13; + border: 1px solid var(--line); + padding: 14px 16px; + border-radius: 10px; + overflow-x: auto; + font-size: 0.88em; +} + +.preview blockquote { + margin: 0.8em 0; + padding: 4px 14px; + border-left: 3px solid var(--accent); + background: rgba(126, 231, 197, 0.06); + color: #c8d3df; + border-radius: 0 8px 8px 0; +} + +.preview hr { + border: 0; + height: 1px; + background: var(--line); + margin: 1.4em 0; +} + +.preview .empty { + color: var(--muted); + font-style: italic; +} + +@media (max-width: 760px) { + .split { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } + .pane + .pane { + border-left: 0; + border-top: 1px solid var(--line); + } +} diff --git a/examples/pocket-notes/source/README.md b/examples/pocket-notes/source/README.md new file mode 100644 index 0000000..0fbb55e --- /dev/null +++ b/examples/pocket-notes/source/README.md @@ -0,0 +1,34 @@ +# Pocket Notes + +A polished markdown notepad capsule. Built as a demo to show that real, +opinionated apps fit in a single `.capsule` file. + +## What it shows + +- **Live markdown preview.** Type on the left, see formatted output on the right. +- **`storage.local` capability.** Drafts persist between sessions, scoped to + this capsule only — no other capsule can read them. +- **Zero network.** `network.default = "deny"` and `allow = []`. The Open + Screen makes this contract visible before the app ever runs. +- **No dependencies.** The markdown renderer is ~100 lines of vanilla JS, + written to be safe inside the strict CSP (no `eval`, no `innerHTML` of + unescaped input). + +## Files + +``` +capsule.json manifest + capability declaration +content/index.html entry point +content/style.css dark theme + responsive split layout +content/app.js editor wiring + tiny markdown renderer +source/README.md this file +``` + +## Pack and run + +From the repo root: + +```bash +node packages/capsule-cli/bin/capsule.mjs pack examples/pocket-notes +node packages/capsule-cli/bin/capsule.mjs run pocket-notes.capsule +``` diff --git a/installers/README.md b/installers/README.md new file mode 100644 index 0000000..72de572 --- /dev/null +++ b/installers/README.md @@ -0,0 +1,63 @@ +# Capsule installers + +Per-platform installers that register `Capsule` as the system handler for +`.capsule` files, so users can **double-click** a capsule in their file +manager and have it open in the sandboxed runtime. + +| Platform | Folder | Install command | +| --- | --- | --- | +| macOS | [`macos/`](macos) | `./installers/macos/build.sh && ./installers/macos/install.sh` | +| Linux | [`linux/`](linux) | `sudo ./installers/linux/install.sh` | +| Windows | [`windows/`](windows) | `powershell -ExecutionPolicy Bypass -File .\installers\windows\install.ps1` | + +Source-checkout installers do the same three things: + +1. **Build the runtime** (`pnpm -r build`). +2. **Stage** the built `dist/` of `capsule-core`, `capsule-runtime`, and + `capsule-cli` into a per-platform location (`/Applications`, `/usr/local`, + `%LOCALAPPDATA%`). +3. **Register the file association** with the OS so `.capsule` files are + routed to the bundled launcher. The launcher locates Node and invokes + `capsule run `. + +Release artifacts already contain the built runtime, so Linux and Windows +installers copy `runtime/` directly and do not require `pnpm`. + +## Requirements + +- **Node.js v20+** on the user's machine. (Bundling Node would balloon the + installer to ~50 MB; for V1 we keep the artifact small and require Node.) +- **macOS**: Command Line Tools for `pnpm`/`npm`. Optional: `duti` + (`brew install duti`) so the installer can force-set Capsule as the + default opener immediately. +- **Linux**: `sudo` access to write to `/usr/local`. The installer registers + the MIME type via `update-mime-database` and `xdg-mime`. +- **Windows**: no admin rights needed; everything is per-user under `HKCU` + and `%LOCALAPPDATA%`. + +## Uninstall + +| Platform | Command | +| --- | --- | +| macOS | `./installers/macos/uninstall.sh` | +| Linux | `sudo ./installers/linux/uninstall.sh` | +| Windows | `powershell -ExecutionPolicy Bypass -File .\installers\windows\uninstall.ps1` | + +## How file association works + +| OS | Mechanism | +| --- | --- | +| macOS | `Info.plist` declares `CFBundleDocumentTypes` + `UTExportedTypeDeclarations` for UTI `org.capsule.capsule`. Launch Services routes `.capsule` opens to `Capsule.app`. | +| Linux | `capsule.desktop` + `share/mime/packages/capsule.xml` declare MIME `application/vnd.capsule+zip`. `xdg-mime` sets it as the default. | +| Windows | HKCU registry entries map `.capsule` → `Capsule.Document` ProgID → launcher `.cmd` with `%1` argument. | + +## Distributing pre-built installers + +The `Build & sign release artifacts` GitHub Actions workflow +([`.github/workflows/release.yml`](../.github/workflows/release.yml)) packages +all three platforms when you push a `v*` tag, optionally code-signs them +(macOS notarization + Windows Authenticode), and attaches them to a GitHub +Release. End users then download the installer for their OS instead of +cloning the repo. + +See [`docs/RELEASE.md`](../docs/RELEASE.md) for the signing setup. diff --git a/docs/assets/capsule-original.png b/installers/assets/capsule.icns similarity index 90% rename from docs/assets/capsule-original.png rename to installers/assets/capsule.icns index 2f59d78..2387e1b 100644 Binary files a/docs/assets/capsule-original.png and b/installers/assets/capsule.icns differ diff --git a/installers/assets/capsule.ico b/installers/assets/capsule.ico new file mode 100644 index 0000000..91a9148 Binary files /dev/null and b/installers/assets/capsule.ico differ diff --git a/installers/assets/capsule.png b/installers/assets/capsule.png new file mode 100644 index 0000000..ae48248 Binary files /dev/null and b/installers/assets/capsule.png differ diff --git a/installers/linux/capsule-launcher.sh b/installers/linux/capsule-launcher.sh new file mode 100755 index 0000000..2cc68e7 --- /dev/null +++ b/installers/linux/capsule-launcher.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# +# Linux launcher for .capsule files. Installed to /usr/local/bin/capsule-launcher +# by ./install.sh and referenced from capsule.desktop. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PREFIX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +RUNTIME_ENTRY="${CAPSULE_RUNTIME_ENTRY:-$PREFIX_DIR/lib/capsule/packages/capsule-cli/bin/capsule.mjs}" + +if ! command -v node >/dev/null 2>&1; then + if command -v zenity >/dev/null 2>&1; then + zenity --error --title="Capsule" \ + --text="Capsule needs Node.js (v20 or newer) to run.\n\nInstall it from https://nodejs.org and try again." + elif command -v notify-send >/dev/null 2>&1; then + notify-send "Capsule" "Node.js (v20+) is required. Install from https://nodejs.org" + else + echo "Capsule: Node.js (v20+) is required. Install from https://nodejs.org" >&2 + fi + exit 1 +fi +NODE_BIN="$(command -v node)" +if ! "$NODE_BIN" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' >/dev/null 2>&1; then + if command -v zenity >/dev/null 2>&1; then + zenity --error --title="Capsule" \ + --text="Capsule needs Node.js v20 or newer to run.\n\nCurrent version: $("$NODE_BIN" -v)" + elif command -v notify-send >/dev/null 2>&1; then + notify-send "Capsule" "Node.js v20+ is required. Current version: $("$NODE_BIN" -v)" + else + echo "Capsule: Node.js v20+ is required. Current version: $("$NODE_BIN" -v)" >&2 + fi + exit 1 +fi + +if [ ! -f "$RUNTIME_ENTRY" ]; then + echo "Capsule runtime not found at $RUNTIME_ENTRY" >&2 + exit 1 +fi + +if [ "$#" -eq 0 ]; then + if command -v zenity >/dev/null 2>&1; then + zenity --info --title="Capsule" \ + --text="Capsule is installed.\n\nTo open a .capsule file, double-click it or run:\n capsule-launcher path/to/file.capsule" + fi + exit 0 +fi + +exec "$NODE_BIN" "$RUNTIME_ENTRY" run "$@" diff --git a/installers/linux/capsule.desktop b/installers/linux/capsule.desktop new file mode 100644 index 0000000..1492dc8 --- /dev/null +++ b/installers/linux/capsule.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Capsule +GenericName=Capsule Document Viewer +Comment=Open Capsule (.capsule) interactive documents in a sandboxed runtime +Exec=capsule-launcher %f +Icon=capsule +Terminal=false +Categories=Utility;Viewer; +MimeType=application/vnd.capsule+zip;application/x-capsule; +NoDisplay=false +StartupNotify=false diff --git a/installers/linux/install.sh b/installers/linux/install.sh new file mode 100755 index 0000000..731ce2e --- /dev/null +++ b/installers/linux/install.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# +# Install Capsule on Linux: +# - Installs a packaged runtime, or builds one when run from a source checkout +# - Installs files to /usr/local/lib/capsule (runtime) and /usr/local/bin (launcher) +# - Registers the Capsule MIME type, desktop file, and icons +# +# Requires: node 20+, sudo for system prefixes; pnpm only for source installs +# Tested on: Ubuntu 22.04+, Fedora 38+, Arch +# +# Usage: sudo ./installers/linux/install.sh +# Reverse: sudo ./installers/linux/uninstall.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SOURCE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +PACKAGED_RUNTIME="$SCRIPT_DIR/runtime" +PREFIX="${PREFIX:-/usr/local}" +LIB_DIR="$PREFIX/lib/capsule" +BIN_DIR="$PREFIX/bin" +ASSET_DIR="$SCRIPT_DIR/assets" +if [ ! -d "$ASSET_DIR" ] && [ -d "$SOURCE_ROOT/installers/assets" ]; then + ASSET_DIR="$SOURCE_ROOT/installers/assets" +fi + +PREFIX_PARENT="$(dirname "$PREFIX")" +if [ "$(id -u)" -ne 0 ] && { { [ -e "$PREFIX" ] && [ ! -w "$PREFIX" ]; } || { [ ! -e "$PREFIX" ] && [ ! -w "$PREFIX_PARENT" ]; }; }; then + echo "This installer needs permission to write to $PREFIX." + echo "Re-run with: sudo $0" + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "Capsule needs Node.js v20 or newer. Install it from https://nodejs.org and re-run this installer." + exit 1 +fi +if ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' >/dev/null 2>&1; then + echo "Capsule needs Node.js v20 or newer. Current version: $(node -v)" + exit 1 +fi + +install_source_runtime() { + if ! command -v pnpm >/dev/null 2>&1; then + echo "Source install needs pnpm. Use a release tarball to install without pnpm." + exit 1 + fi + if ! command -v npm >/dev/null 2>&1; then + echo "Source install needs npm. Install Node.js from https://nodejs.org and try again." + exit 1 + fi + + echo "==> Building runtime" + if [ "$(id -u)" -eq 0 ]; then + SUDO_USER_NAME="${SUDO_USER:-root}" + sudo -u "$SUDO_USER_NAME" env REPO_ROOT="$SOURCE_ROOT" \ + bash -c 'cd "$REPO_ROOT" && pnpm install --frozen-lockfile && pnpm -r build' + else + (cd "$SOURCE_ROOT" && pnpm install --frozen-lockfile && pnpm -r build) + fi + + echo "==> Installing runtime to $LIB_DIR" + rm -rf "$LIB_DIR" + mkdir -p "$LIB_DIR/packages" + for pkg in capsule-core capsule-runtime capsule-cli; do + mkdir -p "$LIB_DIR/packages/$pkg" + cp -R "$SOURCE_ROOT/packages/$pkg/dist" "$LIB_DIR/packages/$pkg/dist" + if [ -d "$SOURCE_ROOT/packages/$pkg/bin" ]; then + cp -R "$SOURCE_ROOT/packages/$pkg/bin" "$LIB_DIR/packages/$pkg/bin" + fi + cp "$SOURCE_ROOT/packages/$pkg/package.json" "$LIB_DIR/packages/$pkg/package.json" + done + cat > "$LIB_DIR/package.json" <<'JSON' +{ + "name": "capsule-bundle", + "private": true, + "version": "0.0.0", + "type": "module", + "workspaces": ["packages/*"] +} +JSON + node "$SOURCE_ROOT/installers/scripts/rewrite-workspace-deps.mjs" "$LIB_DIR" + (cd "$LIB_DIR" && npm install --omit=dev --no-audit --no-fund --silent) +} + +install_packaged_runtime() { + echo "==> Installing packaged runtime to $LIB_DIR" + rm -rf "$LIB_DIR" + mkdir -p "$LIB_DIR" + cp -R "$PACKAGED_RUNTIME/." "$LIB_DIR/" +} + +if [ -d "$PACKAGED_RUNTIME/packages/capsule-cli" ]; then + install_packaged_runtime +elif [ -d "$SOURCE_ROOT/packages/capsule-cli" ]; then + install_source_runtime +else + echo "No packaged runtime or source checkout found." + echo "Run this from a Capsule release tarball, or from the repository checkout." + exit 1 +fi + +echo "==> Installing launcher to $BIN_DIR/capsule-launcher" +install -d "$BIN_DIR" +install -m 0755 "$SCRIPT_DIR/capsule-launcher.sh" "$BIN_DIR/capsule-launcher" + +echo "==> Installing .desktop entry" +install -d "$PREFIX/share/applications" +install -m 0644 "$SCRIPT_DIR/capsule.desktop" "$PREFIX/share/applications/capsule.desktop" + +if [ -f "$ASSET_DIR/capsule.png" ]; then + echo "==> Installing icons" + install -d "$PREFIX/share/icons/hicolor/256x256/apps" + install -d "$PREFIX/share/icons/hicolor/256x256/mimetypes" + install -m 0644 "$ASSET_DIR/capsule.png" "$PREFIX/share/icons/hicolor/256x256/apps/capsule.png" + install -m 0644 "$ASSET_DIR/capsule.png" "$PREFIX/share/icons/hicolor/256x256/mimetypes/application-vnd.capsule+zip.png" +fi + +echo "==> Registering MIME type" +install -d "$PREFIX/share/mime/packages" +cat > "$PREFIX/share/mime/packages/capsule.xml" <<'XML' + + + + Capsule Document + + + + + +XML + +if [ "${CAPSULE_SKIP_REGISTRATION:-0}" = "1" ]; then + echo "==> Skipping desktop database registration" +else + if command -v update-mime-database >/dev/null 2>&1; then + update-mime-database "$PREFIX/share/mime" + fi + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$PREFIX/share/applications" + fi + if command -v gtk-update-icon-cache >/dev/null 2>&1 && [ -d "$PREFIX/share/icons/hicolor" ]; then + gtk-update-icon-cache -q -t -f "$PREFIX/share/icons/hicolor" || true + fi + if command -v xdg-mime >/dev/null 2>&1; then + xdg-mime default capsule.desktop application/vnd.capsule+zip + fi +fi + +echo +echo "Installed." +echo "Open a capsule by double-clicking it, or run:" +echo " capsule-launcher path/to/file.capsule" diff --git a/installers/linux/uninstall.sh b/installers/linux/uninstall.sh new file mode 100755 index 0000000..00fe01b --- /dev/null +++ b/installers/linux/uninstall.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# +# Reverse installers/linux/install.sh + +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Re-run with: sudo $0" + exit 1 +fi + +PREFIX="${PREFIX:-/usr/local}" + +rm -rf "$PREFIX/lib/capsule" +rm -f "$PREFIX/bin/capsule-launcher" +rm -f "$PREFIX/share/applications/capsule.desktop" +rm -f "$PREFIX/share/mime/packages/capsule.xml" +rm -f "$PREFIX/share/icons/hicolor/256x256/apps/capsule.png" +rm -f "$PREFIX/share/icons/hicolor/256x256/mimetypes/application-vnd.capsule+zip.png" + +if command -v update-mime-database >/dev/null 2>&1; then + update-mime-database "$PREFIX/share/mime" || true +fi +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$PREFIX/share/applications" || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1 && [ -d "$PREFIX/share/icons/hicolor" ]; then + gtk-update-icon-cache -q -t -f "$PREFIX/share/icons/hicolor" || true +fi + +echo "Uninstalled." diff --git a/installers/macos/Capsule.app/Contents/Info.plist b/installers/macos/Capsule.app/Contents/Info.plist new file mode 100644 index 0000000..6a05b5d --- /dev/null +++ b/installers/macos/Capsule.app/Contents/Info.plist @@ -0,0 +1,86 @@ + + + + + CFBundleDevelopmentRegion + en + + CFBundleDisplayName + Capsule + CFBundleName + Capsule + + CFBundleIdentifier + dev.capsule.runtime + + CFBundleExecutable + capsule + + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + + CFBundleIconFile + Capsule + + LSMinimumSystemVersion + 11.0 + + + LSUIElement + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Capsule Document + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + org.capsule.capsule + + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + org.capsule.capsule + UTTypeDescription + Capsule Document + UTTypeConformsTo + + public.data + public.archive + com.pkware.zip-archive + + UTTypeTagSpecification + + public.filename-extension + + capsule + + public.mime-type + + application/vnd.capsule+zip + + + UTTypeIconFile + CapsuleDocument + + + + diff --git a/installers/macos/Capsule.app/Contents/MacOS/capsule b/installers/macos/Capsule.app/Contents/MacOS/capsule new file mode 100755 index 0000000..e342fe8 --- /dev/null +++ b/installers/macos/Capsule.app/Contents/MacOS/capsule @@ -0,0 +1,116 @@ +#!/bin/bash +# +# Capsule.app launcher +# -------------------- +# This script is the executable that macOS runs when: +# - the user double-clicks a .capsule file (with this app as default opener) +# - `open -a Capsule.app some.capsule` is run +# - a .capsule is dragged onto the app icon +# +# It locates a Node interpreter, then hands the file to the bundled +# capsule-runtime via the CLI. The runtime takes care of starting the +# sandboxed HTTP server and opening the Open Screen window. +# +# When packaged for distribution (see ../../../build.sh), the runtime is +# embedded inside the app bundle under Contents/Resources/runtime so the user +# does not need a copy of the source repo. + +set -e + +BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +RESOURCES_DIR="$BUNDLE_DIR/Resources" +RUNTIME_ENTRY="$RESOURCES_DIR/runtime/packages/capsule-cli/bin/capsule.mjs" + +# Find a Node interpreter. We probe the most common install locations because +# .app bundles do NOT inherit the user's interactive PATH (Finder strips it). +find_node() { + local candidates=( + "/usr/local/bin/node" + "/opt/homebrew/bin/node" + "$HOME/.volta/bin/node" + "$HOME/.nvm/versions/node/*/bin/node" + "$HOME/.fnm/aliases/default/bin/node" + "$HOME/n/bin/node" + "/usr/bin/node" + ) + for c in "${candidates[@]}"; do + for resolved in $c; do + if [ -x "$resolved" ]; then + echo "$resolved" + return 0 + fi + done + done + if command -v node >/dev/null 2>&1; then + command -v node + return 0 + fi + return 1 +} + +NODE_BIN="$(find_node)" || NODE_BIN="" + +if [ -z "$NODE_BIN" ]; then + osascript <<'OSA' +display dialog "Capsule needs Node.js (v20 or newer) to run. + +Install it from https://nodejs.org and try again." \ + with title "Capsule" \ + buttons {"Open nodejs.org", "OK"} \ + default button "Open nodejs.org" \ + with icon caution + if button returned of result is "Open nodejs.org" then + do shell script "open https://nodejs.org" + end if +OSA + exit 1 +fi +if ! "$NODE_BIN" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' >/dev/null 2>&1; then + NODE_VERSION="$("$NODE_BIN" -v 2>/dev/null || echo unknown)" + osascript < Building runtime + cli" +cd "$REPO_ROOT" +pnpm install --frozen-lockfile >/dev/null +pnpm -r build + +echo "==> Assembling Capsule.app" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" +cp -R "$BUNDLE_SRC" "$OUT_APP" + +if [ -f "$ASSET_DIR/capsule.icns" ]; then + mkdir -p "$OUT_APP/Contents/Resources" + cp "$ASSET_DIR/capsule.icns" "$OUT_APP/Contents/Resources/Capsule.icns" + cp "$ASSET_DIR/capsule.icns" "$OUT_APP/Contents/Resources/CapsuleDocument.icns" +fi + +# Copy each package's built dist + bin (skip node_modules; we'll re-install +# only the production deps inside the bundle). +RUNTIME_DIR="$OUT_APP/Contents/Resources/runtime" +mkdir -p "$RUNTIME_DIR/packages" +for pkg in capsule-core capsule-runtime capsule-cli; do + mkdir -p "$RUNTIME_DIR/packages/$pkg" + cp -R "packages/$pkg/dist" "$RUNTIME_DIR/packages/$pkg/dist" + if [ -d "packages/$pkg/bin" ]; then + cp -R "packages/$pkg/bin" "$RUNTIME_DIR/packages/$pkg/bin" + fi + cp "packages/$pkg/package.json" "$RUNTIME_DIR/packages/$pkg/package.json" +done + +# Minimal root package.json + workspace config so the bundled CLI can resolve +# its workspace siblings via pnpm/npm-style symlinks. +cat > "$RUNTIME_DIR/package.json" <<'JSON' +{ + "name": "capsule-bundle", + "private": true, + "version": "0.0.0", + "type": "module", + "workspaces": ["packages/*"] +} +JSON + +# Use plain npm (always present alongside Node) inside the bundle so we don't +# require the end user to have pnpm installed. +echo "==> Linking workspaces inside bundle" +node "$REPO_ROOT/installers/scripts/rewrite-workspace-deps.mjs" "$RUNTIME_DIR" +(cd "$RUNTIME_DIR" && npm install --omit=dev --no-audit --no-fund --silent) + +echo "==> Stamping bundle" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $(date +%Y%m%d%H%M)" \ + "$OUT_APP/Contents/Info.plist" 2>/dev/null || true + +echo "==> Done" +echo "Bundle: $OUT_APP" +echo +echo "Test it:" +echo " open '$OUT_APP' --args path/to/file.capsule" +echo +echo "Install to /Applications:" +echo " ./installers/macos/install.sh" diff --git a/installers/macos/dmg.sh b/installers/macos/dmg.sh new file mode 100755 index 0000000..8229d16 --- /dev/null +++ b/installers/macos/dmg.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# +# Build a distributable Capsule.dmg from the staged Capsule.app. +# +# If APPLE_DEVELOPER_ID, APPLE_ID, APPLE_TEAM_ID, and APPLE_APP_PASSWORD are +# set in the environment, the .app and .dmg are codesigned and notarized. +# Otherwise an unsigned DMG is produced (Gatekeeper will warn end users). +# +# Usage: +# ./installers/macos/build.sh # build the .app +# ./installers/macos/dmg.sh # then build the .dmg +# +# Output: installers/macos/build/Capsule-.dmg + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BUILD_DIR="$REPO_ROOT/installers/macos/build" +APP="$BUILD_DIR/Capsule.app" +VERSION="${CAPSULE_VERSION:-$(grep -m1 '"version"' "$REPO_ROOT/packages/capsule-cli/package.json" | sed -E 's/.*"version": *"([^"]+)".*/\1/')}" +DMG="$BUILD_DIR/Capsule-${VERSION}.dmg" +STAGING="$BUILD_DIR/dmg-staging" + +if [ ! -d "$APP" ]; then + echo "Capsule.app not found. Run installers/macos/build.sh first." + exit 1 +fi + +# --- Optional codesigning --- +if [ -n "${APPLE_DEVELOPER_ID:-}" ]; then + echo "==> Codesigning .app with Developer ID: $APPLE_DEVELOPER_ID" + # Sign every binary inside Resources/runtime/node_modules first (deep), + # then sign the app itself with hardened runtime. + codesign --force --options runtime --timestamp --deep \ + --sign "$APPLE_DEVELOPER_ID" "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" +else + echo "==> APPLE_DEVELOPER_ID not set — skipping codesign (output will be unsigned)" +fi + +# --- Stage DMG contents --- +echo "==> Staging DMG" +rm -rf "$STAGING" "$DMG" +mkdir -p "$STAGING" +cp -R "$APP" "$STAGING/Capsule.app" +ln -s /Applications "$STAGING/Applications" + +# Optional background image / volume icon +if [ -f "$REPO_ROOT/installers/macos/dmg-background.png" ]; then + mkdir -p "$STAGING/.background" + cp "$REPO_ROOT/installers/macos/dmg-background.png" "$STAGING/.background/background.png" +fi + +# --- Build DMG --- +echo "==> Creating $DMG" +hdiutil create \ + -volname "Capsule $VERSION" \ + -srcfolder "$STAGING" \ + -ov -format UDZO \ + "$DMG" + +rm -rf "$STAGING" + +# --- Optional notarization --- +if [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ] && [ -n "${APPLE_APP_PASSWORD:-}" ]; then + echo "==> Notarizing DMG (this can take a few minutes)" + xcrun notarytool submit "$DMG" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --wait + echo "==> Stapling notarization ticket" + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" +else + echo "==> Notarization secrets not set — skipping notarization" +fi + +# --- Provenance: sha256 + minisign-style signature if MINISIGN_KEY is set --- +SHA="$(shasum -a 256 "$DMG" | awk '{print $1}')" +echo "$SHA $(basename "$DMG")" > "$DMG.sha256" +echo "==> sha256: $SHA" + +if [ -n "${MINISIGN_SECRET_KEY_FILE:-}" ] && command -v minisign >/dev/null 2>&1; then + echo "==> Producing minisign signature" + MINISIGN_ARGS=() + if [ -n "${MINISIGN_PASSWORD:-}" ]; then + MINISIGN_ARGS=(-W "$MINISIGN_PASSWORD") + fi + minisign -S -s "$MINISIGN_SECRET_KEY_FILE" \ + -m "$DMG" \ + "${MINISIGN_ARGS[@]}" +fi + +echo +echo "Built: $DMG" +ls -lh "$DMG"* diff --git a/installers/macos/install.sh b/installers/macos/install.sh new file mode 100755 index 0000000..dd0e8ad --- /dev/null +++ b/installers/macos/install.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# +# Install Capsule.app to /Applications and register it as the handler for +# .capsule files in Finder. +# +# Usage: ./installers/macos/install.sh +# +# Reverses cleanly with: ./installers/macos/uninstall.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BUILD_APP="$REPO_ROOT/installers/macos/build/Capsule.app" +DEST_APP="/Applications/Capsule.app" + +if [ ! -d "$BUILD_APP" ]; then + echo "Capsule.app has not been built yet." + echo "Run: ./installers/macos/build.sh" + exit 1 +fi + +echo "==> Installing $DEST_APP" +if [ -d "$DEST_APP" ]; then + rm -rf "$DEST_APP" +fi +cp -R "$BUILD_APP" "$DEST_APP" + +# Force Launch Services to re-read the Info.plist so the file association +# becomes active immediately (otherwise it can take a logout/login). +echo "==> Registering with Launch Services" +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +if [ -x "$LSREGISTER" ]; then + "$LSREGISTER" -f "$DEST_APP" +fi + +# Set Capsule.app as the default opener for .capsule files. Falls back +# silently if `duti` isn't installed; the file association in Info.plist +# still works on first double-click via Launch Services anyway. +if command -v duti >/dev/null 2>&1; then + duti -s dev.capsule.runtime org.capsule.capsule all + echo "==> Set as default for .capsule files (via duti)" +else + echo "==> Tip: install \`duti\` (brew install duti) to force-set Capsule" + echo " as the default opener for .capsule files. Otherwise macOS will" + echo " pick it up automatically the first time you double-click one." +fi + +echo +echo "Installed." +echo "Try it: double-click any .capsule file in Finder." diff --git a/installers/macos/uninstall.sh b/installers/macos/uninstall.sh new file mode 100755 index 0000000..67a0d08 --- /dev/null +++ b/installers/macos/uninstall.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# +# Remove Capsule.app and its Launch Services registration. + +set -euo pipefail + +DEST_APP="/Applications/Capsule.app" + +if [ ! -d "$DEST_APP" ]; then + echo "Capsule.app is not installed at $DEST_APP." + exit 0 +fi + +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +if [ -x "$LSREGISTER" ]; then + "$LSREGISTER" -u "$DEST_APP" || true +fi + +rm -rf "$DEST_APP" +echo "Uninstalled." diff --git a/installers/scripts/rewrite-workspace-deps.mjs b/installers/scripts/rewrite-workspace-deps.mjs new file mode 100644 index 0000000..33d6b1a --- /dev/null +++ b/installers/scripts/rewrite-workspace-deps.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const runtimeDir = process.argv[2]; +if (!runtimeDir) { + console.error("usage: rewrite-workspace-deps.mjs "); + process.exit(1); +} + +const packagesDir = path.join(runtimeDir, "packages"); +const packageDirs = await readdir(packagesDir, { withFileTypes: true }); +const packageByName = new Map(); + +for (const entry of packageDirs) { + if (!entry.isDirectory()) continue; + const packageJsonPath = path.join(packagesDir, entry.name, "package.json"); + const pkg = JSON.parse(await readFile(packageJsonPath, "utf8")); + if (typeof pkg.name === "string") { + packageByName.set(pkg.name, entry.name); + } +} + +for (const entry of packageDirs) { + if (!entry.isDirectory()) continue; + const packageJsonPath = path.join(packagesDir, entry.name, "package.json"); + const pkg = JSON.parse(await readFile(packageJsonPath, "utf8")); + let changed = false; + + for (const section of ["dependencies", "optionalDependencies", "peerDependencies"]) { + const deps = pkg[section]; + if (!deps || typeof deps !== "object") continue; + + for (const [name, spec] of Object.entries(deps)) { + if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue; + const packageDir = packageByName.get(name); + if (!packageDir) { + throw new Error(`Cannot rewrite ${name}@${spec}; no staged package found`); + } + deps[name] = `file:../${packageDir}`; + changed = true; + } + } + + if (changed) { + await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); + } +} diff --git a/installers/windows/install.ps1 b/installers/windows/install.ps1 new file mode 100644 index 0000000..1dd81ae --- /dev/null +++ b/installers/windows/install.ps1 @@ -0,0 +1,175 @@ +<# +.SYNOPSIS + Install Capsule on Windows and register it as the handler for .capsule files. + +.DESCRIPTION + Installs a packaged runtime, or builds one when run from a source checkout. + It copies the runtime under %LOCALAPPDATA%\Capsule, drops a launcher .cmd + into the same folder, and writes the registry entries that make Explorer + route .capsule double-clicks to it. + + No admin rights required — everything is per-user (HKCU). + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File .\installers\windows\install.ps1 + +.NOTES + Tested on Windows 10/11 with Node 20+ and PowerShell 5+. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $PSCommandPath +$RepoRootCandidate = Resolve-Path (Join-Path $ScriptDir "..\..") -ErrorAction SilentlyContinue +$RepoRoot = if ($RepoRootCandidate) { $RepoRootCandidate.Path } else { $null } +$BundledRuntime = Join-Path $ScriptDir "runtime" +$BundledAssets = Join-Path $ScriptDir "assets" +$SourceAssets = if ($RepoRoot) { Join-Path $RepoRoot "installers\assets" } else { $null } +$AssetDir = if (Test-Path $BundledAssets) { $BundledAssets } elseif ($SourceAssets -and (Test-Path $SourceAssets)) { $SourceAssets } else { $null } +$InstallDir = Join-Path $env:LOCALAPPDATA "Capsule" +$RuntimeDir = Join-Path $InstallDir "runtime" +$Launcher = Join-Path $InstallDir "capsule-launcher.cmd" +$IconPath = Join-Path $InstallDir "capsule.ico" + +function Test-Node20 { + $node = Get-Command node -ErrorAction SilentlyContinue + if (-not $node) { return $false } + & node -e "process.exit(Number(process.versions.node.split('.')[0]) >= 20 ? 0 : 1)" *> $null + return $LASTEXITCODE -eq 0 +} + +if (-not (Test-Node20)) { + throw "Capsule needs Node.js v20 or newer. Install it from https://nodejs.org and re-run this installer." +} + +$UseBundledRuntime = Test-Path (Join-Path $BundledRuntime "packages\capsule-cli") +$UseSourceRuntime = -not $UseBundledRuntime -and $RepoRoot -and (Test-Path (Join-Path $RepoRoot "packages\capsule-cli")) + +if ($UseSourceRuntime) { + if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) { + throw "Source install needs pnpm. Use a release zip to install without pnpm." + } + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + throw "Source install needs npm. Install Node.js from https://nodejs.org and try again." + } + + Write-Host "==> Building runtime" + Push-Location $RepoRoot + try { + pnpm install --frozen-lockfile | Out-Null + pnpm -r build + } finally { + Pop-Location + } +} elseif (-not $UseBundledRuntime) { + throw "No packaged runtime or source checkout found. Run this from a Capsule release zip, or from the repository checkout." +} + +# --- Stage files --- +Write-Host "==> Installing to $InstallDir" +if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir } +New-Item -ItemType Directory -Force -Path $RuntimeDir | Out-Null + +if ($UseBundledRuntime) { + Write-Host "==> Copying packaged runtime" + Copy-Item -Recurse -Force (Join-Path $BundledRuntime "*") $RuntimeDir +} else { + + foreach ($pkg in @("capsule-core", "capsule-runtime", "capsule-cli")) { + $src = Join-Path $RepoRoot "packages\$pkg" + $dst = Join-Path $RuntimeDir "packages\$pkg" + New-Item -ItemType Directory -Force -Path $dst | Out-Null + Copy-Item -Recurse -Force (Join-Path $src "dist") (Join-Path $dst "dist") + if (Test-Path (Join-Path $src "bin")) { + Copy-Item -Recurse -Force (Join-Path $src "bin") (Join-Path $dst "bin") + } + Copy-Item -Force (Join-Path $src "package.json") (Join-Path $dst "package.json") + } + + @' +{ + "name": "capsule-bundle", + "private": true, + "version": "0.0.0", + "type": "module", + "workspaces": ["packages/*"] +} +'@ | Set-Content -Encoding UTF8 (Join-Path $RuntimeDir "package.json") + + node (Join-Path $RepoRoot "installers\scripts\rewrite-workspace-deps.mjs") $RuntimeDir + + Push-Location $RuntimeDir + try { + npm install --omit=dev --no-audit --no-fund --silent | Out-Null + } finally { + Pop-Location + } +} + +if ($AssetDir) { + $IconSource = Join-Path $AssetDir "capsule.ico" + if (Test-Path $IconSource) { + Copy-Item -Force $IconSource $IconPath + } +} + +# --- Launcher .cmd --- +$entry = Join-Path $RuntimeDir "packages\capsule-cli\bin\capsule.mjs" +@" +@echo off +setlocal +where node >nul 2>nul +if errorlevel 1 ( + powershell -Command "[System.Windows.Forms.MessageBox]::Show('Capsule needs Node.js (v20 or newer). Install from https://nodejs.org', 'Capsule', 'OK', 'Warning')" + exit /b 1 +) +node -e "process.exit(Number(process.versions.node.split('.')[0]) >= 20 ? 0 : 1)" >nul 2>nul +if errorlevel 1 ( + powershell -Command "[System.Windows.Forms.MessageBox]::Show('Capsule needs Node.js v20 or newer.', 'Capsule', 'OK', 'Warning')" + exit /b 1 +) +if "%~1"=="" ( + powershell -Command "[System.Windows.Forms.MessageBox]::Show('Capsule is installed. Double-click a .capsule file to open it.', 'Capsule', 'OK', 'Information')" + exit /b 0 +) +node "$entry" run %* +"@ | Set-Content -Encoding ASCII $Launcher + +# --- File association (HKCU) --- +Write-Host "==> Registering file association" + +$progId = "Capsule.Document" +$extKey = "HKCU:\Software\Classes\.capsule" +$progIdKey = "HKCU:\Software\Classes\$progId" +$openCmdKey = "$progIdKey\shell\open\command" +$iconKey = "$progIdKey\DefaultIcon" + +New-Item -Force -Path $extKey | Out-Null +Set-Item -Path $extKey -Value $progId +Set-ItemProperty -Path $extKey -Name "Content Type" -Value "application/vnd.capsule+zip" + +New-Item -Force -Path $progIdKey | Out-Null +Set-Item -Path $progIdKey -Value "Capsule Document" +Set-ItemProperty -Path $progIdKey -Name "FriendlyTypeName" -Value "Capsule Document" +if (Test-Path $IconPath) { + New-Item -Force -Path $iconKey | Out-Null + Set-Item -Path $iconKey -Value "`"$IconPath`",0" +} + +New-Item -Force -Path $openCmdKey | Out-Null +Set-Item -Path $openCmdKey -Value "`"$Launcher`" `"%1`"" + +# Tell the shell to refresh associations now. +$signature = @' +[DllImport("Shell32.dll")] +public static extern void SHChangeNotify(int eventId, int flags, IntPtr item1, IntPtr item2); +'@ +$shell = Add-Type -MemberDefinition $signature -Name "Shell32Notify" -PassThru +$shell::SHChangeNotify(0x08000000, 0x0000, [IntPtr]::Zero, [IntPtr]::Zero) + +Write-Host "" +Write-Host "Installed." +Write-Host "Try double-clicking any .capsule file." diff --git a/installers/windows/uninstall.ps1 b/installers/windows/uninstall.ps1 new file mode 100644 index 0000000..042b293 --- /dev/null +++ b/installers/windows/uninstall.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Reverse installers/windows/install.ps1 +#> + +$ErrorActionPreference = "SilentlyContinue" + +Remove-Item -Recurse -Force (Join-Path $env:LOCALAPPDATA "Capsule") +Remove-Item -Recurse -Force "HKCU:\Software\Classes\.capsule" +Remove-Item -Recurse -Force "HKCU:\Software\Classes\Capsule.Document" + +$signature = @' +[DllImport("Shell32.dll")] +public static extern void SHChangeNotify(int eventId, int flags, IntPtr item1, IntPtr item2); +'@ +(Add-Type -MemberDefinition $signature -Name "Shell32Notify" -PassThru)::SHChangeNotify(0x08000000, 0x0000, [IntPtr]::Zero, [IntPtr]::Zero) + +Write-Host "Uninstalled."