diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e3ff875..3b92833 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -74,9 +74,16 @@ jobs: \"prerelease\": $IS_PRE }") - RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + # Fallback: if release already exists for this tag, fetch it instead + RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) + if [ -z "$RELEASE_ID" ]; then + echo "::warning::Release already exists for tag $TAG — reusing existing release" + RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \ + -H "Authorization: token $GITEA_TOKEN") + RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + fi echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" - echo "Created release ID: $RELEASE_ID" + echo "Release ID: $RELEASE_ID" # ── Windows portable ZIP (cross-built from Linux) ───────── build-windows: diff --git a/build-dist-windows.sh b/build-dist-windows.sh index 4e4b500..9d1d4c2 100644 --- a/build-dist-windows.sh +++ b/build-dist-windows.sh @@ -228,6 +228,8 @@ WIN_DEPS=( "winrt-Windows.Foundation>=3.0.0" "winrt-Windows.Foundation.Collections>=3.0.0" "winrt-Windows.ApplicationModel>=3.0.0" + # System tray + "pystray>=0.19.0" ) # Download cross-platform deps (prefer binary, allow source for pure Python) @@ -359,7 +361,6 @@ echo "[8b/9] Creating launcher and packaging..." cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER @echo off -title LedGrab v${VERSION_CLEAN} cd /d "%~dp0" :: Set paths @@ -370,15 +371,17 @@ set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml if not exist "%~dp0data" mkdir "%~dp0data" if not exist "%~dp0logs" mkdir "%~dp0logs" -:: Start the server — reads port from config, prints its own banner -"%~dp0python\python.exe" -m wled_controller.main - -pause +:: Start the server (tray icon handles UI and exit) +"%~dp0python\pythonw.exe" -m wled_controller LAUNCHER # Convert launcher to Windows line endings sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat" +# Copy hidden launcher VBS +mkdir -p "$DIST_DIR/scripts" +cp server/scripts/start-hidden.vbs "$DIST_DIR/scripts/" + # ── Create autostart scripts ───────────────────────────────── cat > "$DIST_DIR/install-autostart.bat" << 'AUTOSTART' diff --git a/build-dist.ps1 b/build-dist.ps1 index 883f95e..733a9f8 100644 --- a/build-dist.ps1 +++ b/build-dist.ps1 @@ -130,7 +130,7 @@ if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" } # ── Install dependencies ────────────────────────────────────── Write-Host "[5/8] Installing dependencies..." -$extras = "camera,notifications" +$extras = "camera,notifications,tray" if (-not $SkipPerf) { $extras += ",perf" } # Install the project (pulls all deps via pyproject.toml), then remove @@ -202,7 +202,6 @@ Write-Host "[8/8] Creating launcher..." $launcherContent = @' @echo off -title LedGrab v%VERSION% cd /d "%~dp0" :: Set paths @@ -213,24 +212,19 @@ set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml if not exist "%~dp0data" mkdir "%~dp0data" if not exist "%~dp0logs" mkdir "%~dp0logs" -echo. -echo ============================================= -echo LedGrab v%VERSION% -echo Open http://localhost:8080 in your browser -echo ============================================= -echo. - -:: Start the server (open browser after short delay) -start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080" -"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080 - -pause +:: Start the server (tray icon handles UI and exit) +"%~dp0python\pythonw.exe" -m wled_controller '@ $launcherContent = $launcherContent -replace '%VERSION%', $VersionClean $launcherPath = Join-Path $DistDir "LedGrab.bat" Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII +# Copy hidden launcher VBS +$scriptsDir = Join-Path $DistDir "scripts" +New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null +Copy-Item -Path (Join-Path $ServerDir "scripts\start-hidden.vbs") -Destination $scriptsDir + # ── Create ZIP ───────────────────────────────────────────────── $ZipPath = Join-Path $BuildDir $ZipName diff --git a/contexts/ci-cd.md b/contexts/ci-cd.md index ce03c53..eeac147 100644 --- a/contexts/ci-cd.md +++ b/contexts/ci-cd.md @@ -1,5 +1,7 @@ # CI/CD & Release Workflow +> **Reference guide:** [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) — reusable patterns for Gitea Actions, cross-build, NSIS, Docker. When modifying workflows or build scripts, consult this guide to stay in sync with established patterns. + ## Workflows | File | Trigger | Purpose | @@ -59,6 +61,33 @@ Creates the Gitea release with a description table listing all artifacts. **The - Display-dependent tests are skipped via `@requires_display` marker - Uses `python` not `python3` (Git Bash on Windows resolves `python3` to MS Store stub) +## Version Detection Pattern + +Build scripts use a fallback chain: CLI argument → exact git tag → CI env var (`GITEA_REF_NAME` / `GITHUB_REF_NAME`) → hardcoded in source. Always strip leading `v` for clean version strings. + +## NSIS Installer Best Practices + +- **User-scoped install** (`$LOCALAPPDATA`, `RequestExecutionLevel user`) — no admin required +- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility +- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install +- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/` +- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi` + +## Hidden Launcher (VBS) + +All shortcuts and the installer finish page use `scripts/start-hidden.vbs` instead of `.bat` to avoid console window flash. The VBS launcher must include an embedded Python fallback — installed distributions don't have Python on PATH, dev environment uses system Python. + +## Gitea vs GitHub Actions Differences + +| Feature | GitHub Actions | Gitea Actions | +| ------- | -------------- | ------------- | +| Context prefix | `github.*` | `gitea.*` | +| Ref name | `${{ github.ref_name }}` | `${{ gitea.ref_name }}` | +| Server URL | `${{ github.server_url }}` | `${{ gitea.server_url }}` | +| Output vars | `$GITHUB_OUTPUT` | `$GITHUB_OUTPUT` (same) | +| Secrets | `${{ secrets.NAME }}` | `${{ secrets.NAME }}` (same) | +| Docker Buildx | Available | May not work (runner networking) | + ## Common Tasks ### Creating a release @@ -78,3 +107,48 @@ git push origin v0.2.0-alpha.1 2. Add upload step in the relevant `build-*` job 3. **Update the release description** in `create-release` job body template 4. Test with a pre-release tag first + +### Re-triggering a failed release workflow + +```bash +# Option A: Delete and re-push the same tag +git push origin :refs/tags/v0.1.0-alpha.2 +# Delete the release in Gitea UI or via API +git tag -f v0.1.0-alpha.2 +git push origin v0.1.0-alpha.2 + +# Option B: Just bump the version (simpler) +git tag v0.1.0-alpha.3 +git push origin v0.1.0-alpha.3 +``` + +The `create-release` job has fallback logic — if the release already exists for a tag, it fetches and reuses the existing release ID. + +## Local Build Testing (Windows) + +### Prerequisites + +- NSIS: `& "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" install NSIS.NSIS` +- Installs to `C:\Program Files (x86)\NSIS\makensis.exe` + +### Build steps + +```bash +npm ci && npm run build # frontend +bash build-dist-windows.sh v1.0.0 # Windows dist +"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer +``` + +### Iterating on installer only +If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot. + +### Common issues + +| Issue | Fix | +| ----- | --- | +| `zip: command not found` | Git Bash doesn't include `zip` — harmless for installer builds | +| `Exec expects 1 parameters, got 2` | Use `MUI_FINISHPAGE_RUN_FUNCTION` instead of `MUI_FINISHPAGE_RUN_PARAMETERS` | +| `Error opening file for writing: python\_asyncio.pyd` | Server is running — stop it before installing | +| App doesn't start after install | VBS must use embedded Python fallback, not bare `python` | +| `winget` not recognized | Use full path: `$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe` | +| `dist/` has stale files | Re-run full build script — `dist/` doesn't auto-update | diff --git a/contexts/frontend.md b/contexts/frontend.md index 174b1c6..c1d07f8 100644 --- a/contexts/frontend.md +++ b/contexts/frontend.md @@ -76,7 +76,9 @@ Plain `` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.ts` or `_lineSourceEntitySelect` in `advanced-calibration.ts` for examples. -Both widgets hide the native `` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes. +Both widgets hide the native `` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection. **IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `` elements (e.g., `A`). **Never use emoji** — they render inconsistently across platforms and themes. @@ -190,6 +192,17 @@ document.addEventListener('languageChanged', () => { Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern. +## API Calls (CRITICAL) + +**ALWAYS use `fetchWithAuth()` from `core/api.ts` for authenticated API requests.** It auto-prepends `API_BASE` (`/api/v1`) and attaches the auth token. + +- **Paths are relative to `/api/v1`** — pass `/gradients`, NOT `/api/v1/gradients` +- `fetchWithAuth('/gradients')` → `GET /api/v1/gradients` with auth header +- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123` +- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404) + +For raw `fetch()` without auth (rare), use the full path manually. + ## Bundling & Development Workflow The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production. @@ -262,6 +275,315 @@ Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(- Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.ts`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`. +## Adding a New Entity Type (Full Checklist) + +This section documents the complete pattern for adding a new entity type to the frontend, covering tabs, cards, modals, CRUD operations, and all required wiring. + +### 1. DataCache (state.ts) + +Register a cache for the new entity's API endpoint in `static/js/core/state.ts`: + +```typescript +export const myEntitiesCache = new DataCache({ + endpoint: '/my-entities', + extractData: json => json.entities || [], +}); +``` + +- `endpoint` is relative to `/api/v1` (fetchWithAuth prepends it) +- `extractData` unwraps the response envelope +- Subscribe to sync into a legacy variable if needed: `myEntitiesCache.subscribe(v => { _cachedMyEntities = v; });` + +### 2. CardSection instance + +Create a global CardSection in the feature module (e.g. `features/my-entities.ts`): + +```typescript +const csMyEntities = new CardSection('my-entities', { + titleKey: 'my_entity.section_title', // i18n key for section header + gridClass: 'templates-grid', // CSS grid class + addCardOnclick: "showMyEntityEditor()", // onclick for the "+" card + keyAttr: 'data-my-id', // attribute used to match cards during reconcile + emptyKey: 'section.empty.my_entities', // i18n key shown when no cards + bulkActions: _myEntityBulkActions, // optional bulk action definitions +}); +``` + +### 3. Tab registration (streams.ts) + +Add the tab entry in the `tabs` array inside `loadSourcesTab()`: + +```typescript +{ key: 'my_entities', icon: ICON_MY_ENTITY, titleKey: 'streams.group.my_entities', count: myEntities.length }, +``` + +Then add the rendering block for the tab content: + +```typescript +// First load: full render +if (!csMyEntities.isMounted()) { + const items = myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) })); + html += csMyEntities.render(csMyEntities.applySortOrder(items)); +} else { + // Incremental update + csMyEntities.reconcile(myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) }))); +} +``` + +After `innerHTML` assignment, call `csMyEntities.bind()` for first mount. + +### 4. Card builder function + +Build cards using `wrapCard()` from `core/card-colors.ts`: + +```typescript +function createMyEntityCard(entity: MyEntity): string { + return wrapCard({ + dataAttr: 'data-my-id', + id: entity.id, + removeOnclick: `deleteMyEntity('${entity.id}')`, + removeTitle: t('common.delete'), + content: ` +
+
+ ${ICON_MY_ENTITY} ${escapeHtml(entity.name)} +
+
+
+ 🏷️ ${escapeHtml(entity.description || '')} +
+ ${renderTagChips(entity.tags)}`, + actions: ` + + `, + }); +} +``` + +**Required HTML classes:** +- `.template-card` — root (auto-added by wrapCard) +- `.card-header` > `.card-title` > `.card-title-text` — title with icon +- `.stream-card-props` > `.stream-card-prop` — property badges +- `.template-card-actions` — button row (auto-added by wrapCard) +- `.card-remove-btn` — delete X button (auto-added by wrapCard) + +### 5. Modal HTML template + +Create `templates/modals/my-entity-editor.html`: + +```html + +``` + +Include it in `templates/index.html`: `{% include 'modals/my-entity-editor.html' %}` + +### 6. Modal class (dirty checking) + +```typescript +class MyEntityModal extends Modal { + constructor() { super('my-entity-modal'); } + + snapshotValues() { + return { + name: (document.getElementById('my-entity-name') as HTMLInputElement).value, + // ... all tracked fields, serialize complex state as JSON strings + tags: JSON.stringify(_tagsInput ? _tagsInput.getValue() : []), + }; + } + + onForceClose() { + // Cleanup: destroy tag inputs, entity selects, etc. + if (_tagsInput) { _tagsInput.destroy(); _tagsInput = null; } + } +} +const myEntityModal = new MyEntityModal(); +``` + +### 7. CRUD functions + +**Create / Edit (unified):** + +```typescript +export async function showMyEntityEditor(editId: string | null = null) { + const titleEl = document.getElementById('my-entity-title')!; + const idInput = document.getElementById('my-entity-id') as HTMLInputElement; + const nameInput = document.getElementById('my-entity-name') as HTMLInputElement; + + idInput.value = ''; + nameInput.value = ''; + + if (editId) { + // Edit mode: populate from cache + const entities = await myEntitiesCache.fetch(); + const entity = entities.find(e => e.id === editId); + if (!entity) return; + idInput.value = entity.id; + nameInput.value = entity.name; + titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.edit')}`; + } else { + titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.add')}`; + } + + myEntityModal.open(); + myEntityModal.snapshot(); +} +``` + +**Clone:** Fetch existing entity, open editor with its data but no ID (creates new): + +```typescript +export async function cloneMyEntity(entityId: string) { + const entities = await myEntitiesCache.fetch(); + const source = entities.find(e => e.id === entityId); + if (!source) return; + + // Open editor as "create" with pre-filled data + await showMyEntityEditor(null); + (document.getElementById('my-entity-name') as HTMLInputElement).value = source.name + ' (Copy)'; + // ... populate other fields from source + myEntityModal.snapshot(); // Re-snapshot after populating clone data +} +``` + +**Save:** POST (new) or PUT (edit) based on hidden ID field: + +```typescript +export async function saveMyEntity() { + const id = (document.getElementById('my-entity-id') as HTMLInputElement).value; + const name = (document.getElementById('my-entity-name') as HTMLInputElement).value.trim(); + if (!name) { myEntityModal.showError(t('my_entity.error.name_required')); return; } + + const payload = { name, /* ... other fields */ }; + + try { + const url = id ? `/my-entities/${id}` : '/my-entities'; + const method = id ? 'PUT' : 'POST'; + const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); + if (!res!.ok) { const err = await res!.json(); throw new Error(err.detail); } + + showToast(id ? t('my_entity.updated') : t('my_entity.created'), 'success'); + myEntitiesCache.invalidate(); + myEntityModal.forceClose(); + if (window.loadSourcesTab) window.loadSourcesTab(); + } catch (e: any) { + if (e.isAuth) return; + myEntityModal.showError(e.message); + } +} +``` + +**Delete:** Confirm, API call, invalidate cache, reload: + +```typescript +export async function deleteMyEntity(entityId: string) { + const ok = await showConfirm(t('my_entity.confirm_delete')); + if (!ok) return; + try { + await fetchWithAuth(`/my-entities/${entityId}`, { method: 'DELETE' }); + showToast(t('my_entity.deleted'), 'success'); + myEntitiesCache.invalidate(); + if (window.loadSourcesTab) window.loadSourcesTab(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message || t('my_entity.error.delete_failed'), 'error'); + } +} +``` + +### 8. Window exports (app.ts) + +Import and expose all onclick handlers: + +```typescript +import { showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity } from './features/my-entities.ts'; + +Object.assign(window, { + showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity, +}); +``` + +**Critical:** Functions used in `onclick="..."` HTML attributes MUST appear in `Object.assign(window, ...)` or they will be undefined at runtime. + +### 9. global.d.ts + +Add the window function declarations so TypeScript doesn't complain: + +```typescript +showMyEntityEditor?: (id?: string | null) => void; +cloneMyEntity?: (id: string) => void; +deleteMyEntity?: (id: string) => void; +``` + +### 10. i18n keys + +Add keys to all three locale files (`en.json`, `ru.json`, `zh.json`): + +```json +"my_entity.section_title": "My Entities", +"my_entity.add": "Add Entity", +"my_entity.edit": "Edit Entity", +"my_entity.created": "Entity created", +"my_entity.updated": "Entity updated", +"my_entity.deleted": "Entity deleted", +"my_entity.confirm_delete": "Delete this entity?", +"my_entity.error.name_required": "Name is required", +"my_entity.name": "Name:", +"my_entity.name.hint": "A descriptive name for this entity", +"section.empty.my_entities": "No entities yet. Click + to create one." +``` + +### 11. Cross-references + +After adding the entity: + +- **Backup/restore:** Add to `STORE_MAP` in `api/routes/system.py` +- **Graph editor:** Update entity maps in graph editor files (see graph-editor.md) +- **Tutorials:** Update tutorial steps if adding a new tab + +### CRITICAL: Common Pitfalls (MUST READ) + +These mistakes have been made repeatedly. **Check every one before considering the entity complete:** + +1. **DOM ID conflicts:** If your modal reuses a shared component (e.g. gradient stop editor, color picker) that uses hardcoded `document.getElementById()` calls, **both modals exist in the DOM simultaneously**. The shared component will render into whichever element it finds first (the wrong one). Fix: add an ID prefix mechanism to the shared component, set it before init, reset it on modal close. + +2. **Tags go under the name input:** The tags container `
` goes **inside the same `form-group` as the name ``**, directly after it — NOT in a separate section or at the bottom of the modal. Look at any existing modal (css-editor.html, audio-source-editor.html, device-settings.html) for the pattern. + +3. **Cache reload after save/delete/clone:** Use `cache.invalidate()` then `await loadPictureSources()` (imported directly from streams.ts). Do NOT use `window.loadSourcesTab()` without `await` — it reads the stale cache before the invalidation takes effect, so the new entity won't appear until page reload. + +4. **IconSelect / EntitySelect sync:** When programmatically changing a `` and the visual widget are **separate** — changing one does NOT update the other. + +5. **Never use `window.prompt()`:** Always use a proper Modal subclass with `snapshotValues()` for dirty checking. Prompts break the UX and have no validation, no hint text, no i18n. + +6. **Never do API-only clone:** Clone should open the editor modal pre-filled with the source entity's data (name + " (Copy)"), with an empty ID field so saving creates a new entity. Do NOT call a `/clone` endpoint and refresh — the user must be able to edit before saving. + ## Visual Graph Editor See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions. diff --git a/contexts/server-operations.md b/contexts/server-operations.md index 4615b42..5bad03c 100644 --- a/contexts/server-operations.md +++ b/contexts/server-operations.md @@ -8,7 +8,7 @@ Two independent server modes with separate configs, ports, and data directories: | Mode | Command | Config | Port | API Key | Data | | ---- | ------- | ------ | ---- | ------- | ---- | -| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` | +| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` | | **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` | Both can run simultaneously on different ports. diff --git a/installer.nsi b/installer.nsi index fec7dca..3fc3456 100644 --- a/installer.nsi +++ b/installer.nsi @@ -10,6 +10,7 @@ ; ── Metadata ──────────────────────────────────────────────── !define APPNAME "LedGrab" +!define VBSNAME "start-hidden.vbs" !define DESCRIPTION "Ambient lighting system — captures screen content and drives LED strips in real time" !define VERSIONMAJOR 0 !define VERSIONMINOR 1 @@ -33,6 +34,12 @@ SetCompressor /SOLID lzma ; ── Pages ─────────────────────────────────────────────────── +; Use MUI_FINISHPAGE_RUN_FUNCTION instead of MUI_FINISHPAGE_RUN_PARAMETERS — +; NSIS Exec command chokes on the quoting with RUN_PARAMETERS. +!define MUI_FINISHPAGE_RUN "" +!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}" +!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp + !insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_COMPONENTS @@ -44,6 +51,33 @@ SetCompressor /SOLID lzma !insertmacro MUI_LANGUAGE "English" +; ── Functions ───────────────────────────────────────────── + +Function LaunchApp + ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' + Sleep 2000 + ExecShell "open" "http://localhost:8080/" +FunctionEnd + +; Detect running instance before install (file lock check on python.exe) +Function .onInit + IfFileExists "$INSTDIR\python\python.exe" 0 done + ClearErrors + FileOpen $0 "$INSTDIR\python\python.exe" a + IfErrors locked + FileClose $0 + Goto done + locked: + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \ + "${APPNAME} is currently running.$\n$\nYes = Stop and continue$\nNo = Continue anyway (may cause errors)$\nCancel = Abort" \ + IDYES kill IDNO done + Abort + kill: + nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%${APPNAME}%python%$\'" call terminate' + Sleep 2000 + done: +FunctionEnd + ; ── Installer Sections ────────────────────────────────────── Section "!${APPNAME} (required)" SecCore @@ -54,6 +88,7 @@ Section "!${APPNAME} (required)" SecCore ; Copy the entire portable build File /r "build\LedGrab\python" File /r "build\LedGrab\app" + File /r "build\LedGrab\scripts" File "build\LedGrab\LedGrab.bat" ; Create data and logs directories @@ -65,8 +100,9 @@ Section "!${APPNAME} (required)" SecCore ; Start Menu shortcuts CreateDirectory "$SMPROGRAMS\${APPNAME}" - CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \ - "" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}" + CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \ + "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ + "$INSTDIR\python\pythonw.exe" 0 CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe" ; Registry: install location + Add/Remove Programs entry @@ -98,13 +134,15 @@ Section "!${APPNAME} (required)" SecCore SectionEnd Section "Desktop shortcut" SecDesktop - CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \ - "" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}" + CreateShortcut "$DESKTOP\${APPNAME}.lnk" \ + "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ + "$INSTDIR\python\pythonw.exe" 0 SectionEnd Section "Start with Windows" SecAutostart - CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \ - "" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}" + CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \ + "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ + "$INSTDIR\python\pythonw.exe" 0 SectionEnd ; ── Section Descriptions ──────────────────────────────────── @@ -131,6 +169,7 @@ Section "Uninstall" ; Remove application files (but NOT data/ — preserve user config) RMDir /r "$INSTDIR\python" RMDir /r "$INSTDIR\app" + RMDir /r "$INSTDIR\scripts" Delete "$INSTDIR\LedGrab.bat" Delete "$INSTDIR\uninstall.exe"