chore: sync CI/CD with upstream guide and update context docs
Some checks failed
Lint & Test / test (push) Failing after 30s

release.yml: add fallback for existing releases on tag re-push.
installer.nsi: add .onInit file lock check, use LaunchApp function
instead of RUN_PARAMETERS to fix NSIS quoting bug.
build-dist.ps1: copy start-hidden.vbs to dist scripts/.
start-hidden.vbs: embedded Python fallback for installed/dev envs.

Update ci-cd.md with version detection, NSIS best practices, local
build testing, Gitea vs GitHub differences, troubleshooting.
Update frontend.md with full entity type checklist and common pitfalls.
This commit is contained in:
2026-03-24 13:59:27 +03:00
parent 1111ab7355
commit fc62d5d3b1
7 changed files with 468 additions and 29 deletions

View File

@@ -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:

View File

@@ -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'

View File

@@ -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

View File

@@ -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 |

View File

@@ -76,7 +76,9 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.ts`. This replaces the `<select>` 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 `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` 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 `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **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<MyEntity[]>({
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: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(entity.name)}">
${ICON_MY_ENTITY} <span class="card-title-text">${escapeHtml(entity.name)}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">🏷️ ${escapeHtml(entity.description || '')}</span>
</div>
${renderTagChips(entity.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneMyEntity('${entity.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showMyEntityEditor('${entity.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
```
**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
<div id="my-entity-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="my-entity-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="my-entity-title" data-i18n="my_entity.add">Add Entity</h2>
<button class="modal-close-btn" onclick="closeMyEntityModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="my-entity-id">
<div id="my-entity-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="my-entity-name" data-i18n="my_entity.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="my_entity.name.hint">...</small>
<input type="text" id="my-entity-name" required>
</div>
<!-- Type-specific fields here -->
<div id="my-entity-tags-container"></div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeMyEntityModal()" title="Cancel" data-i18n-title="settings.button.cancel">&times;</button>
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save">&#x2713;</button>
</div>
</div>
</div>
```
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 `<div>` goes **inside the same `form-group` as the name `<input>`**, 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 `<select>` value (e.g. loading a preset, populating for edit), you MUST also call `.setValue(val)` (IconSelect) or `.refresh()` (EntitySelect). The native `<select>` 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.

View File

@@ -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.

View File

@@ -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"