Compare commits
12 Commits
42f40e990e
...
a56f520d4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a56f520d4e | ||
|
|
c0f828ec2c | ||
|
|
5bb06c4904 | ||
|
|
7621e044b4 | ||
|
|
76569a872f | ||
|
|
d9726d062e | ||
|
|
91f40c4a89 | ||
|
|
e63a17865c | ||
|
|
a59e53aec5 | ||
|
|
6042095147 | ||
|
|
932deced12 | ||
|
|
861385b4ff |
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 3–5 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}: {
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user