Skip to content

Commit 36811ae

Browse files
lsteinclaude
andauthored
feat(installer): signed uv-bootstrap desktop launcher (#300)
* feat(installer): signed uv-bootstrap desktop launcher Replace the fragile shell/PyInstaller install paths with a small native launcher that, on first run, uses a bundled uv to install a managed CPython and the photomapai package into a per-user runtime dir, then starts the server and opens the browser. Because the launcher is tiny (no torch bundled), signing/notarization is fast and cheap. Launcher (launcher/, Go): - Embeds uv (-tags embed_uv); torch backend via `uv tool install --torch-backend auto` (GPU auto-detected, no CUDA index to maintain). - Honors PHOTOMAP_PORT/PHOTOMAP_HOST for its readiness poll + browser, and forwards extra args to start_photomap after `--`. - Runs the server from a neutral cwd so a stray ./photomap source tree can't shadow the installed package. Browser auto-launch: - photomap/backend/browser.py + --no-browser / PHOTOMAP_NO_BROWSER, wired into the worker and supervisor (opens once, guarded for reload/non-loopback/ Docker/headless). Dockerfile sets PHOTOMAP_NO_BROWSER=1. CI / signing: - .github/workflows/deploy-launcher.yml: per-OS build + sign + package (notarized .dmg, Azure Trusted Signing + Inno setup.exe, AppImage). Replaces deploy-pyinstaller.yml (deprecated). Packaging helpers under INSTALL/launcher/. `make appimage` builds the Linux artifact on demand. Quick wins: - install_linux_mac.sh: pip install -e . -> pip install .; stamp Info.plist version instead of hardcoded 0.3.0. - Docs (installation.md, README, index.md) lead with the signed installers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build: add fast 'make launcher' target (non-embed binary) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b2f96aa commit 36811ae

29 files changed

Lines changed: 1605 additions & 349 deletions
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
name: Deploy Launcher
2+
3+
# Builds the small native launcher for each OS, signs/notarizes it, packages it
4+
# (.dmg / setup.exe / .AppImage), and uploads the result as a build artifact.
5+
# Replaces deploy-pyinstaller.yml: the launcher does NOT bundle torch, so there
6+
# is no cpu/cu129 matrix and no disk-space juggling — builds are small and fast.
7+
#
8+
# Required repository secrets (signing is skipped gracefully if absent, so
9+
# workflow_dispatch still produces unsigned artifacts for testing):
10+
# macOS MACOS_CERT_P12_BASE64, MACOS_CERT_PASSWORD, MACOS_KEYCHAIN_PASSWORD,
11+
# APPLE_API_KEY_P8_BASE64, APPLE_API_KEY_ID, APPLE_API_ISSUER_ID
12+
# Windows AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET
13+
#
14+
# Required repository variables (non-secret Azure Trusted Signing config):
15+
# AZURE_CODESIGN_ENDPOINT e.g. https://eus.codesigning.azure.net
16+
# AZURE_CODESIGN_ACCOUNT the Trusted Signing account name
17+
# AZURE_CODESIGN_PROFILE the certificate profile name
18+
19+
on:
20+
workflow_call:
21+
secrets:
22+
MACOS_CERT_P12_BASE64: { required: false }
23+
MACOS_CERT_PASSWORD: { required: false }
24+
MACOS_KEYCHAIN_PASSWORD: { required: false }
25+
APPLE_API_KEY_P8_BASE64: { required: false }
26+
APPLE_API_KEY_ID: { required: false }
27+
APPLE_API_ISSUER_ID: { required: false }
28+
AZURE_TENANT_ID: { required: false }
29+
AZURE_CLIENT_ID: { required: false }
30+
AZURE_CLIENT_SECRET: { required: false }
31+
workflow_dispatch:
32+
33+
env:
34+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
35+
# Pin the uv we have tested the launcher against (must support
36+
# `--torch-backend` on `uv tool install`).
37+
UV_VERSION: "0.11.19"
38+
GO_VERSION: "1.22"
39+
40+
jobs:
41+
version:
42+
runs-on: ubuntu-latest
43+
outputs:
44+
version: ${{ steps.get_version.outputs.version }}
45+
steps:
46+
- uses: actions/checkout@v5
47+
- uses: actions/setup-python@v6
48+
with:
49+
python-version: '3.12'
50+
- name: Extract version from pyproject.toml
51+
id: get_version
52+
run: |
53+
VERSION=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
54+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
55+
56+
# --------------------------------------------------------------------------
57+
linux:
58+
needs: version
59+
runs-on: ubuntu-22.04
60+
env:
61+
VERSION: ${{ needs.version.outputs.version }}
62+
steps:
63+
- uses: actions/checkout@v5
64+
- uses: actions/setup-go@v5
65+
with:
66+
go-version: ${{ env.GO_VERSION }}
67+
68+
- name: Fetch uv ${{ env.UV_VERSION }}
69+
run: |
70+
mkdir -p launcher/assets
71+
curl -fsSL -o /tmp/uv.tar.gz \
72+
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz"
73+
tar -xzf /tmp/uv.tar.gz --strip-components=1 -C launcher/assets uv-x86_64-unknown-linux-gnu/uv
74+
mv launcher/assets/uv launcher/assets/uv-bin
75+
76+
- name: Build launcher
77+
run: |
78+
mkdir -p dist
79+
cd launcher
80+
go build -tags embed_uv -ldflags "-X main.version=${VERSION}" -o ../dist/photomap .
81+
82+
- name: Build AppImage
83+
run: |
84+
curl -fsSL -o /tmp/appimagetool \
85+
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
86+
chmod +x /tmp/appimagetool
87+
export PATH="/tmp:$PATH"
88+
export APPIMAGE_EXTRACT_AND_RUN=1 # CI has no FUSE
89+
chmod +x INSTALL/launcher/linux/build_appimage.sh
90+
INSTALL/launcher/linux/build_appimage.sh \
91+
dist/photomap "${VERSION}" \
92+
"dist/PhotoMapAI-${VERSION}-x86_64.AppImage" \
93+
photomap/frontend/static/icons/favicon-32x32.png
94+
95+
- uses: actions/upload-artifact@v5
96+
with:
97+
name: photomap-launcher-linux-x64-v${{ env.VERSION }}
98+
path: dist/PhotoMapAI-${{ env.VERSION }}-x86_64.AppImage
99+
retention-days: 30
100+
101+
# --------------------------------------------------------------------------
102+
macos:
103+
needs: version
104+
runs-on: macos-latest
105+
env:
106+
VERSION: ${{ needs.version.outputs.version }}
107+
# 'true' when the signing/notarization secrets are configured.
108+
SIGNING_ENABLED: ${{ secrets.MACOS_CERT_P12_BASE64 != '' && secrets.APPLE_API_KEY_P8_BASE64 != '' }}
109+
steps:
110+
- uses: actions/checkout@v5
111+
- uses: actions/setup-go@v5
112+
with:
113+
go-version: ${{ env.GO_VERSION }}
114+
115+
- name: Fetch uv ${{ env.UV_VERSION }}
116+
run: |
117+
mkdir -p launcher/assets
118+
# macos-latest runners are Apple Silicon (arm64). Intel Macs are not
119+
# covered by this build; add an x86_64 leg / universal2 if needed.
120+
curl -fsSL -o /tmp/uv.tar.gz \
121+
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-aarch64-apple-darwin.tar.gz"
122+
tar -xzf /tmp/uv.tar.gz --strip-components=1 -C launcher/assets uv-aarch64-apple-darwin/uv
123+
mv launcher/assets/uv launcher/assets/uv-bin
124+
125+
- name: Build launcher
126+
run: |
127+
mkdir -p dist
128+
cd launcher
129+
go build -tags embed_uv -ldflags "-X main.version=${VERSION}" -o ../dist/photomap .
130+
131+
- name: Assemble .app
132+
run: |
133+
chmod +x INSTALL/launcher/macos/build_app.sh
134+
INSTALL/launcher/macos/build_app.sh \
135+
dist/photomap "${VERSION}" "dist/PhotoMapAI.app" \
136+
photomap/frontend/static/icons/icon.icns
137+
138+
- name: Import signing certificate
139+
if: env.SIGNING_ENABLED == 'true'
140+
env:
141+
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
142+
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
143+
MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
144+
run: |
145+
KEYCHAIN="$RUNNER_TEMP/build.keychain"
146+
security create-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
147+
security set-keychain-settings -lut 21600 "$KEYCHAIN"
148+
security unlock-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
149+
echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
150+
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
151+
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
152+
security list-keychains -d user -s "$KEYCHAIN" login.keychain
153+
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" | grep "Developer ID Application" | head -1 | sed -E 's/.*"(.*)".*/\1/')
154+
echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
155+
156+
- name: Sign .app (hardened runtime)
157+
if: env.SIGNING_ENABLED == 'true'
158+
run: |
159+
# Sign the nested Mach-O first, then the bundle.
160+
codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \
161+
"dist/PhotoMapAI.app/Contents/Resources/photomap"
162+
codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" \
163+
"dist/PhotoMapAI.app"
164+
codesign --verify --deep --strict --verbose=2 "dist/PhotoMapAI.app"
165+
166+
- name: Build DMG
167+
run: |
168+
DMG="dist/PhotoMapAI-${VERSION}.dmg"
169+
ROOT="$RUNNER_TEMP/dmg-root"
170+
mkdir -p "$ROOT"
171+
cp -R "dist/PhotoMapAI.app" "$ROOT/"
172+
ln -s /Applications "$ROOT/Applications"
173+
hdiutil create -volname "PhotoMapAI" -srcfolder "$ROOT" -ov -format UDZO "$DMG"
174+
echo "DMG=$DMG" >> "$GITHUB_ENV"
175+
176+
- name: Sign + notarize + staple DMG
177+
if: env.SIGNING_ENABLED == 'true'
178+
env:
179+
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
180+
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
181+
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
182+
run: |
183+
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG"
184+
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/api_key.p8"
185+
xcrun notarytool submit "$DMG" \
186+
--key "$RUNNER_TEMP/api_key.p8" \
187+
--key-id "$APPLE_API_KEY_ID" \
188+
--issuer "$APPLE_API_ISSUER_ID" \
189+
--wait
190+
xcrun stapler staple "$DMG"
191+
192+
- uses: actions/upload-artifact@v5
193+
with:
194+
name: photomap-launcher-macos-arm64-v${{ env.VERSION }}
195+
path: dist/PhotoMapAI-${{ env.VERSION }}.dmg
196+
retention-days: 30
197+
198+
# --------------------------------------------------------------------------
199+
windows:
200+
needs: version
201+
runs-on: windows-latest
202+
env:
203+
VERSION: ${{ needs.version.outputs.version }}
204+
SIGNING_ENABLED: ${{ secrets.AZURE_CLIENT_ID != '' }}
205+
steps:
206+
- uses: actions/checkout@v5
207+
- uses: actions/setup-go@v5
208+
with:
209+
go-version: ${{ env.GO_VERSION }}
210+
211+
- name: Fetch uv ${{ env.UV_VERSION }}
212+
shell: pwsh
213+
run: |
214+
New-Item -ItemType Directory -Force -Path launcher/assets | Out-Null
215+
Invoke-WebRequest -Uri "https://github.com/astral-sh/uv/releases/download/$env:UV_VERSION/uv-x86_64-pc-windows-msvc.zip" -OutFile "$env:TEMP/uv.zip"
216+
Expand-Archive -Path "$env:TEMP/uv.zip" -DestinationPath "$env:TEMP/uv" -Force
217+
# Embed file name is extension-less on every OS; the bytes are the exe.
218+
Copy-Item "$env:TEMP/uv/uv.exe" "launcher/assets/uv-bin"
219+
220+
- name: Build launcher
221+
shell: pwsh
222+
run: |
223+
New-Item -ItemType Directory -Force -Path dist | Out-Null
224+
Set-Location launcher
225+
go build -tags embed_uv -ldflags "-X main.version=$env:VERSION" -o ../dist/photomap.exe .
226+
227+
- name: Sign launcher exe (Azure Trusted Signing)
228+
if: env.SIGNING_ENABLED == 'true'
229+
uses: azure/trusted-signing-action@v0
230+
with:
231+
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
232+
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
233+
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
234+
endpoint: ${{ vars.AZURE_CODESIGN_ENDPOINT }}
235+
trusted-signing-account-name: ${{ vars.AZURE_CODESIGN_ACCOUNT }}
236+
certificate-profile-name: ${{ vars.AZURE_CODESIGN_PROFILE }}
237+
files-folder: dist
238+
files-folder-filter: exe
239+
240+
- name: Build installer (Inno Setup)
241+
shell: pwsh
242+
run: |
243+
choco install innosetup --no-progress -y
244+
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
245+
"/DAppVersion=$env:VERSION" `
246+
"/DSourceExe=dist\photomap.exe" `
247+
INSTALL\launcher\windows\photomap.iss
248+
249+
- name: Sign installer (Azure Trusted Signing)
250+
if: env.SIGNING_ENABLED == 'true'
251+
uses: azure/trusted-signing-action@v0
252+
with:
253+
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
254+
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
255+
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
256+
endpoint: ${{ vars.AZURE_CODESIGN_ENDPOINT }}
257+
trusted-signing-account-name: ${{ vars.AZURE_CODESIGN_ACCOUNT }}
258+
certificate-profile-name: ${{ vars.AZURE_CODESIGN_PROFILE }}
259+
files-folder: dist
260+
files-folder-filter: exe
261+
files-folder-recurse: false
262+
263+
- uses: actions/upload-artifact@v5
264+
with:
265+
name: photomap-launcher-windows-x64-v${{ env.VERSION }}
266+
path: dist/PhotoMapAI-${{ env.VERSION }}-setup.exe
267+
retention-days: 30

.github/workflows/deploy-pyinstaller.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
name: Deploy PyInstaller Executables
22

3+
# DEPRECATED: superseded by deploy-launcher.yml (the small signed uv-bootstrap
4+
# launcher). No longer wired into deploy.yml; kept one release cycle as a
5+
# fallback and for manual dispatch. Remove this workflow, photomap.spec, and
6+
# INSTALL/pyinstaller/ once the signed launcher has shipped on all platforms.
7+
38
on:
49
workflow_call:
510
workflow_dispatch:

.github/workflows/deploy.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,22 @@ jobs:
7474
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
7575
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
7676

77-
deploy-pyinstaller:
77+
deploy-launcher:
7878
needs: deploy-dockerhub
79-
uses: ./.github/workflows/deploy-pyinstaller.yml
79+
uses: ./.github/workflows/deploy-launcher.yml
80+
secrets:
81+
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
82+
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
83+
MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
84+
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
85+
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
86+
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
87+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
88+
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
89+
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
8090

8191
upload-release:
82-
needs: [tag-release, deploy-pypi, deploy-dockerhub, deploy-pyinstaller]
92+
needs: [tag-release, deploy-pypi, deploy-dockerhub, deploy-launcher]
8393
uses: ./.github/workflows/upload-artifacts.yml
8494
with:
8595
tag_name: ${{ needs.tag-release.outputs.tag }}

.github/workflows/upload-artifacts.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ jobs:
1414
upload-release:
1515
runs-on: ubuntu-latest
1616
steps:
17-
- name: Download CPU installer package artifacts
17+
- name: Download launcher artifacts
1818
uses: actions/download-artifact@v5
19-
with:
20-
pattern: "*-cpu-*"
19+
with:
20+
pattern: "photomap-launcher-*"
2121
path: artifacts
2222

2323
- name: List downloaded artifacts
@@ -27,6 +27,6 @@ jobs:
2727
uses: softprops/action-gh-release@v3
2828
with:
2929
tag_name: ${{ inputs.tag_name }}
30-
files: artifacts/*-cpu-*/*
30+
files: artifacts/photomap-launcher-*/*
3131
env:
3232
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,9 @@ demo_images/photomap_index
115115

116116
# Node.js
117117
node_modules/
118+
119+
# Go launcher: build output and the uv binary CI fetches/embeds at build time
120+
/launcher/launcher
121+
/launcher/photomap
122+
/launcher/photomap.exe
123+
/launcher/assets/uv-bin

INSTALL/install_linux_mac.sh

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ create_mac_launcher() {
103103
local repo_root=.
104104
local icon_path="$repo_root/photomap/frontend/static/icons/favicon-32x32.icns"
105105

106+
# Read the installed package version rather than hardcoding it, so the bundle
107+
# version never drifts from what's actually installed.
108+
local app_version
109+
app_version="$("$install_path/bin/python" -c "from importlib.metadata import version; print(version('photomapai'))" 2>/dev/null)"
110+
app_version="${app_version:-0.0.0}"
111+
106112
# Create app bundle structure
107113
mkdir -p "$app_dir/Contents/MacOS"
108114
mkdir -p "$app_dir/Contents/Resources"
@@ -124,9 +130,9 @@ create_mac_launcher() {
124130
<key>CFBundleIconFile</key>
125131
<string>photomap.icns</string>
126132
<key>CFBundleVersion</key>
127-
<string>0.3.0</string>
133+
<string>$app_version</string>
128134
<key>CFBundleShortVersionString</key>
129-
<string>0.3.0</string>
135+
<string>$app_version</string>
130136
<key>LSUIElement</key>
131137
<false/>
132138
</dict>
@@ -194,8 +200,10 @@ main() {
194200
pip install --upgrade pip
195201

196202
# Step 5: Install PhotoMap
203+
# Non-editable install: an editable (-e) install hard-links the venv to this
204+
# unpacked source directory, so moving or deleting it later breaks the launcher.
197205
print_info "Installing PhotoMap..."
198-
pip install -e .
206+
pip install .
199207

200208
# Step 6: Install the CLIP model
201209
print_info "Downloading CLIP model..."

0 commit comments

Comments
 (0)