Skip to content

Commit 5feb43d

Browse files
Userclaude
authored andcommitted
feat(pro): Keygen license activation + macOS core/pro signed release
Port the mobile Keygen licensing to desktop main (validate/activate/ revalidate, encrypted license cache, device fingerprint), gate pro at runtime via the cached entitlement, and add license-key activation to the UpgradeScreen. Wire a macOS-only CI release that builds + Developer-ID-signs + notarizes both the core (free) and pro artifacts, cloning the private desktop-pro source into pro/ and publishing pro to a separate update channel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bfdc565 commit 5feb43d

15 files changed

Lines changed: 1035 additions & 80 deletions

File tree

.github/workflows/release.yml

Lines changed: 66 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ concurrency:
1212
group: release
1313
cancel-in-progress: false
1414

15+
# macOS-only for now: core (free) + pro, both Developer-ID signed + notarized.
16+
# Pro source lives in the separate private repo off-grid-ai/desktop-pro (gitignored
17+
# here), cloned into pro/ at build time via the PRO_REPO_TOKEN secret. Windows is
18+
# parked (bundled llama-server fix owned elsewhere) — re-add a windows-2022 job when
19+
# it lands.
1520
jobs:
16-
# Bump the version once (on Ubuntu) and push it, so every matrix job builds the
21+
# Bump the version once (on Ubuntu) and push it, so every build job builds the
1722
# same version. [skip ci] keeps it from re-triggering.
1823
version:
1924
if: "!contains(github.event.head_commit.message, '[skip ci]')"
@@ -45,16 +50,12 @@ jobs:
4550
# mismatched/duplicate release (the version race that tangled v0.0.20/.21).
4651
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
4752
48-
# Build + publish per-OS natively (native modules + platform binaries differ).
49-
build:
53+
# CORE (free) build: OFFGRID_FORCE_CORE=1 makes the build ignore any pro/ that might
54+
# be present → __OFFGRID_PRO__ false (in CI pro/ isn't there anyway; belt-and-braces).
55+
# Publishes to the default update channel (latest-mac.yml).
56+
build-mac-core:
5057
needs: version
51-
strategy:
52-
fail-fast: false
53-
matrix:
54-
# windows-2022 = VS 2022 toolchain; windows-latest ships VS 18 (2026)
55-
# which node-gyp 11 can't parse, breaking native-module compiles.
56-
os: [macos-latest, windows-2022]
57-
runs-on: ${{ matrix.os }}
58+
runs-on: macos-latest
5859
steps:
5960
- uses: actions/checkout@v4
6061
with:
@@ -69,34 +70,13 @@ jobs:
6970
with:
7071
node-version: '20'
7172
cache: 'npm'
72-
73-
# node-gyp (better-sqlite3-multiple-ciphers) fails on Python 3.13+; pin 3.12.
74-
- uses: actions/setup-python@v5
75-
if: runner.os == 'Windows'
76-
with:
77-
python-version: '3.12'
78-
7973
- name: Install dependencies
8074
run: npm ci
81-
82-
# Windows needs win64 native runner binaries (the repo ships macOS ones).
83-
- name: Fetch Windows binaries
84-
if: runner.os == 'Windows'
85-
shell: pwsh
86-
run: ./scripts/fetch-win-binaries.ps1
87-
88-
# macOS signing identity (no-op if the secret is absent → unsigned build).
89-
- name: Import Apple signing certificate
90-
if: runner.os == 'macOS' && env.CSC_LINK != ''
75+
- name: Build (typecheck + bundle) — CORE
9176
env:
92-
CSC_LINK: ${{ secrets.CSC_LINK }}
93-
run: echo "Signing cert provided via CSC_LINK"
94-
95-
- name: Build (typecheck + bundle)
77+
OFFGRID_FORCE_CORE: '1'
9678
run: npm run build
97-
98-
- name: Package & publish (macOS)
99-
if: runner.os == 'macOS'
79+
- name: Package & publish CORE (signed + notarized)
10080
env:
10181
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10282
CSC_LINK: ${{ secrets.CSC_LINK }}
@@ -109,23 +89,58 @@ jobs:
10989
# notarytool needs the API key as a file; write it from the secret.
11090
printf '%s' "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
11191
export APPLE_API_KEY="$RUNNER_TEMP/AuthKey.p8"
112-
npx electron-builder --mac --publish always
92+
npx electron-builder --mac \
93+
-c.productName="Off Grid AI" \
94+
-c.appId="co.getoffgridai.desktop" \
95+
-c.dmg.artifactName='OffGrid-core-${version}.${ext}' \
96+
--publish always
11397
114-
- name: Package & publish (Windows)
115-
if: runner.os == 'Windows'
98+
# PRO build: clone the private pro source into pro/ → __OFFGRID_PRO__ true. Pro is
99+
# license-gated at runtime (a leaked DMG is inert without a valid Keygen key), so
100+
# publishing it publicly is safe. Publishes to a SEPARATE update channel (pro-mac.yml)
101+
# so core and pro auto-updates never feed each other the wrong binary.
102+
build-mac-pro:
103+
needs: version
104+
runs-on: macos-latest
105+
steps:
106+
- uses: actions/checkout@v4
107+
with:
108+
ref: ${{ needs.version.outputs.sha }}
109+
fetch-depth: 0
110+
lfs: true
111+
- name: Pull LFS binaries
112+
run: git lfs pull
113+
- name: Clone private pro source into pro/
116114
env:
117-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
118-
# Optional Windows code signing (no-op if absent → unsigned, SmartScreen warns)
119-
CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
120-
CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
121-
run: npx electron-builder --win --publish always
122-
123-
- name: Upload artifacts
124-
uses: actions/upload-artifact@v4
115+
PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }}
116+
run: |
117+
rm -rf pro
118+
git clone --depth 1 \
119+
"https://x-access-token:${PRO_REPO_TOKEN}@github.com/off-grid-ai/desktop-pro.git" pro
120+
rm -rf pro/.git # don't nest a repo inside the build tree
121+
- uses: actions/setup-node@v4
125122
with:
126-
name: dist-${{ matrix.os }}
127-
path: |
128-
dist/*.dmg
129-
dist/*.exe
130-
dist/*.zip
131-
if-no-files-found: ignore
123+
node-version: '20'
124+
cache: 'npm'
125+
- name: Install dependencies
126+
run: npm ci
127+
- name: Build (typecheck + bundle) — PRO
128+
run: npm run build
129+
- name: Package & publish PRO (signed + notarized, pro channel)
130+
env:
131+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
132+
CSC_LINK: ${{ secrets.CSC_LINK }}
133+
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
134+
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
135+
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
136+
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
137+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
138+
run: |
139+
printf '%s' "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
140+
export APPLE_API_KEY="$RUNNER_TEMP/AuthKey.p8"
141+
npx electron-builder --mac \
142+
-c.productName="Off Grid AI Pro" \
143+
-c.appId="co.getoffgridai.desktop.pro" \
144+
-c.dmg.artifactName='OffGrid-pro-${version}.${ext}' \
145+
-c.publish.channel=pro \
146+
--publish always

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<a href="https://github.com/off-grid-ai/desktop/releases/latest">Download (macOS · Windows)</a> ·
1919
<a href="docs/FEATURES.md">Features</a> ·
2020
<a href="https://getoffgridai.co">getoffgridai.co</a> ·
21-
<a href="https://getoffgridai.co/early-access/">Pro early access</a>
21+
<a href="https://getoffgridai.co/pay">Get Pro</a>
2222
</p>
2323

2424
<p align="center">
@@ -156,7 +156,7 @@ curl -X POST http://127.0.0.1:7878/v1/models/activate \
156156
-H 'Content-Type: application/json' -d '{"id":"unsloth/gemma-4-E4B-it-GGUF"}'
157157
```
158158

159-
## Off Grid Pro — coming July 2026
159+
## Off Grid Pro — available now
160160

161161
The free app **runs** models. **Pro** adds the always-on layer that turns your own work
162162
into private, on-device memory — and an assistant that helps you act on it. Everything is
@@ -175,9 +175,11 @@ explicit opt-in, with a visible recording indicator, and nothing leaves the devi
175175
as authoritative sources, and a skills framework (trigger → action) — on the roadmap
176176
toward a proactive secretary and a prospective "Ahead" view of your day.
177177

178-
Pro launches **July 2026** — already paid? You're first in line when it ships. Pro features
179-
live in a separate **private** package (a `pro/` submodule); the open core never imports it —
180-
see [Architecture](#architecture--open-core).
178+
**Pro is live.** Buy a license at **[getoffgridai.co/pay](https://getoffgridai.co/pay)** — you're
179+
emailed a license key, you install the Pro build, and you activate the key in-app (one license
180+
covers multiple devices). Pro features live in a separate **private** package; the open core
181+
never imports it — see [Architecture](#architecture--open-core). The Pro build is license-gated
182+
at runtime, so it stays locked until a valid key is activated.
181183

182184
<table>
183185
<tr>
@@ -196,8 +198,7 @@ see [Architecture](#architecture--open-core).
196198

197199
<sub>Pro screens shown with synthetic demo data.</sub>
198200

199-
**[Join early access](https://getoffgridai.co/early-access/)** (free) — or
200-
**[pay now](https://getoffgridai.co/pay)** for lifetime free + first access.
201+
**[Get Pro](https://getoffgridai.co/pay)** — buy a license, install the Pro build, activate your key.
201202

202203
## Install
203204

docs/RELEASE_DESKTOP.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Off Grid AI Desktop — Release Runbook
2+
3+
How we ship Off Grid AI Desktop: two artifacts (**core** = free, **pro** =
4+
license-gated), signed + notarized, with Keygen license activation. Mirrors the
5+
mobile licensing model (same Keygen account/product — a key works on both).
6+
7+
---
8+
9+
## 0. Mental model
10+
11+
- **Core build** — no `pro/` submodule code bundled (`__OFFGRID_PRO__ = false`).
12+
Free features only. Pro tabs show the UpgradeScreen.
13+
- **Pro build**`pro/` submodule bundled (`__OFFGRID_PRO__ = true`), but pro
14+
stays **locked until a valid Keygen license is activated** at runtime. A leaked
15+
pro DMG is inert without a key.
16+
- The gate lives in the **main process** (`src/main/bootstrap/loadProFeaturesMain.ts`
17+
`proEnabled()`), which consults `isProEntitled()` from
18+
`src/main/licensing/license-service.ts`. The renderer reads the verdict
19+
synchronously at load via the `pro:is-enabled` IPC (preload → `window.api.isPro`).
20+
21+
Env overrides (dev/contributor only):
22+
- `OFFGRID_PRO=0` → force free even in a pro build.
23+
- `OFFGRID_PRO=1` → force pro on **without** a license (for working on pro features).
24+
- unset → the real paid path: license-gated.
25+
- `OFFGRID_FORCE_CORE=1` (build-time) → build a core artifact even though `pro/`
26+
is checked out (used to produce both DMGs from one checkout).
27+
28+
---
29+
30+
## 1. Local test build (no signing, no publish) ← do this BEFORE any release
31+
32+
The last macOS release published but didn't run properly. Always smoke-test a
33+
**packaged** build locally first.
34+
35+
```bash
36+
git lfs pull # ensure resources/bin/* are real binaries, not LFS stubs
37+
./scripts/build-mac-local.sh both # → dist/OffGrid-core-<v>.dmg + dist/OffGrid-pro-<v>.dmg
38+
# or: ./scripts/build-mac-local.sh core / pro
39+
```
40+
41+
These builds are **unsigned** (no cert/Apple-ID prompts) and never touch GitHub.
42+
43+
### Smoke test
44+
1. Open `dist/OffGrid-core-<v>.dmg`, drag to /Applications, **right-click → Open**
45+
(unsigned → Gatekeeper). Confirm: app launches, a model downloads + chat works,
46+
pro tabs show the UpgradeScreen (no license box, since core).
47+
2. Open `dist/OffGrid-pro-<v>.dmg` similarly. Confirm: app launches, pro tabs show
48+
the UpgradeScreen **with** a "Enter your license key" box. Paste a valid key →
49+
"Activated. Restart to finish unlocking Pro." → Restart → pro tabs unlock.
50+
3. Things that broke before — verify explicitly: local model server starts on
51+
:7878 (chat responds), whisper/ffmpeg present, no missing-binary errors in the
52+
console (`Console.app` → filter "Off Grid").
53+
54+
> Both DMGs use distinct appIds (`…desktop` / `…desktop.pro`) so they can be
55+
> installed side by side. They share the canonical userData dir
56+
> (`~/Library/Application Support/Off Grid AI Desktop`), so an activated license
57+
> persists across both.
58+
59+
---
60+
61+
## 2. Licensing — how a purchase becomes an unlock
62+
63+
This backend already exists (shared with mobile). Nothing to build:
64+
65+
```
66+
Buy Pro on web (RevenueCat checkout)
67+
→ RevenueCat webhook → Cloudflare Worker (license.getoffgridai.co)
68+
→ Worker creates/renews a Keygen license, emails the key via Resend
69+
→ user pastes key into the app (UpgradeScreen)
70+
→ app validates via Keygen + activates this machine (platform tag macos/windows)
71+
→ entitlement cached, encrypted, in userData/license.json
72+
→ background revalidation catches expiry/revocation when online
73+
```
74+
75+
Desktop licensing code (ported from mobile):
76+
- `src/main/licensing/keygen-config.ts` — account/product/policy IDs, public key (non-secret).
77+
- `src/main/licensing/keygen-client.ts` — Keygen REST (validate / activate / list / deactivate).
78+
- `src/main/licensing/device-fingerprint.ts` — stable per-install id (userData file).
79+
- `src/main/licensing/license-service.ts` — entitlement logic, encrypted cache, revalidation.
80+
- `src/main/license-ipc.ts` — IPC: `pro:is-enabled` (sync), `license:activate|status|…`.
81+
82+
Device cap is enforced server-side by Keygen. Reinstalls reuse the same
83+
fingerprint and reclaim their slot.
84+
85+
---
86+
87+
## 3. Real release (signed + notarized, two artifacts)
88+
89+
> Not yet wired into CI as two artifacts — see TODO at the bottom. The mechanics:
90+
91+
### macOS (working today)
92+
Signing uses the Apple Developer ID + notarization (`electron-builder.yml`
93+
`mac.notarize: true`). CI secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`,
94+
`APPLE_API_KEY*`. Do **not** add an afterSign re-sign hook — it invalidates the
95+
notarization staple (this was a past bug).
96+
97+
### Windows — PARKED
98+
Windows is on hold and owned elsewhere (the bundled llama-server doesn't start in
99+
the Windows binary; being fixed separately). Azure Trusted Signing setup (§4) is
100+
kept below for when Windows resumes, but it is **not** on the current path. Focus
101+
is macOS core + pro.
102+
103+
### Per-artifact config
104+
Core and pro differ only by `OFFGRID_FORCE_CORE`, `productName`, `appId`, and
105+
`artifactName` (see `scripts/build-mac-local.sh` for the exact overrides). They
106+
must publish to **separate update channels** so electron-updater never feeds a
107+
core user a pro binary (or vice versa).
108+
109+
---
110+
111+
## 4. Azure Trusted Signing setup (Windows) — PARKED (kept for when Windows resumes)
112+
113+
Modern Windows code-signing requires the private key on certified hardware/HSM,
114+
so a downloadable `.pfx` no longer works in CI. Azure Trusted Signing is the
115+
cheapest CI-native option (~$10/mo) and electron-builder 25+ supports it natively.
116+
117+
### One-time setup (Azure portal — only the org owner can do this)
118+
1. **Subscription + provider**: in an Azure subscription, register the
119+
`Microsoft.CodeSigning` resource provider.
120+
2. **Trusted Signing Account**: create one (region: East US / West US3 /
121+
West Central US / North or West Europe).
122+
3. **Identity Validation****(1–3 weeks — start FIRST)**: submit legal business
123+
name + D-U-N-S + address. The validated name becomes the cert subject and your
124+
`publisherName`.
125+
4. **Certificate Profile**: once validation passes, create a **Public Trust** profile.
126+
5. **CI service principal**: create an App Registration + client secret, and assign
127+
it the **"Trusted Signing Certificate Profile Signer"** role on the account.
128+
129+
### Values to collect
130+
| What | Where it goes |
131+
|------|----------------|
132+
| `endpoint` (e.g. `https://eus.codesigning.azure.net/`) | electron-builder config |
133+
| `codeSigningAccountName` | electron-builder config |
134+
| `certificateProfileName` | electron-builder config |
135+
| `publisherName` (exact validated org name) | electron-builder config |
136+
| `AZURE_TENANT_ID` | GitHub repo secret |
137+
| `AZURE_CLIENT_ID` | GitHub repo secret |
138+
| `AZURE_CLIENT_SECRET` | GitHub repo secret |
139+
140+
### electron-builder wiring (when values are in hand)
141+
Add to the Windows config (electron-builder 25+ reads `AZURE_*` env via
142+
DefaultAzureCredential and auto-downloads the Trusted Signing dlib):
143+
144+
```yaml
145+
win:
146+
executableName: off-grid-ai
147+
azureSignOptions:
148+
publisherName: "<validated org name>"
149+
endpoint: "https://<region>.codesigning.azure.net/"
150+
certificateProfileName: "<profile>"
151+
codeSigningAccountName: "<account>"
152+
```
153+
154+
SmartScreen reputation accrues over downloads; Microsoft-backed certs gain trust
155+
quickly. No EV needed.
156+
157+
---
158+
159+
## TODO before first paid release (macOS)
160+
- [ ] Split `.github/workflows/release.yml` into **macOS** core + pro build jobs,
161+
using `OFFGRID_FORCE_CORE` and the per-artifact overrides; separate update channels.
162+
- [ ] Confirm the RevenueCat offering/products issue desktop-valid keys (they're
163+
product-scoped, so likely already do) and the key email copy isn't mobile-only.
164+
- [ ] Device-management UI (list/deactivate machines) — backend is ready
165+
(`license:list-devices` / `license:deactivate`).
166+
167+
### Parked (owned elsewhere)
168+
- [ ] Windows: bundled llama-server doesn't start — fix in progress separately.
169+
- [ ] Azure Trusted Signing validated + `azureSignOptions` wired (§4) — resumes with Windows.

electron.vite.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import tailwindcss from '@tailwindcss/vite'
88
// builds. When it's missing (free / contributor build) we alias the pro entry
99
// points to a null stub so the app builds and runs with core features only.
1010
// Mirrors mobile/metro.config.js (proExists + extraNodeModules).
11-
const proExists = existsSync(resolve('pro/package.json'))
11+
// OFFGRID_FORCE_CORE=1 builds the free/core artifact even when the pro/ submodule
12+
// is checked out — lets CI (and local) produce BOTH core and pro DMGs from one
13+
// checkout without removing the submodule.
14+
const forceCore = process.env.OFFGRID_FORCE_CORE === '1'
15+
const proExists = !forceCore && existsSync(resolve('pro/package.json'))
1216
const stub = resolve('src/bootstrap/proStub.ts')
1317
const proMain = proExists ? resolve('pro/main/index.ts') : stub
1418
const proRenderer = proExists ? resolve('pro/renderer/index.tsx') : stub

0 commit comments

Comments
 (0)