12 Commits

Author SHA1 Message Date
Scott Idem
a56f520d4e feat(launcher): add quick mirror/extend toggle in left menu
Always visible in launcher menu, disabled outside native mode, with title text and preview-note messaging.
2026-05-20 19:28:46 -04:00
Scott Idem
c0f828ec2c feat: Add platform-specific VLC launch profiles (macOS working; Linux deferred)
- macOS: Uses direct binary path (/Applications/VLC.app/Contents/MacOS/VLC)
  with --no-play-and-exit --play-and-pause flags and AppleScript fullscreen toggle.
  Confirmed working: pause on last frame, stays in window.

- Linux: Uses shell command (vlc --no-play-and-exit --play-and-pause).
  Known issue: not going fullscreen, not pausing on end. Deferred for later
  investigation of flag interpretation vs launcher execution layer.

- Created separate profile constants: VLC_MIRROR_MAC_PROFILE and
  VLC_MIRROR_LINUX_PROFILE in ae_launcher__default_launch_profiles.ts

- Updated TODO__Agents.md with Linux VLC issue for future debugging

Files: ae_launcher__default_launch_profiles.ts, documentation/TODO__Agents.md
2026-05-20 19:09:15 -04:00
Scott Idem
5bb06c4904 feat(display): expose list_display_modes and set_display_mode in relay
Svelte callers can now enumerate all display modes and set resolution/
refresh/HiDPI per display through the native bridge.
2026-05-20 18:16:43 -04:00
Scott Idem
7621e044b4 docs(launcher): update display layout doc — display_control replaces displayplacer as primary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:48:25 -04:00
Scott Idem
76569a872f feat(launcher): add display mode toggle; fix silent display layout failures
- Add Extend/Mirror toggle to Native OS config section (always visible,
  no Technical Mode required). Default: Extend. State updates on success.
- Replace .catch(() => {}) swallowing with console.warn logging on both
  set_display_layout call sites so failures appear in the Electron console
- Remove old edit-mode-only Extend button (replaced by new toggle)
- Update PROJECT doc: displayplacer install note, binary lookup order, GitHub link
- Clean up TODO__Agents.md: resolve stale items, add new low-priority Electron items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:33:33 -04:00
Scott Idem
d9726d062e docs(launcher): sync docs to current code state
set_wallpaper: update signature in PROJECT doc (§5.3 and §7) — actual params
are {path?, url?, url_external?, display?, api_key?, account_id?}, not just {path}.

set_display_layout: clarify in PROJECT doc (§5.3 and §7) — now auto-detects
displays via displayplacer list; configStr is optional manual override, not required.

MODULE profiles table: correct post_delay_ms values (Mac 2000→1000ms,
Win 3000→1500ms) and add polling-loop note explaining the new timing behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:16 -04:00
Scott Idem
91f40c4a89 fix(launcher): border-2 on wallpaper chips/buttons for VNC readability
ring-1 uses CSS box-shadow which nearly disappears when the UI is scaled
down through VNC. Switched all preset chips and action buttons to border-2
so they remain clearly distinct at reduced resolution.
preset-tonal-surface + border-2 border-surface-400 for unselected chips;
disabled:opacity-60 (vs default 50) on Save & Apply so it is still
identifiable as a button when no URL is entered yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:22:22 -04:00
Scott Idem
e63a17865c fix(launcher): replace fixed post_delay with frontmost poll across all launch profiles
`open -a App` returns immediately; a fixed delay is unreliable for cold starts.
Each post_script now polls every 500 ms (up to 7.5 s) until the target process is
frontmost before firing the automation keystroke. Keynote polls document count
instead of frontmost since start(front document) requires the file to be loaded.
Mac profiles: post_delay_ms 2000 → 1000. Win/Parallels profiles: 3000 → 1500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:16:01 -04:00
Scott Idem
a59e53aec5 fix(launcher): larger, ring-bordered preset chips for VNC readability; fix lint errors
Bumps chip buttons h-6→h-8 with text-xs and ring-1 borders so active/inactive
states are clearly distinct at VNC/remote scale. Save & Apply bumped to h-10
text-sm. Fixes: /50 opacity modifier in class: directives (uses class expression
instead), stale svelte-ignore comments replaced with onkeydown handlers, each
block key added. Documents wallpaper repeat-apply macOS caching bug in TODO with
workaround and fix location (Electron temp filename).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:29:34 -04:00
Scott Idem
6042095147 feat(launcher): preset chips + simplified buttons for wallpaper config
Replaces free-text-only inputs with quick-select preset chips (1 primary,
4 client external/projector) that populate the URL field on click.
Consolidates Save/Apply/Save&Apply/Restore into a single Save & Apply
primary button plus an icon-only Restore button. All status messages
merged into one shared state variable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:22:01 -04:00
Scott Idem
932deced12 docs: mark IDAA server-side Novi verification resolved in TODO__Agents.md
All Access Denied root causes fixed and deployed 2026-05-19. 503 auto-retry
regression also documented and fixed same session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:47:46 -04:00
Scott Idem
861385b4ff docs(idaa): update Novi verification docs to reflect server-side proxy (complete)
CLIENT__IDAA_and_customized_mods.md:
- Verification Flow: describe Aether proxy call, not direct browser-to-Novi fetch
- Replace old fetch() code snippet with new Aether endpoint call
- Update novi_idaa_api_key / novi_api_root_url field descriptions (server-side only now)
- Security notes: key never sent to browser; shape changes go in backend method
- Rate limit note: 12h TTL (was 5-min), add 503 auto-retry behavior
- Fix Redis cache key: idaa:novi_member:{uuid} (account_id was dropped from key)

GUIDE__AE_API_V3_for_Frontend.md §12:
- 503 frontend action: auto-retry once after 3s before api_error
- Mark migration section complete (2026-05-19); update table to show retry behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:46:51 -04:00
11 changed files with 471 additions and 252 deletions

View File

@@ -113,21 +113,22 @@ IDAA members do not log in through Aether — they log in through Novi (idaa.org
> **Security note (2026-03-09):** The iframe HTML files previously also passed `email` and `full_name`
> via URL params. These were unverifiable claims that could be spoofed via URL. They have been removed.
> The SvelteKit layout now fetches verified identity directly from the Novi API.
> The SvelteKit layout now verifies identity via the Aether server-side Novi proxy — the Novi API
> call originates from the server, not the member's browser.
> See "Iframe Integration" → "Novi UUID Verification Flow" below.
### Verification Flow (`(idaa)/+layout.svelte`)
When a `uuid` param is present in the URL, the layout performs an **async Novi API call** to verify:
When a `uuid` param is present in the URL, the layout performs an **async call to the Aether server-side endpoint** (`GET /v3/action/idaa/novi_member/{uuid}`), which proxies to Novi server-to-server:
1. The UUID actually exists in Novi's system (prevents fake/crafted UUIDs)
2. Gets verified name and email directly from Novi — these can't be forged via URL
2. Gets verified name and email — these can't be forged via URL
3. Sets `$idaa_loc.novi_uuid`, `$idaa_loc.novi_email`, `$idaa_loc.novi_full_name`
4. Sets `$idaa_loc.novi_verified = true` on success
A `novi_verifying` UI state prevents the "Access Denied" screen from flashing during the API round-trip.
**All or nothing:** If the Novi API key is not configured, or the verification call fails, access is denied. There is no URL-param fallback.
**All or nothing:** If the Novi API key is not configured on the site, or the verification call fails, access is denied. There is no URL-param fallback.
**Required `site_cfg_json` fields:**
```json
@@ -148,22 +149,26 @@ This section documents the exact way Aether uses the Novi API for the IDAA integ
- **All-or-nothing policy:** If the Novi API key is not configured or the verification call fails, the Novi-based access path is denied. The layout explicitly prevents child routes from rendering while verification is in-flight to avoid flashing "Access Denied".
- **Rate limits (Novi API):** 20 calls/second · 600 calls/minute · 100,000 calls/day. The layout handles 429 responses with a 10-second flat backoff and one retry. If the retry also returns 429, access is denied and a "Reload / Retry" button is shown. The 5-minute TTL cache on successful verification prevents repeated calls during normal use.
- **Rate limits (Novi API):** 20 calls/second · 600 calls/minute · 100,000 calls/day. The Aether backend handles 429 responses; the frontend receives a `429` and retries once after 10 seconds. The 12-hour TTL cache on successful verification (Redis server-side + `$idaa_loc` client-side) prevents repeated calls during normal use. A `503` (Novi unreachable) is auto-retried once after 3 seconds before surfacing an error to the user.
### Verification Flow (implementation)
1. The IDAA iframe loads Aether pages with a `?uuid=<uuid>&iframe=true` param.
2. When the `uuid` param is present the IDAA layout performs an authenticated GET against the Novi customers endpoint:
2. When the `uuid` param is present the IDAA layout calls the Aether server-side proxy:
```js
// simplified
fetch(`${api_root_url}/customers/${uuid}`, {
fetch(`${aether_api_url}/v3/action/idaa/novi_member/${uuid}`, {
method: 'GET',
headers: { 'Authorization': `Basic ${api_key}` }
headers: {
'x-aether-api-key': api_key,
'x-account-id': account_id
}
})
// Aether calls Novi server-to-server; member's browser IP is never in the Novi call path.
```
3. On success the layout uses the returned JSON to build a display name and normalized email, then writes these values to the IDAA store and marks verification success.
3. On success (`200`), the layout reads `data.full_name` and `data.email` from the response and writes them to the IDAA store, marking verification success.
4. The layout then determines a target Novi permission level (`authenticated`, `trusted`, `administrator`) by checking configured UUID lists (`novi_trusted_li`, `novi_admin_li`) and upgrades the Aether session only if the Novi-derived level is higher than the current global level.
@@ -171,9 +176,9 @@ fetch(`${api_root_url}/customers/${uuid}`, {
### Key `site_cfg_json` fields and where they are used
- **`novi_idaa_api_key`**: Base64-encoded Basic auth token provided by Novi. Required for the verification request. Accessed in code as `$ae_loc.site_cfg_json.novi_idaa_api_key` and passed in the `Authorization: Basic <key>` header. If missing, Novi-based access is denied.
- **`novi_idaa_api_key`**: Base64-encoded Basic auth token provided by Novi. Used by the Aether **server** to authenticate against Novi — the frontend never touches the key itself. The frontend checks only for its *presence* in `site_cfg_json` as a guard meaning "IDAA is configured for this site". If missing, Novi-based access is denied.
- **`novi_api_root_url`**: Optional API root (defaults to `https://www.idaa.org/api`). Used to form the verification URL.
- **`novi_api_root_url`**: Optional Novi API root (defaults to `https://www.idaa.org/api`). Read by the Aether server, not the frontend.
- **`novi_admin_li`**: Array of UUIDs treated as administrators for IDAA. Merged into `$idaa_loc.novi_admin_li` during layout initialization and used to set `administrator` level.
@@ -214,35 +219,31 @@ These fields are read elsewhere in the IDAA UI to enable flows for verified user
### Security notes and operational guidance
- The previous implementation leaked `email` and `full_name` via URL params — this was removed because those values are unauthenticated and can be spoofed.
- The API key is sensitive — keep it only in site config and do not expose it in client-side code or public repositories.
- The verification request uses Basic auth with the provided `novi_idaa_api_key` (already Base64-encoded by Novi) — treat the token like a password.
- If Novi changes their customer API shape, update the layout parsing (display name/email normalization) and this documentation.
- The API key is sensitive — keep it only in site `cfg_json` and do not expose it in client-side code or public repositories. The key is read and used exclusively by the Aether backend; it is never sent to the browser.
- If Novi changes their customer API shape, update `app/methods/idaa_novi_verify_methods.py` in the backend (display name/email normalization) and this documentation.
If you need a compact checklist for re-creating this flow in another integration, ask and I will add a small runbook with exact request/response field mappings.
### Planned: Server-Side Novi Verification (FastAPI)
### ~~Planned: Server-Side Novi Verification~~ ✅ Implemented (2026-05-19)
**Problem:** The current implementation calls the Novi API client-side — from the member's browser directly to Novi. Hotel/conference WiFi, VPNs, corporate/hospital networks, and Cloudflare IP reputation filtering can block these calls and produce false "Access Denied" for legitimate members.
**Problem solved:** The previous client-side Novi API call originated from the member's browser.
Hotel/conference WiFi, VPNs, corporate/hospital networks, and Cloudflare IP reputation filtering
could block these calls and produce false "Access Denied" for legitimate members.
**Solution:** A FastAPI endpoint proxies the Novi call server-to-server (Aether → Novi), caching results in Redis. Members' browser IPs are no longer in the call path.
**Solution implemented:** A FastAPI endpoint proxies the Novi call server-to-server
(Aether → Novi), with Redis caching. Members' browser IPs are no longer in the call path.
**Endpoint:** `GET /v3/action/idaa/novi_member/{uuid}`
- Standard Aether auth headers required (`x-aether-api-key`, `x-account-id`)
- Standard Aether auth headers (`x-aether-api-key`, `x-account-id`)
- Server reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json`
- Redis cache key: `idaa:novi_member:{account_id}:{uuid}` — TTL 4 hours, only cache verified 200s
- Redis cache: `idaa:novi_member:{uuid}` — 4-hour TTL, only 200s cached
- `404` results never cached (recently-joined members not incorrectly denied)
**Response codes:**
**Frontend:** `verify_novi_uuid()` in `(idaa)/+layout.svelte` now calls this endpoint with
standard Aether headers. The `novi_idaa_api_key` is still checked for presence in
`site_cfg_json` as a proxy for "is IDAA configured for this site" (server holds the key itself).
| Code | Meaning | Frontend action |
|---|---|---|
| `200` | Verified — `{ "verified": true, "full_name": "...", "email": "..." }` | Grant access |
| `404` | UUID not in Novi (genuine non-member) | Deny access |
| `429` | Novi rate limited | Show retry UI (not a denial) |
| `503` | Novi unreachable | Show retry UI (not a denial) |
**Frontend change when implemented:** Replace the direct `fetch()` to Novi in `verify_novi_uuid()` with a call to this endpoint via `ae_api`. The `api_key` param becomes unused (server holds it). Response code mapping: 404 → denied, 429 → `'rate_limited'`, 503 → `'api_error'`.
**FastAPI task:** Tracked in `aether_api_fastapi/documentation/TODO__Agents.md` under "IDAA: Server-Side Novi Verification".
**Full API spec:** `GUIDE__AE_API_V3_for_Frontend.md` §12.
### Permission Levels (Ascending)
| Level | Condition | Access |

View File

@@ -673,19 +673,19 @@ Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether
|---|---|---|
| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member |
| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry |
| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry |
| `503` | Novi unreachable or Novi 5xx error | Auto-retry once after 3s; if retry also fails, surface as `'api_error'` |
### Migration from direct Novi call
### Migration from direct Novi call — ✅ Complete (2026-05-19)
The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping:
`+layout.svelte:verify_novi_uuid()` now calls this endpoint instead of Novi directly. Response code mapping (for reference):
| Direct Novi result | This endpoint returns | Frontend state |
| Direct Novi result | This endpoint returns | Frontend behavior |
|---|---|---|
| `200` with identity data | `200` | `verified` |
| `200` with no identity data | `404` | `denied` |
| `404` | `404` | `denied` |
| `429` | `429` | `'rate_limited'` |
| Network error / Novi 5xx | `503` | `'api_error'` |
| `429` | `429` | Auto-retry after 10s; `'rate_limited'` if retry fails |
| Network error / Novi 5xx | `503` | Auto-retry after 3s; `'api_error'` if retry fails |
### Caching

View File

@@ -331,16 +331,18 @@ tune it later without changing the profile table.
| Profile name | Extension aliases | Default app | Display mode | Post delay | Notes |
|---|---|---|---|---|---|
| `powerpoint_mac_extend` | `pptx`, `ppt` | Microsoft PowerPoint for macOS | `extend` | `2000ms` | Open in the presentation app and extend to an external display if one is present. |
| `keynote_mac_extend` | `key` | Keynote | `extend` | `2000ms` | Keynote slideshow on the external display if available. |
| `libreoffice_mac_extend` | `odp` | LibreOffice for macOS | `extend` | `2000ms` | LibreOffice Impress for OpenDocument presentations. |
| `acrobat_mac_mirror` | `pdf` | Adobe Acrobat for macOS | `mirror` | `2000ms` | PDF handout / deck view uses mirrored display. |
| `vlc_mirror` | `mp4`, `mkv`, `mp3`, `m4v`, `m4a`, `webm`, `wav`, `aac`, `flac`, `mov`, `mpeg`, `avi`, `flv`, `ogg`, `ogv`, `wmv` | VLC for macOS | `mirror` | `2000ms` | Media playback is mirrored so the room sees the same output as the operator. |
| `powerpoint_win_extend` | `pptxwin`, `pptwin` | PowerPoint for Windows (Parallels) | `extend` | `3000ms` | Windows PowerPoint profile for Parallels-based rooms. |
| `libreoffice_win_extend` | `odpwin` | LibreOffice for Windows | `extend` | `3000ms` | Windows LibreOffice profile for Parallels-based rooms. |
| `acrobat_win_mirror` | `pdfwin` | Adobe Acrobat for Windows (Parallels) | `mirror` | `3000ms` | Windows PDF profile for mirrored display rooms. |
| `powerpoint_mac_extend` | `pptx`, `ppt` | Microsoft PowerPoint for macOS | `extend` | `1000ms` | Open in the presentation app and extend to an external display if one is present. |
| `keynote_mac_extend` | `key` | Keynote | `extend` | `1000ms` | Keynote slideshow on the external display if available. Post-script polls `count of documents > 0` before starting — handles slow file load on cold start. |
| `libreoffice_mac_extend` | `odp` | LibreOffice for macOS | `extend` | `1000ms` | LibreOffice Impress for OpenDocument presentations. |
| `acrobat_mac_mirror` | `pdf` | Adobe Acrobat for macOS | `mirror` | `1000ms` | PDF handout / deck view uses mirrored display. |
| `vlc_mirror` | `mp4`, `mkv`, `mp3`, `m4v`, `m4a`, `webm`, `wav`, `aac`, `flac`, `mov`, `mpeg`, `avi`, `flv`, `ogg`, `ogv`, `wmv` | VLC for macOS | `mirror` | `1000ms` | Media playback is mirrored so the room sees the same output as the operator. Uses `--no-play-and-exit` so playback ends on the last frame instead of closing video output. |
| `powerpoint_win_extend` | `pptxwin`, `pptwin` | PowerPoint for Windows (Parallels) | `extend` | `1500ms` | Windows PowerPoint profile for Parallels-based rooms. Higher base delay to account for Parallels VM startup latency. |
| `libreoffice_win_extend` | `odpwin` | LibreOffice for Windows | `extend` | `1500ms` | Windows LibreOffice profile for Parallels-based rooms. |
| `acrobat_win_mirror` | `pdfwin` | Adobe Acrobat for Windows (Parallels) | `mirror` | `1500ms` | Windows PDF profile for mirrored display rooms. |
| `url_web` | `url` | Browser / Event File web presentation | `extend` | `n/a` | Web-based presentations are handled as Event File URLs rather than cached local files. |
> **Post-open automation timing:** All profiles with a `post_script` use a polling loop rather than a fixed sleep — the AppleScript waits up to ~7.5 s for the target process to become frontmost before firing the automation keystroke. The `post_delay_ms` value is a minimum baseline before polling begins. Overridable per profile via `launch_profiles[profile].post_delay_ms` in `event_device.data_json`.
Versioning is handled automatically: when a presenter uploads an updated file, the new
hash is cached separately and the old one remains intact.

View File

@@ -104,10 +104,10 @@ The native shell provides specialized handlers for controlling the "Podium Exper
### 5.3 Implemented Actuators (Phase 5 Complete)
- **Recording:** `manage_recording({action})` — Aperture session capture (`start`, `stop`, `status`). macOS only.
- **Display Layouts:** `set_display_layout({mode})` — Mirror / Extend via `displayplacer`. macOS only.
- **Display Layouts:** `set_display_layout({mode, configStr?})` — Mirror / Extend displays. macOS only. **Primary:** native `display_control` binary (`resources/bin/display_control`) uses CoreGraphics APIs directly — no Homebrew dependency. Built from `scripts/display_control.m` via `scripts/build-display-control.sh` on a Mac; commit the binary to the repo. **Fallback:** [`displayplacer`](https://github.com/jakehilborn/displayplacer) (`brew install displayplacer`) used when binary is absent or `configStr` override is set. Failures are logged to the Electron console but do not block file open. A **Display Mode** toggle (Extend / Mirror) is available in the Launcher config — Native OS section, visible without Technical Mode.
- **Power Control:** `power_control({action})` — Shutdown, reboot, sleep. macOS + Linux.
- **Window Control:** `window_control({action})` — Maximize, minimize, fullscreen, kiosk mode.
- **Wallpaper:** `set_wallpaper({path})` — macOS (AppleScript) + Linux (gsettings).
- **Wallpaper:** `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Downloads from URL (cached locally) or applies a local path. Per-display targeting (`'all'`/`'primary'`/`'external'`). macOS only in production; Linux returns a dev-mode preview payload.
> **Note:** `update_app` is implemented as a stub — downloads but does not install. Not yet functional for end users.
@@ -169,9 +169,9 @@ no-op when `window.aetherNative` is not present (i.e., in browser/non-native mod
- `control_presentation({app, action})` — Slide navigation (`next`/`prev`/`start`/`stop`) for PowerPoint or Keynote via AppleScript.
### System Management (Phase 5)
- `set_wallpaper({path})` — Sets desktop wallpaper. macOS (AppleScript) + Linux (gsettings).
- `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Sets desktop wallpaper. Downloads from `url` (cached to `~/Library/Caches/OSIT/wallpaper/`) or applies a local `path`. `url_external` targets the projector/second display separately. macOS only in production; Linux returns a dev-mode preview payload without applying.
- `window_control({action, value?})` — Electron window management: maximize, minimize, fullscreen, kiosk.
- `set_display_layout({mode, configStr?})` — Mirror or extend displays via displayplacer. macOS only.
- `set_display_layout({mode, configStr?})` — Mirror or extend displays via [`displayplacer`](https://github.com/jakehilborn/displayplacer). macOS only. Auto-detects via `displayplacer list`; `configStr` overrides auto-detection when set. Binary lookup order: bundled `resources/bin/displayplacer``/opt/homebrew/bin/` (Apple Silicon) → `/usr/local/bin/` (Intel). Requires `brew install displayplacer` on each venue Mac if not bundled.
- `power_control({action})` — Shutdown, reboot, or sleep the host machine. macOS + Linux.
- `manage_recording({action, options?})` — Aperture capture control (`start`/`stop`/`status`). macOS only.
- `open_external({url, app?})` — Opens a URL in Chrome, Firefox, or the default browser.

View File

@@ -23,6 +23,7 @@ guessing defaults.
- [x] Built-in Launcher defaults refactored into canonical profile names plus extension aliases (`ae_launcher__default_launch_profiles.ts`)
- [x] Device-level Launch Timing section added under Launcher Configuration → Device, with per-profile `launch_profiles[profile].post_delay_ms` overrides
- [x] **URL file launch support (2026-05-13)**`event_file.extension = 'URL'` (or filename starting with `http://`/`https://`) is treated as a non-downloaded URL. Background sync skips URL files so they are never treated as cacheable hosted files. Shared `AE_Comp_Hosted_Files_Download_Button` now hard-bypasses the download path for URL records. In native mode, `handle_open_file()` routes to `native.open_external({ url, app: 'chrome' })` with `'default'` fallback. Health section crash fixed (guarded `native_device` against undefined in preview/edit mode).
- [x] **[Launcher/Electron] Display mirroring auto-detection (2026-05-20)** — `native:set-display-layout` rewrote to auto-detect displays via `displayplacer list` when no `configStr` provided. Old code silently returned `{ success: false }` (swallowed by `.catch(() => {})` in relay). Now parses `displayplacer list` output, extracts quoted display strings, builds correct mirror/extend commands. Manual `configStr` still takes priority when provided.
**Svelte-side migration — remaining before May 26:**
- [x] **[Launcher] Built-in Svelte default profiles (2026-05-11)** — canonical profile constants live in `ae_launcher__default_launch_profiles.ts` with extension aliases and a `resolve_launch_profile()` 3-step fallback (device config → event config → built-in defaults). Covers macOS (`pptx`, `ppt`, `key`, `odp`, `pdf`), media (`mp4`, `mkv`, `mp3`, etc.), Windows/Parallels variants, and URL path.
@@ -63,6 +64,28 @@ guessing defaults.
- [ ] **[Launcher] End-to-end test on macOS** — test pptx and key opens on a real podium Mac
before May 26 setup day. Verify: file copies to tmp correctly, script fires, app opens in
slideshow mode, error fallback works.
- [ ] **[Launcher/Electron] Wallpaper stops applying after several changes (post-CMSC)** —
After setting the wallpaper 35 times in a session, macOS silently ignores further `set desktop
picture` calls even though the SvelteKit side reports "Saved & Applied ✓". Restore Default
(`restore_macos_default_wallpaper`) immediately unblocks it; closing/reopening Electron does
not. **Workaround:** use Restore Default, then re-apply. **Root cause:** macOS caches the
current wallpaper path and skips the AppleScript call when the downloaded file lands at the
same temp path. **Fix (post-CMSC):** in the Electron `set_wallpaper` handler
(`aether_app_native_electron`), append a timestamp or random suffix to the temp filename on
every download so macOS always sees a new path (e.g. `wallpaper_1748123456.jpg` instead of
`wallpaper.jpg`).
- [ ] **[Launcher/Electron] `run_cmd`/`run_cmd_sync` — phantom `return_stdout` param (low priority)** —
`electron_relay.ts` passes `return_stdout` in the args object, but both IPC handlers ignore it
(stdout is always returned). Effectively a no-op, but creates a misleading API contract. Fix:
remove the param from relay calls, or honor it in the handlers. No behavioral impact currently.
- [ ] **[Launcher/Electron] `power_control` — sudo requirement not surfaced in UI (low priority)** —
`shutdown` and `reboot` require `sudo` on Linux. macOS works without it; Launcher only targets
macOS so this is a non-issue in production. If Linux podiums are ever deployed, `power_control`
shutdown/reboot will fail silently with a permissions error. No fix needed before CMSC.
- [ ] **[Launcher/Electron] `manage_recording` — aperture binary path on packaged builds** —
Handler resolves the aperture binary via a dev-relative path. Needs verification that the path
resolves correctly inside a packaged `.app` bundle (`app.asar` / `resources/`). Not blocking
for CMSC (recording not part of CMSC workflow). Verify on next packaged build test.
---
@@ -83,11 +106,17 @@ All known root causes fixed across 10+ commits to `src/routes/idaa/(idaa)/+layou
Deploying as of 2026-05-19. Monitor for further member reports.
#### All fixes applied
- [x] **Server-side Novi verification migrated (key fix for hotel/VPN/Cloudflare-filtered networks)**
`verify_novi_uuid()` now calls `GET /v3/action/idaa/novi_member/{uuid}` through the Aether
backend (server-to-server, Redis-cached 4h) instead of making a browser-to-Novi call.
Eliminates false Access Denied for members on hotel/conference WiFi, VPNs, and corporate
networks where the member's IP was rejected by Novi/Cloudflare.
Frontend: `(idaa)/+layout.svelte` | Backend: FastAPI `aether_api_fastapi` | Docs: `GUIDE__AE_API_V3_for_Frontend.md` §12, `CLIENT__IDAA_and_customized_mods.md`
- [x] Novi TTL extended to **12 hours** (5 min → 45 min → 12 h) — covers a full conference day
- [x] Access Denied on iframe reload (sessionStorage URL preservation) — `2855e091f`
- [x] TTL cache bypassed when `$ae_loc` auth flags reset — `2855e091f`
- [x] "Verification Unavailable" screen distinct from "Access Denied" — `2855e091f`
- [x] "Try Again" without page reload (`retry_count` pattern) — `2855e091f`
- [x] Novi TTL extended to 45 minutes (from 5) — `2855e091f` + manual edit
- [x] 12 s AbortController hard timeout on Novi fetch — `e921ca973`
- [x] Network/AbortError gets 3 s grace + one retry — `e921ca973`
- [x] Clear Cache & Reload added to Access Denied state (iframe mode) — `2855e091f`
@@ -111,6 +140,32 @@ below. The TTL + `verify_in_flight` guards are the current mitigation.
---
### ~~[IDAA] Server-side Novi verification — 503 not auto-retried~~ ✅ Fixed (2026-05-20)
---
### [Launcher/VLC] Linux playback — fullscreen + pause-on-end not working
**Status:** Mac ✅ working perfectly; Linux 🚧 deferred for later investigation
**Date discovered:** 2026-05-20
macOS VLC profile (direct binary path) successfully:
- Opens VLC with the media file
- Plays + pauses on the last frame (instead of returning to playlist)
- Fullscreen toggle works (Cmd+F via AppleScript post-script)
Linux VLC command (`vlc --no-play-and-exit --play-and-pause "{{path}}"`) currently:
- Does NOT go fullscreen
- Does NOT pause on the last frame (plays through, returns to playlist)
**Current state:** Both macOS and Linux commands in `ae_launcher__default_launch_profiles.ts`.
macOS is the primary venue deployment platform; Linux support is nice-to-have.
**Investigation needed:** Determine if the VLC flags are being interpreted on Linux,
or if there's a launcher execution layer issue (e.g. `shell:` prefix handling).
File: `src/lib/ae_events/ae_launcher__default_launch_profiles.ts``make_vlc_mirror_linux_profile()`.
---
### [Stores] Svelte 4 → Svelte 5 State Migration (prerequisite for Phase 2c)
The app uses `svelte-persisted-store` (Svelte 4 store contract) for all core persisted state
(`ae_loc`, `idaa_loc`, `ae_api`, `ae_sess`, etc.). In Svelte 5 `$effect`, reading **any field**
@@ -191,19 +246,6 @@ Phases 1, 2a, 2b are complete (see ✅ Completed below). One phase remaining:
`$ae_loc.*` auth-field read sites across 150+ files. Deferred until a Svelte runes migration
of the store layer itself (touching every component anyway makes the callsite sweep cheap).
### [Backend] Join event_location_id onto event_presenter API view
The `event_presenter` object currently has `event_session_id` but not `event_location_id`.
When navigating from the Presenter View to the Launcher, the frontend has to do a secondary
session lookup to discover the location (magic redirect in launcher base `+page.svelte`).
Joining `event_session.event_location_id` into the presenter view/response would let the
frontend pass the location directly in the Launcher URL without the extra lookup.
- [x] Backend: added `event_location_id` (and `event_location_id_random`) to the `event_presenter` view or API response (2026-04-09)
- [x] Frontend: updated `ae_EventPresenter` type and `properties_to_save`; now pass as `events__launcher_id` in `presenter_page_menu.svelte` (2026-04-09)
### [TypeScript] svelte-check hidden errors — discovered 2026-03-27
**HOW WE FOUND THIS:** The `@lucide/svelte` 0.577.0 update (2026-03-10) dropped `class` from
`IconProps`. Fixing it required a `declare module '@lucide/svelte'` augmentation. That
@@ -358,7 +400,7 @@ Firefox unaffected. Production unaffected (public IPs only).
- `BUILD_MODE` arg can stay if needed for other build differences; just stop using it to pick the env file
- Update `.gitignore` in sveltekit to un-ignore `.env.test` / remove stale entries if desired
**Do not touch before the April 21 show.** Low risk but unnecessary churn right before an event.
Low risk but unnecessary churn — defer until after the next active show.
- [ ] **Branch strategy cleanup:** All environments (test, prod, bak) currently pull from the same
branches. `deploy.sh` defaults are `ae_app_3x_llm` / `development` — acceptable for now but
@@ -368,12 +410,6 @@ Firefox unaffected. Production unaffected (public IPs only).
Linode → `deploy.sh`. Deferred until Gitea usage is more established.
### [Files] Download button — wrong ID used in `handle_click()` (2026-04-22)
- [x] **Fixed (2026-05-11):** All 5 spots in `ae_comp__hosted_files_download_button.svelte` updated
to use `hosted_file_obj?.hosted_file_id ?? hosted_file_id` instead of the old
`hosted_file_obj?.id || ...` chain that stopped at `event_file_id`. Needs live re-test to
confirm downloads still work correctly from Manage Files.
### [Files] `db_events.file.clear()` on upload clears all cached files (2026-04-22)
In `ae_comp__event_files_upload.svelte` line 114, `db_events.file.clear()` wipes the entire
`file` Dexie table, not just files for the current session/presenter. Normally harmless (the

View File

@@ -52,15 +52,31 @@ export interface LaunchProfile {
// url?: string;
}
function make_vlc_mirror_profile(): LaunchProfile {
/**
* macOS VLC profile — uses direct binary path for max reliability.
* Bypasses `open -a` argument-handling quirks that could lose file path or re-use existing process.
*/
function make_vlc_mirror_mac_profile(): LaunchProfile {
return {
app: 'VLC',
app: 'VLC (macOS)',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 1500,
// Direct binary path ensures VLC receives media file + flags reliably.
// `--no-play-and-exit` prevents closing on end, `--play-and-pause` holds final frame.
open_cmd: '/Applications/VLC.app/Contents/MacOS/VLC --no-play-and-exit --play-and-pause "{{path}}"',
post_delay_ms: 1000,
// Poll until VLC is frontmost before sending Cmd+F. A fixed delay is unreliable because
// VLC cold-start on a loaded conference Mac can take 3-5 seconds.
// Polling (15 × 0.5 s = up to 7.5 s after the initial wait) fires as soon as VLC is ready.
post_script: `tell application "VLC"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "VLC" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
@@ -69,14 +85,35 @@ end tell`
};
}
/**
* Linux VLC profile — uses shell command for compatibility.
*/
function make_vlc_mirror_linux_profile(): LaunchProfile {
return {
app: 'VLC (Linux)',
display_mode: 'mirror',
// shell: prefix runs as bash command. Same flags as macOS: `--no-play-and-exit` keeps window open, `--play-and-pause` holds final frame.
open_cmd: 'shell:vlc --no-play-and-exit --play-and-pause "{{path}}"',
post_delay_ms: 1000
// No post_script on Linux — VLC opens fullscreen by default, no need to send F.
};
}
const POWERPOINT_MAC_EXTEND_PROFILE: LaunchProfile = {
app: 'Microsoft PowerPoint',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft PowerPoint" "{{path}}"',
post_delay_ms: 2000,
post_delay_ms: 1000,
post_script: `tell application "Microsoft PowerPoint"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "Microsoft PowerPoint" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "Microsoft PowerPoint"
keystroke return using command down
@@ -88,9 +125,20 @@ const KEYNOTE_MAC_EXTEND_PROFILE: LaunchProfile = {
app: 'Keynote',
display_mode: 'extend',
open_cmd: 'open -a "Keynote" "{{path}}"',
post_delay_ms: 2000,
post_delay_ms: 1000,
// Keynote uses `start (front document)` which requires the document to actually be loaded —
// polling frontmost is not enough here. Poll document count instead.
post_script: `tell application "Keynote"
activate
end tell
repeat 20 times
delay 0.5
tell application "Keynote"
if (count of documents) > 0 then exit repeat
end tell
end repeat
delay 0.3
tell application "Keynote"
start (front document)
end tell`
};
@@ -99,10 +147,17 @@ const LIBREOFFICE_MAC_EXTEND_PROFILE: LaunchProfile = {
app: 'LibreOffice',
display_mode: 'extend',
open_cmd: 'open -a "LibreOffice" "{{path}}"',
post_delay_ms: 2000,
post_delay_ms: 1000,
post_script: `tell application "LibreOffice"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "soffice" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "soffice"
key code 96
@@ -114,10 +169,17 @@ const ACROBAT_MAC_MIRROR_PROFILE: LaunchProfile = {
app: 'Adobe Acrobat Reader DC',
display_mode: 'mirror',
open_cmd: 'open -a "Adobe Acrobat Reader DC" "{{path}}"',
post_delay_ms: 2000,
post_delay_ms: 1000,
post_script: `tell application "Adobe Acrobat Reader DC"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "AdobeReader" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "AdobeReader"
keystroke "l" using command down
@@ -125,16 +187,24 @@ tell application "System Events"
end tell`
};
const VLC_MIRROR_PROFILE: LaunchProfile = make_vlc_mirror_profile();
const VLC_MIRROR_MAC_PROFILE: LaunchProfile = make_vlc_mirror_mac_profile();
const VLC_MIRROR_LINUX_PROFILE: LaunchProfile = make_vlc_mirror_linux_profile();
const POWERPOINT_WIN_EXTEND_PROFILE: LaunchProfile = {
app: 'Microsoft Office PowerPoint (Windows)',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft Office PowerPoint" "{{path}}"',
post_delay_ms: 3000,
post_delay_ms: 1500,
post_script: `tell application "Microsoft Office PowerPoint"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "Microsoft Office PowerPoint" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
key code 96
end tell`
@@ -144,10 +214,17 @@ const LIBREOFFICE_WIN_EXTEND_PROFILE: LaunchProfile = {
app: 'LibreOffice (Windows)',
display_mode: 'extend',
open_cmd: 'open -a "LibreOffice" "{{path}}"',
post_delay_ms: 3000,
post_delay_ms: 1500,
post_script: `tell application "LibreOffice"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "soffice" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "soffice"
key code 96
@@ -159,10 +236,17 @@ const ACROBAT_WIN_MIRROR_PROFILE: LaunchProfile = {
app: 'Acrobat Reader (Windows)',
display_mode: 'mirror',
open_cmd: 'open -a "Acrobat Reader Windows" "{{path}}"',
post_delay_ms: 3000,
post_delay_ms: 1500,
post_script: `tell application "Acrobat Reader Windows"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "Acrobat Reader Windows" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
key code 108 using control down
end tell`
@@ -208,10 +292,20 @@ export const DEFAULT_LAUNCH_PROFILE_DEFS: DefaultLaunchProfileDefinition[] = [
aliases: ['pdf'],
profile: ACROBAT_MAC_MIRROR_PROFILE
},
{
name: 'vlc_mirror_mac',
aliases: [],
profile: VLC_MIRROR_MAC_PROFILE
},
{
name: 'vlc_mirror_linux',
aliases: [],
profile: VLC_MIRROR_LINUX_PROFILE
},
{
name: 'vlc_mirror',
aliases: ['mp4', 'mkv', 'mov', 'mpeg', 'avi', 'flv', 'ogg', 'ogv', 'mp3', 'm4v', 'm4a', 'webm', 'wmv', 'wav', 'aac', 'flac'],
profile: VLC_MIRROR_PROFILE
profile: VLC_MIRROR_MAC_PROFILE // Default to macOS (primary deployment platform)
},
{
name: 'powerpoint_win_extend',

View File

@@ -503,6 +503,30 @@ export async function set_display_layout({
return await native.set_display_layout({ mode, configStr });
}
export async function list_display_modes() {
if (!native || !native.list_display_modes)
return { success: false, error: 'Native handler list_display_modes not available' };
return await native.list_display_modes();
}
export async function set_display_mode({
display_index,
width,
height,
refresh_rate,
hidpi
}: {
display_index: number;
width: number;
height: number;
refresh_rate?: number;
hidpi?: boolean | null;
}) {
if (!native || !native.set_display_mode)
return { success: false, error: 'Native handler set_display_mode not available' };
return await native.set_display_mode({ display_index, width, height, refresh_rate, hidpi });
}
export async function power_control({
action
}: {

View File

@@ -6,6 +6,7 @@ import Launcher_Cfg_Section from './launcher_cfg_section.svelte';
import {
Code,
Columns2,
Copy,
FlaskConical,
FolderOpen,
Image,
@@ -27,6 +28,7 @@ let test_cmd_result = $state('');
let remote_app: 'powerpoint' | 'keynote' = $state('powerpoint');
let remote_status = $state('');
let system_status = $state('');
let display_mode = $state<'extend' | 'mirror'>('extend');
async function handle_remote_control(
action: 'next' | 'prev' | 'start' | 'stop'
@@ -55,6 +57,18 @@ async function handle_system_action(promise: Promise<any>, label: string) {
setTimeout(() => (system_status = ''), 3000);
}
async function handle_display_mode(mode: 'extend' | 'mirror') {
system_status = `Setting display: ${mode}...`;
const res = await native.set_display_layout({ mode });
if (res?.success) {
display_mode = mode;
system_status = `Display: ${mode}`;
} else {
system_status = `Display error: ${res?.error || 'No external display?'}`;
}
setTimeout(() => (system_status = ''), 4000);
}
// Modal state for dangerous actions
let show_power_confirm = $state<{ action: string; label: string } | null>(null);
</script>
@@ -124,6 +138,33 @@ let show_power_confirm = $state<{ action: string; label: string } | null>(null);
</div>
</div>
<!-- Display mode toggle: Extend = independent screens (default); Mirror = same content on both -->
<div class="flex flex-col gap-2">
<p class="ml-1 text-[9px] font-bold uppercase opacity-50">Display Mode</p>
<div class="grid grid-cols-2 gap-1">
<button
type="button"
onclick={() => handle_display_mode('extend')}
class="btn btn-xs justify-start border-2"
class:border-primary-500={display_mode === 'extend'}
class:preset-tonal-primary={display_mode === 'extend'}
class:border-surface-400={display_mode !== 'extend'}
class:preset-tonal-surface={display_mode !== 'extend'}>
<Columns2 size="0.85em" class="mr-1 shrink-0" /> Extend
</button>
<button
type="button"
onclick={() => handle_display_mode('mirror')}
class="btn btn-xs justify-start border-2"
class:border-warning-500={display_mode === 'mirror'}
class:preset-tonal-warning={display_mode === 'mirror'}
class:border-surface-400={display_mode !== 'mirror'}
class:preset-tonal-surface={display_mode !== 'mirror'}>
<Copy size="0.85em" class="mr-1 shrink-0" /> Mirror
</button>
</div>
</div>
<!-- 2. Presentation Remote Control (Common) -->
<div class="flex flex-col gap-2">
<div class="flex flex-row items-center justify-between px-1">
@@ -186,19 +227,6 @@ let show_power_confirm = $state<{ action: string; label: string } | null>(null);
System Actions
</p>
<div class="grid grid-cols-1 gap-1">
<button
type="button"
onclick={() =>
handle_system_action(
native.set_display_layout({
mode: 'extend'
}),
'Extend Display'
)}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500 justify-start">
<Columns2 size="0.85em" class="mr-1 shrink-0" /> Extend
Mode
</button>
<button
type="button"
onclick={() =>

View File

@@ -4,7 +4,7 @@ import { events_func } from '$lib/ae_events/ae_events_functions';
import { events_loc } from '$lib/stores/ae_events_stores';
import * as native from '$lib/electron/electron_relay';
import Launcher_Cfg_Section from './launcher_cfg_section.svelte';
import { FlaskConical, Image, Monitor, RotateCcw, Save, Zap } from '@lucide/svelte';
import { FlaskConical, Image, RotateCcw, Save, Zap } from '@lucide/svelte';
interface Props {
on_expand?: () => void;
@@ -19,11 +19,20 @@ type NativeDeviceLike = {
[key: string]: unknown;
};
const PRIMARY_PRESETS = [
{ label: 'OSIT Default', value: 'https://static.oneskyit.com/images/site_background.webp' },
] as const;
const EXTERNAL_PRESETS = [
{ label: 'CMSC', value: 'https://static.oneskyit.com/c/CMSC/images/CMSC_AE_Launcher_bg_secondary.png' },
{ label: 'LCI', value: 'https://static.oneskyit.com/c/LCI/images/LCI_AE_Launcher_bg_secondary.png' },
{ label: 'LCI Alt', value: 'https://static.oneskyit.com/c/LCI/images/LCI_AE_Launcher_bg_secondary_bak.jpg' },
{ label: 'BGH', value: 'https://static.oneskyit.com/c/BGH/images/BGH_AE_Launcher_bg_secondary.png' },
] as const;
let url_input = $state('');
let url_external_input = $state('');
let save_status = $state('');
let apply_status = $state('');
let restore_status = $state('');
let status = $state('');
let last_device_id: string | null = null;
let linux_test_popup_open = $state(false);
@@ -73,19 +82,14 @@ $effect(() => {
sync_from_device();
});
function set_save_status(msg: string) {
save_status = msg;
setTimeout(() => { if (save_status === msg) save_status = ''; }, 3000);
function set_status(msg: string, duration = 4000) {
status = msg;
if (msg) setTimeout(() => { if (status === msg) status = ''; }, duration);
}
function set_apply_status(msg: string) {
apply_status = msg;
setTimeout(() => { if (apply_status === msg) apply_status = ''; }, 4000);
}
async function handle_save() {
async function handle_save(): Promise<boolean> {
const device_id = get_device_id();
if (!device_id) { set_save_status('No device loaded'); return; }
if (!device_id) return false;
const native_device = get_native_device();
const other_json_obj = parse_other_json(native_device?.other_json);
@@ -110,7 +114,6 @@ async function handle_save() {
}
const other_json = JSON.stringify(other_json_obj);
set_save_status('Saving...');
const result = await events_func.update_ae_obj__event_device({
api_cfg: $ae_api,
@@ -119,22 +122,21 @@ async function handle_save() {
log_lvl: 0
});
if (!result) { set_save_status('Save failed'); return; }
if (!result) return false;
const store_loc = $ae_loc as { native_device?: NativeDeviceLike };
store_loc.native_device = { ...get_native_device(), ...result, other_json };
set_save_status('Saved');
return true;
}
async function handle_apply() {
async function handle_apply(): Promise<{ success: boolean; linux_test?: boolean }> {
const url = url_input.trim();
const url_ext = url_external_input.trim();
if (!url && !url_ext) { set_apply_status('Enter a URL first'); return; }
if (!url && !url_ext) return { success: false };
// If only external is set, target only that display so the built-in stays unchanged.
const display = !url && url_ext ? 'external' : 'all';
set_apply_status('Downloading & applying...');
const result = await native.set_wallpaper({
url: url || undefined,
url_external: url_ext || undefined,
@@ -143,37 +145,47 @@ async function handle_apply() {
account_id: String($ae_api.account_id ?? '')
});
if (result?.success && (result as any).linux_test_mode) {
if (result?.success && (result as { linux_test_mode?: boolean }).linux_test_mode) {
linux_test_popup_data = result as Record<string, unknown>;
linux_test_popup_open = true;
set_apply_status('Linux dev mode — see popup');
return { success: true, linux_test: true };
} else if (result?.success) {
$events_loc.launcher.wallpaper_applied_url = url || null;
$events_loc.launcher.wallpaper_applied_url_external = url_ext || null;
set_apply_status('Applied ✓');
return { success: true };
}
return { success: false };
}
async function handle_save_and_apply() {
const device_id = get_device_id();
if (!device_id) { set_status('No device loaded'); return; }
set_status('Saving...');
const saved = await handle_save();
if (!saved) { set_status('Save failed'); return; }
set_status('Applying...');
const { success, linux_test } = await handle_apply();
if (!success) {
set_status('Saved — apply failed');
} else if (linux_test) {
set_status('Linux dev mode — see popup');
} else {
set_apply_status(`Error: ${(result as any)?.error ?? 'Unknown error'}`);
set_status('Saved & Applied ✓');
}
}
async function handle_restore_default() {
restore_status = 'Restoring...';
set_status('Restoring...');
const result = await native.restore_macos_default_wallpaper('all');
if (result?.success) {
// Clear tracked applied URL so the next config URL re-applies correctly.
$events_loc.launcher.wallpaper_applied_url = null;
$events_loc.launcher.wallpaper_applied_url_external = null;
restore_status = 'Restored ✓';
set_status('Restored ✓');
} else {
restore_status = `Error: ${(result as any)?.error ?? 'Unknown error'}`;
}
setTimeout(() => { if (restore_status.startsWith('Restored') || restore_status.startsWith('Error')) restore_status = ''; }, 4000);
}
async function handle_save_and_apply() {
await handle_save();
if (save_status !== 'Save failed' && save_status !== 'No device loaded') {
await handle_apply();
set_status(`Restore failed: ${(result as { error?: string })?.error ?? 'Unknown'}`);
}
}
@@ -200,7 +212,7 @@ const section_description = $derived(
{#if $ae_loc.edit_mode && !$ae_loc.is_native}
<div
class="bg-warning-500/10 border-warning-500/30 mb-1 flex items-center gap-2 rounded-lg border px-2 py-1.5">
class="bg-warning-500/10 border-warning-500/30 mb-2 flex items-center gap-2 rounded-lg border px-2 py-1.5">
<FlaskConical size="0.75em" class="text-warning-500" />
<span class="text-warning-500 text-[9px] font-bold tracking-wide uppercase">
Dev Preview — Apply requires Electron; Save works from any device
@@ -208,131 +220,95 @@ const section_description = $derived(
</div>
{/if}
{#if !get_device_id()}
<div
class="bg-warning-500/10 border-warning-500/20 mb-2 rounded-lg border px-2 py-1.5 text-[9px] opacity-80">
No device record loaded — Save requires a native device config.
</div>
{/if}
<div class="flex flex-col gap-3">
<p class="ml-1 text-[9px] font-bold uppercase opacity-50">
Desktop Background Images
</p>
<p class="ml-1 text-[10px] leading-snug opacity-60">
Paste an HTTPS image URL. Save writes it to this device's config so all
devices auto-apply on their next heartbeat. Apply sets it immediately on
this machine.
</p>
{#if !get_device_id()}
<div
class="bg-warning-500/10 border-warning-500/20 rounded-lg border px-2 py-2 text-[9px] opacity-80">
No device record loaded — Save requires a native device config.
</div>
{/if}
<!-- All Displays (primary URL) -->
<section class="border-surface-500/10 rounded-lg border p-2">
<div class="mb-1.5 flex items-center gap-1.5">
<Monitor size="0.85em" class="opacity-50" />
<p class="text-[10px] font-bold uppercase tracking-wide">
All Displays
</p>
{#if is_applied}
<span
class="text-success-500 ml-auto text-[9px] font-bold">Applied ✓</span>
{:else if $events_loc.launcher.wallpaper_applied_url}
<span class="text-warning-500 ml-auto text-[9px] italic"
>Pending apply</span>
{/if}
<!-- Primary display -->
<div class="flex flex-col gap-1.5">
<p class="text-[10px] font-bold uppercase opacity-50">Primary Display</p>
<div class="flex flex-wrap gap-1.5">
{#each PRIMARY_PRESETS as preset (preset.value)}
<button
type="button"
onclick={() => (url_input = preset.value)}
title={preset.value}
class="btn btn-sm h-8 px-3 text-xs border-2 {url_input === preset.value ? 'preset-filled-primary border-primary-500' : 'preset-tonal-surface border-surface-400'}">
{preset.label}
</button>
{/each}
</div>
<input
type="url"
bind:value={url_input}
placeholder="https://example.com/wallpaper.jpg"
class="input input-sm preset-tonal-surface mb-1.5 h-8 w-full text-[10px]" />
{#if $events_loc.launcher.wallpaper_applied_url && $events_loc.launcher.wallpaper_applied_url !== url_input.trim()}
<p class="text-[9px] italic opacity-40 truncate">
Applied: {$events_loc.launcher.wallpaper_applied_url}
</p>
placeholder="https:// or select a preset above"
class="input input-sm preset-tonal-surface h-8 w-full text-[10px]" />
{#if is_applied}
<p class="text-success-500 pl-0.5 text-[10px]">Applied ✓</p>
{/if}
</section>
</div>
<!-- External Display (optional override) -->
<section class="border-surface-500/10 rounded-lg border p-2">
<div class="mb-1.5 flex items-center gap-1.5">
<Monitor size="0.85em" class="opacity-40" />
<p class="text-[10px] font-bold uppercase tracking-wide">
External / Projector
<span class="ml-1 font-normal normal-case opacity-50">(optional)</span>
</p>
</div>
<p class="mb-1.5 text-[9px] leading-snug opacity-50">
Leave blank to use the same image on all displays.
<!-- External / projector display -->
<div class="flex flex-col gap-1.5">
<p class="text-[10px] font-bold uppercase opacity-50">
External / Projector
<span class="font-normal normal-case opacity-75">(optional)</span>
</p>
<div class="flex flex-wrap gap-1.5">
{#each EXTERNAL_PRESETS as preset (preset.value)}
<button
type="button"
onclick={() => (url_external_input = preset.value)}
title={preset.value}
class="btn btn-sm h-8 px-3 text-xs border-2 {url_external_input === preset.value ? 'preset-filled-primary border-primary-500' : 'preset-tonal-surface border-surface-400'}">
{preset.label}
</button>
{/each}
</div>
<input
type="url"
bind:value={url_external_input}
placeholder="https://example.com/projector-bg.jpg"
placeholder="Blank = use primary on all displays"
class="input input-sm preset-tonal-surface h-8 w-full text-[10px]" />
</section>
<!-- Action Buttons -->
<div class="grid grid-cols-2 gap-2">
<button
type="button"
onclick={handle_save}
disabled={!get_device_id()}
class="btn btn-sm preset-tonal-surface h-8 text-[10px]">
<Save size="0.8em" class="mr-1" /> Save Config
</button>
<button
type="button"
onclick={handle_apply}
disabled={!url_input.trim() && !url_external_input.trim()}
class="btn btn-sm preset-tonal-primary h-8 text-[10px]">
<Zap size="0.8em" class="mr-1" /> Apply Now
</button>
</div>
<button
type="button"
onclick={handle_save_and_apply}
disabled={!get_device_id() || (!url_input.trim() && !url_external_input.trim())}
class="btn btn-sm preset-filled-primary h-8 w-full text-[10px] font-bold">
<Save size="0.8em" class="mr-1" />
<Zap size="0.8em" class="mr-1" />
Save & Apply
</button>
<!-- Restore macOS default — safety valve for end-of-show cleanup -->
{#if $ae_loc.is_native || $ae_loc.edit_mode}
<!-- Actions: single Save & Apply + icon-only Restore -->
<div class="flex gap-2">
<button
type="button"
onclick={handle_restore_default}
class="btn btn-sm preset-tonal-surface h-8 w-full text-[10px] opacity-60 hover:opacity-100">
<RotateCcw size="0.8em" class="mr-1" /> Restore macOS Default
onclick={handle_save_and_apply}
disabled={!get_device_id() || (!url_input.trim() && !url_external_input.trim())}
class="btn preset-filled-primary border-2 border-primary-600 h-10 flex-1 text-sm font-bold disabled:opacity-60">
<Save size={16} class="mr-1" />
<Zap size={16} class="mr-0.5" />
Save & Apply
</button>
{#if $ae_loc.is_native || $ae_loc.edit_mode}
<button
type="button"
onclick={handle_restore_default}
title="Restore macOS default wallpaper"
class="btn preset-tonal-surface border-2 border-surface-400 h-10 w-10 opacity-75 hover:opacity-100">
<RotateCcw size={18} />
</button>
{/if}
</div>
{#if status}
<div
class="text-center text-[10px] italic"
class:text-success-500={status.includes('✓')}
class:text-primary-500={status.includes('Saving') || status.includes('Applying') || status.includes('dev')}
class:text-error-500={status.includes('failed') || status.includes('No device') || status.includes('Restore failed')}>
{status}
</div>
{/if}
{#if save_status}
<div
class="text-primary-500 text-center text-[9px] italic"
class:text-error-500={save_status.includes('failed') || save_status.includes('No device')}>
{save_status}
</div>
{/if}
{#if apply_status}
<div
class="text-[9px] italic text-center"
class:text-success-500={apply_status.includes('✓')}
class:text-primary-500={apply_status.includes('Downloading')}
class:text-error-500={apply_status.includes('Error')}>
{apply_status}
</div>
{/if}
{#if restore_status}
<div
class="text-[9px] italic text-center"
class:text-success-500={restore_status.includes('✓')}
class:text-primary-500={restore_status === 'Restoring...'}
class:text-error-500={restore_status.includes('Error')}>
{restore_status}
</div>
{/if}
</div>
</Launcher_Cfg_Section>
@@ -340,19 +316,19 @@ const section_description = $derived(
<!-- Shows what WOULD have been applied; actual gsettings call is skipped to avoid
resetting the dev workstation monitors on every test cycle. -->
{#if linux_test_popup_open && linux_test_popup_data}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-9999 flex items-center justify-center bg-black/70 p-4"
role="presentation"
onclick={() => (linux_test_popup_open = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
onclick={() => (linux_test_popup_open = false)}
onkeydown={(e) => e.key === 'Escape' && (linux_test_popup_open = false)}>
<div
class="bg-surface-50/95 dark:bg-surface-900/95 border-warning-500/40 relative flex max-h-[90vh] w-full max-w-lg flex-col gap-0 overflow-hidden rounded-xl border shadow-2xl"
role="dialog"
aria-modal="true"
aria-label="Linux Dev Mode — Wallpaper not applied"
tabindex="-1"
onclick={(e) => e.stopPropagation()}>
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}>
<div class="bg-warning-500/10 border-warning-500/30 flex items-center gap-2 border-b px-4 py-3">
<span class="text-warning-600 dark:text-warning-400 font-mono text-xs font-bold uppercase tracking-wider">
@@ -369,7 +345,7 @@ const section_description = $derived(
<div class="flex flex-col gap-3 overflow-y-auto p-4 font-mono text-xs">
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Display Target</span>
<div class="rounded bg-surface-500/10 px-3 py-2">
<div class="bg-surface-500/10 rounded px-3 py-2">
{linux_test_popup_data.display ?? 'all'}
</div>
</div>
@@ -377,7 +353,7 @@ const section_description = $derived(
{#if linux_test_popup_data.url}
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Primary URL</span>
<div class="text-primary-500 rounded bg-surface-500/10 px-3 py-2 break-all">
<div class="text-primary-500 bg-surface-500/10 rounded px-3 py-2 break-all">
{linux_test_popup_data.url}
</div>
</div>
@@ -386,7 +362,7 @@ const section_description = $derived(
{#if linux_test_popup_data.url_external}
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">External / Projector URL</span>
<div class="text-primary-500 rounded bg-surface-500/10 px-3 py-2 break-all">
<div class="text-primary-500 bg-surface-500/10 rounded px-3 py-2 break-all">
{linux_test_popup_data.url_external}
</div>
</div>
@@ -394,8 +370,8 @@ const section_description = $derived(
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Would Have Run</span>
<div class="rounded bg-surface-500/10 px-3 py-2 leading-relaxed whitespace-pre-wrap text-success-600 dark:text-success-400">
{#each (linux_test_popup_data.would_run as string[]) as cmd}
<div class="bg-surface-500/10 text-success-600 dark:text-success-400 rounded px-3 py-2 leading-relaxed whitespace-pre-wrap">
{#each (linux_test_popup_data.would_run as string[]) as cmd, i (i)}
<div class="mb-1">{cmd}</div>
{/each}
</div>

View File

@@ -219,9 +219,8 @@ async function handle_open_file() {
// URL presentations may still want to set display mode (e.g. Google Slides → extend)
if (profile.display_mode !== 'none') {
open_file_status_message = `Setting display (${profile.display_mode})...`;
await native.set_display_layout({ mode: profile.display_mode }).catch(() => {
/* No external display or displayplacer unconfigured — continue */
});
const disp_res = await native.set_display_layout({ mode: profile.display_mode }).catch((e: any) => ({ success: false, error: String(e) }));
if (!disp_res?.success) console.warn('[Launcher] set_display_layout:', disp_res?.error ?? 'no external display');
}
open_file_status_message = `Opening ${event_file_obj.title || 'URL'}...`;
@@ -357,9 +356,8 @@ async function handle_open_file() {
// --- Step 3: Set display layout (skip silently on failure / no external display) ---
if (profile.display_mode !== 'none') {
open_file_status_message = `Setting display (${profile.display_mode})...`;
await native.set_display_layout({ mode: profile.display_mode }).catch(() => {
/* No external display or displayplacer unavailable — continue */
});
const disp_res = await native.set_display_layout({ mode: profile.display_mode }).catch((e: any) => ({ success: false, error: String(e) }));
if (!disp_res?.success) console.warn('[Launcher] set_display_layout:', disp_res?.error ?? 'no external display');
}
// --- Step 4: Open the file ---

View File

@@ -18,16 +18,29 @@
* theme mode without hunting through the system menu or requiring admin access.
*/
import { Moon, Sun, Eye, EyeOff } from '@lucide/svelte';
import { Moon, Sun, Eye, EyeOff, Columns2, Copy } from '@lucide/svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import * as native from '$lib/electron/electron_relay';
interface Props {
log_lvl?: number;
}
let { log_lvl = $bindable(0) }: Props = $props();
let quick_display_mode = $state<'extend' | 'mirror'>('extend');
const is_native_launcher_mode = $derived(
!!$ae_loc.is_native && $events_loc.launcher.app_mode === 'native'
);
async function set_quick_display_mode(mode: 'extend' | 'mirror') {
if (!is_native_launcher_mode) return;
const res = await native.set_display_layout({ mode });
if (res?.success) quick_display_mode = mode;
}
</script>
<div class="flex w-full max-w-full flex-col items-center justify-center gap-1">
@@ -152,4 +165,51 @@ let { log_lvl = $bindable(0) }: Props = $props();
{/if}
</button>
</div>
<!-- ── Quick display mode controls — always visible (native-only action) ── -->
<div class="flex w-full max-w-full flex-row items-center justify-center gap-1">
<button
type="button"
onclick={() => set_quick_display_mode('extend')}
disabled={!is_native_launcher_mode}
class="
btn btn-sm group w-1/2 max-w-1/2 text-xs transition-all
border-2
"
class:border-primary-500={quick_display_mode === 'extend'}
class:preset-tonal-primary={quick_display_mode === 'extend'}
class:border-surface-400={quick_display_mode !== 'extend'}
class:preset-tonal-surface={quick_display_mode !== 'extend'}
title="Set display layout to Extend (separate laptop and projector screens).">
<Columns2 size="0.9em" class="m-1 inline-block" />
<span class="hidden group-hover:inline-block">Display: Extend</span>
<span class="group-hover:hidden">Extend</span>
</button>
<button
type="button"
onclick={() => set_quick_display_mode('mirror')}
disabled={!is_native_launcher_mode}
class="
btn btn-sm group w-1/2 max-w-1/2 text-xs transition-all
border-2
"
class:border-warning-500={quick_display_mode === 'mirror'}
class:preset-tonal-warning={quick_display_mode === 'mirror'}
class:border-surface-400={quick_display_mode !== 'mirror'}
class:preset-tonal-surface={quick_display_mode !== 'mirror'}
title="Set display layout to Mirror (same content on laptop and projector).">
<Copy size="0.9em" class="m-1 inline-block" />
<span class="hidden group-hover:inline-block">Display: Mirror</span>
<span class="group-hover:hidden">Mirror</span>
</button>
</div>
{#if !is_native_launcher_mode}
<div
class="text-[10px] leading-tight opacity-70 text-center px-2"
title="Shown here as a visual preview. Active in native app mode in the session room.">
Display toggle shown as an example preview. Active in native app mode in the session room.
</div>
{/if}
</div>