- Primary display_control path now validates mode explicitly ('mirror'|'extend')
and returns an error instead of silently defaulting to extend.
Passes mode directly to binary (simpler, avoids redundant ternary).
- README: update set_display_layout bridge table row to correctly describe
display_control as primary and displayplacer as fallback.
- README: add one-time 'Build display_control Binary' section in Development
with xcode-select, build, test, and git commit steps.
412 lines
19 KiB
Markdown
412 lines
19 KiB
Markdown
# Aether Native Launcher (Electron)
|
||
|
||
The Aether Native Launcher is a specialized Electron-based shell for the Aether Platform. It provides a secure bridge between the SvelteKit web UI and the local operating system, enabling features restricted by browser sandboxing.
|
||
|
||
## 🚀 Overview
|
||
|
||
This application serves as the "Native Mode" runtime for Aether podiums and devices. It handles:
|
||
- **Local File Orchestration:** Managed cache for presentation files (PPTx, Keynote, PDF).
|
||
- **Automation:** Specialized AppleScript handlers for PowerPoint and Keynote.
|
||
- **Hardware Telemetry:** Direct access to CPU, RAM, and Network interface data.
|
||
- **Remote Control:** Slide navigation and application control via WebSocket intents.
|
||
|
||
## Launcher Terminology
|
||
|
||
Use these terms consistently when working on the launcher bridge:
|
||
|
||
- **Launch Profiles**: the Svelte-side map keyed by file extension.
|
||
- **Launch Profile**: one resolved config object selected from that map for a file.
|
||
- **Native Template**: the single AppleScript or shell command string Electron executes after
|
||
the file has been copied to temp. This is an implementation detail of the bridge.
|
||
|
||
In short, the profiles are the policy; the launch profile is the selected policy entry; the
|
||
native template is the executable string produced by that policy.
|
||
|
||
Do not use `launch_scripts` as the public/config-facing term. If that wording appears in old
|
||
comments or generated output, treat it as stale naming drift and update it to `launch_profiles`
|
||
when referring to the Svelte-side map or `native_template` when referring to the resolved string.
|
||
|
||
## 🖥️ Onsite Deployment
|
||
|
||
**Current hardware:** MacBook Air 2018 — Intel x64. All current deployments use `aether_launcher-darwin-x64`.
|
||
**Future hardware:** Apple Silicon Macs use `aether_launcher-darwin-arm64`. Windows and Linux builds are planned.
|
||
|
||
SSH user on all laptops: **`speaker ready`**
|
||
IP pattern: `192.168.32.1XX` (XX = zero-padded laptop number, e.g. 03 → `.103`). Find/replace this prefix for other onsite environments.
|
||
|
||
Deploy files live in `deploy/`:
|
||
|
||
| File | Purpose |
|
||
| --- | --- |
|
||
| `deploy/deploy.sh` | Deploy script — handles arch detection, scp, and seed.json |
|
||
| `deploy/devices.conf` | Laptop list: number, IP, `event_device_id` |
|
||
| `deploy/event.env` | **Gitignored** — per-event API key and URLs (create from example) |
|
||
| `deploy/event.env.example` | Template for `event.env` |
|
||
|
||
### Step 1 — Build the app (workstation)
|
||
|
||
```bash
|
||
cd ~/OSIT_dev/aether_app_native_electron
|
||
npm run package:mac
|
||
# Produces builds/aether_launcher-darwin-x64/aether_launcher.app ← the one to deploy
|
||
```
|
||
|
||
Only rebuild if source code has changed. The `.app` bundle is identical for all Intel laptops —
|
||
only `~/seed.json` differs per device.
|
||
|
||
> **Note:** The build will print `WARNING: Could not find icon "..." with extension ".icon"`. This
|
||
> is cosmetic — `electron-packager` checks for several icon format variants and warns for each it
|
||
> doesn't find. The `.icns` file is correctly embedded as `electron.icns` inside the app bundle.
|
||
|
||
### Step 2 — Create event.env
|
||
|
||
```bash
|
||
cp deploy/event.env.example deploy/event.env
|
||
# Edit deploy/event.env — fill in AETHER_API_KEY
|
||
```
|
||
|
||
Create the API key in the Aether admin panel before the show (Core → Accounts or Events →
|
||
Devices API key section). All laptops share one key per event. Delete it after the show.
|
||
|
||
### Step 3 — Run the deploy script
|
||
|
||
```bash
|
||
# Deploy specific laptops:
|
||
./deploy/deploy.sh 01 02 03
|
||
|
||
# Deploy all laptops in devices.conf:
|
||
./deploy/deploy.sh all
|
||
|
||
# Update seed.json only (no .app copy — e.g. when rotating the API key):
|
||
./deploy/deploy.sh --seed-only all
|
||
|
||
# Deploy and re-grant Accessibility permission in one pass:
|
||
./deploy/deploy.sh --fix-accessibility all
|
||
```
|
||
|
||
The script auto-detects each Mac's CPU architecture, copies the correct `.app` build, writes
|
||
`seed.json`, and verifies. One SSH connection failure won't abort the batch — it logs and
|
||
continues, then reports which laptops need a retry.
|
||
|
||
### Step 4 — Verify and launch
|
||
|
||
After the script completes, launch the app on each laptop and confirm it connects and shows
|
||
the correct device name in the Launcher UI.
|
||
|
||
### macOS Accessibility Permission
|
||
|
||
The launcher sends keystrokes to PowerPoint and Keynote via AppleScript. macOS requires
|
||
explicit **Accessibility** access for this. Every time a new `.app` binary is deployed, macOS
|
||
invalidates the stored permission because the code signature changes — even when rsync
|
||
updates in-place.
|
||
|
||
**Symptom:** Slide control silently fails; `osascript` eventually returns a permissions error.
|
||
|
||
**Manual fix** (GUI — always works):
|
||
1. System Settings → Privacy & Security → Accessibility
|
||
2. Find `aether_launcher` in the list → remove it (− button)
|
||
3. Re-add it (+ button → `/Applications/aether_launcher.app`) and toggle it on
|
||
|
||
**Automated fix** — use the `--fix-accessibility` deploy flag:
|
||
```bash
|
||
./deploy/deploy.sh --fix-accessibility 01 02 03
|
||
./deploy/deploy.sh --build --fix-accessibility all
|
||
```
|
||
This runs `tccutil reset` (no password required) then attempts a direct TCC database grant
|
||
via `sudo sqlite3`. The sqlite3 step requires NOPASSWD sudo on each Mac — one-time setup:
|
||
|
||
```bash
|
||
ssh "speaker ready"@192.168.32.1XX "sudo visudo -f /etc/sudoers.d/aether-tcc"
|
||
```
|
||
Add this line, then save:
|
||
```text
|
||
speaker ready ALL=(ALL) NOPASSWD: /usr/bin/sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db *
|
||
```
|
||
|
||
If the sqlite3 grant fails (SIP enabled, sudoers not configured), the script logs a warning
|
||
and falls back gracefully — a fresh permission prompt appears the first time the app uses
|
||
accessibility. The `--fix-accessibility` flag can be combined with any other flag.
|
||
|
||
**Long-term fix:** Code-sign the app with an Apple Developer certificate. A stable signature
|
||
means macOS never invalidates the permission on updates. Currently out of scope.
|
||
|
||
### Adding SSH key to a new laptop (first time only)
|
||
|
||
```bash
|
||
ssh-copy-id "speaker ready"@192.168.32.1XX
|
||
```
|
||
|
||
Run once per laptop before deploying.
|
||
|
||
---
|
||
|
||
### Manual deploy reference
|
||
|
||
The script covers the normal case. For one-off fixes or if the script isn't available:
|
||
|
||
```bash
|
||
# Detect arch
|
||
ssh "speaker ready"@192.168.32.103 "uname -m"
|
||
# x86_64 → darwin-x64 | arm64 → darwin-arm64
|
||
|
||
# Copy .app (Intel example):
|
||
scp -r builds/aether_launcher-darwin-x64/aether_launcher.app \
|
||
"speaker ready"@192.168.32.103:/Applications/aether_launcher.app
|
||
|
||
# Write seed.json:
|
||
ssh "speaker ready"@192.168.32.103 "cat > ~/seed.json" << 'EOF'
|
||
{
|
||
"event_device_id": "DEVICE_ID_FOR_THIS_LAPTOP",
|
||
"aether_api_key": "YOUR_API_KEY",
|
||
"primary_api_base_url": "https://api.oneskyit.com",
|
||
"backup_api_base_url": "https://bak-api.oneskyit.com",
|
||
"onsite_api_base_url": null
|
||
}
|
||
EOF
|
||
|
||
# Verify:
|
||
ssh "speaker ready"@192.168.32.103 "cat ~/seed.json"
|
||
```
|
||
|
||
`event_device_id` values by laptop — see the **Device Reference** table below.
|
||
|
||
---
|
||
|
||
## 📋 Device Reference
|
||
|
||
| Laptop | IP Address | event_device_id | Notes |
|
||
|--------|------------------|-----------------|--------------------------|
|
||
| 01 | 192.168.32.101 | tFLL1fLQfnk | |
|
||
| 02 | 192.168.32.102 | rpbfunVPEzw | |
|
||
| 03 | 192.168.32.103 | 1EPfPX8kfw8 | |
|
||
| 04 | 192.168.32.104 | zvgyLM5yieU | |
|
||
| 05 | 192.168.32.105 | QOc046GoeSc | |
|
||
| 06 | 192.168.32.106 | 2o8j6eb0L6s | |
|
||
| 07 | 192.168.32.107 | Oa1tlxPEVSQ | |
|
||
| 08 | 192.168.32.108 | fY4yznpUZ48 | |
|
||
| 09 | 192.168.32.109 | YlgGCyjo9bY | |
|
||
| 10 | 192.168.32.110 | GcTnFsp1mHI | |
|
||
| 11 | 192.168.32.111 | 6z88m9oEZio | |
|
||
| 12 | 192.168.32.112 | EggJqL2kWkA | |
|
||
| 13 | 192.168.32.113 | O11eckHFdVE | |
|
||
| 14 | 192.168.32.114 | reI0SecUEhI | |
|
||
| 15 | 192.168.32.115 | crozxT8mA44 | |
|
||
| 16 | 192.168.32.116 | 0nP4VZsvr2Q | |
|
||
| 17 | 192.168.32.117 | Gm2gNqPGzLA | |
|
||
| 19 | 192.168.32.119 | 6tpukvRVugU | (no laptop 18) |
|
||
| x20 | 192.168.32.120 | rwLYnKUNd1M | old 04, spare/retired |
|
||
|
||
`aether_api_key`: all laptops share a single key per event deployment. The key is created in
|
||
the Aether admin panel before the show and deleted after. Check `builds/seed.json` for the
|
||
current key, or create a new one in Aether (Core → Accounts or Events → Devices API key section)
|
||
before each deployment.
|
||
|
||
---
|
||
|
||
## ⚙️ Configuration
|
||
|
||
The application requires a `seed.json` file to identify the device and connect to the Aether API.
|
||
|
||
### 1. Seed Configuration
|
||
|
||
**Location: `~/seed.json`** (user's home directory — external to the app bundle by design)
|
||
|
||
This file is intentionally kept outside the application bundle so it can be edited per-device
|
||
without re-signing or repackaging the app. On macOS this is `/Users/speaker ready/seed.json`.
|
||
|
||
Seed file format:
|
||
```json
|
||
{
|
||
"event_device_id": "tFLL1fLQfnk",
|
||
"aether_api_key": "YOUR_API_KEY",
|
||
"primary_api_base_url": "https://api.oneskyit.com",
|
||
"backup_api_base_url": "https://bak-api.oneskyit.com",
|
||
"onsite_api_base_url": null
|
||
}
|
||
```
|
||
|
||
`event_device_id` is the `id_random` from the Aether `event_device` record for that physical
|
||
laptop — see the Device Reference table above. `aether_api_key` is a shared key created per
|
||
event deployment and deleted after the show.
|
||
|
||
### 2. Development Setup
|
||
```bash
|
||
npm install
|
||
npm start # Compiles TypeScript (tsc) then launches Electron
|
||
```
|
||
|
||
### 3. File Cache Layout
|
||
|
||
Presentation files are cached locally under `hash_prefix_length`-char subdirectories (default: 2):
|
||
```text
|
||
[local_file_cache_path]/
|
||
4a/
|
||
4a228ef8ac1a...sha256hash...file
|
||
1d/
|
||
1d720916a831...sha256hash...file
|
||
```
|
||
|
||
**Important:** `hash_prefix_length` must be consistent. If it changes, files in old directories
|
||
become orphaned and will be re-downloaded. The default is `2` and should not be changed unless
|
||
explicitly coordinated across all devices.
|
||
|
||
## 🌉 The Native Bridge (`aetherNative`)
|
||
|
||
The bridge is exposed to the renderer via `contextBridge`. It can be accessed in the web UI via `window.aetherNative`.
|
||
|
||
**Design principle:** The Electron app is a thin OS primitive layer. Business logic (which script
|
||
runs for which file type, how to sequence operations, etc.) belongs in the SvelteKit/Svelte side
|
||
where it can be changed without a rebuild and redeployment. Electron handlers should rarely need
|
||
to change.
|
||
|
||
### File Cache
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `check_cache({cache_root, hash, hash_prefix_length?, verify_hash?})` | Checks if a file exists in the hashed cache. `verify_hash: true` re-hashes the file to confirm integrity. |
|
||
| `download_to_cache({url, cache_root, hash, api_key, account_id, hash_prefix_length?})` | Streams a file from the API into the hashed cache. Verifies SHA-256 integrity before finalizing. |
|
||
| `copy_from_cache_to_temp({cache_root, hash, temp_root, filename, hash_prefix_length?})` | **Preferred primitive.** Copies cached file to temp dir with original filename. Returns `{ success, path }`. The Svelte caller decides what to do next. |
|
||
| `launch_from_cache({cache_root, hash, temp_root, filename, hash_prefix_length?, native_template?})` | Combines copy + launch. The Svelte side resolves the Launch Profile to a single `native_template` string (AppleScript or `shell:` prefixed command). If no template is supplied, it returns an error. |
|
||
|
||
### Shell & OS
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `run_cmd({cmd, timeout?, return_stdout?})` | Async shell command execution. |
|
||
| `run_cmd_sync({cmd})` | Synchronous shell command execution. |
|
||
| `run_osascript(script)` | **Hardened.** Runs AppleScript via temp `.scpt` file — handles multi-line scripts and paths with spaces/special characters correctly. macOS only. |
|
||
| `open_folder(path)` | Opens a directory in Finder / system file manager. |
|
||
| `open_local_file_v2(path)` | Opens a file with its default OS application. |
|
||
| `open_external({url, app?})` | Opens a URL in Chrome, Firefox, or the default browser. |
|
||
| `kill_processes({process_name_li})` | Terminates processes by name. macOS/Linux: `pkill -f`. Windows: `taskkill /F`. |
|
||
|
||
### Presentations (Phase 5 — legacy specialized handlers)
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `launch_presentation({path, app?, os?})` | Platform-aware launcher. Resolves `[home]`/`[tmp]` placeholders. Hardened (2026-05-11): AppleScript written to temp `.scpt` file, same as `run_osascript`. For new flows prefer `copy_from_cache_to_temp` + `run_osascript` for full control. |
|
||
| `control_presentation({app, action})` | Slide navigation (`next`/`prev`/`start`/`stop`) for PowerPoint or Keynote via AppleScript. macOS only. |
|
||
|
||
### System Management (Phase 5)
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `get_device_config()` | Returns hydrated device config injected at startup from `seed.json` + API. |
|
||
| `get_device_info()` | Returns OS metadata: platform, hostname, IPs, CPU count, free RAM, home/tmp paths. |
|
||
| `window_control({action, value?})` | Electron window: maximize, minimize, restore, close, fullscreen, kiosk, devtools, reload. |
|
||
| `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` | Sets desktop wallpaper. Accepts a local `path` or downloads from `url` (cached to `~/Library/Caches/OSIT/wallpaper/`). `url_external` sets a separate image on the projector/second display. `display`: `'all'` (default) \| `'primary'` \| `'external'`. macOS only in production; Linux returns a dev-mode preview payload without applying. |
|
||
| `power_control({action})` | Shutdown, reboot, or sleep. macOS + Linux. Requires sudo for shutdown/reboot. |
|
||
| `set_display_layout({mode, configStr?})` | Mirror/extend displays. macOS only. **Primary path:** bundled `display_control` binary (native CoreGraphics, no Homebrew). **Fallback:** `displayplacer` — used when `display_control` binary is absent, or when a `configStr` override is set. `configStr` is an optional manual override (full `displayplacer` config string) stored in `event_device.data_json` for per-device tuning. Build `display_control` once via `scripts/build-display-control.sh` on a Mac and commit `resources/bin/display_control`. |
|
||
| `manage_recording({action, options?})` | Screen recording via bundled `aperture` binary. macOS only. |
|
||
| `update_app(args)` | **Stub.** Downloads update package but does not install. Not functional. |
|
||
| `list_tools()` | Returns a self-describing manifest of all available bridge functions. |
|
||
|
||
### Example Usage (preferred composable pattern)
|
||
|
||
```typescript
|
||
import * as native from '$lib/electron/electron_relay';
|
||
|
||
// Step 1: copy the cached file to temp and get the resolved path
|
||
const copy = await native.copy_from_cache_to_temp({
|
||
cache_root: $ae_loc.local_file_cache_path,
|
||
hash: event_file_obj.hash_sha256,
|
||
temp_root: $ae_loc.host_file_temp_path,
|
||
filename: event_file_obj.filename
|
||
});
|
||
if (!copy.success) { /* handle error */ return; }
|
||
|
||
// Step 2: run whatever script/command you want with that path
|
||
// Option A — AppleScript (macOS):
|
||
await native.run_osascript(`
|
||
tell application "Microsoft PowerPoint"
|
||
activate
|
||
open (POSIX file "${copy.path}")
|
||
delay 3
|
||
end tell
|
||
`);
|
||
|
||
// Option B — shell command:
|
||
await native.run_cmd({ cmd: `open "${copy.path}"` });
|
||
|
||
// Option C — use a template from device config (data-driven, no rebuild needed):
|
||
const template = $ae_loc.native_device?.launch_profiles?.pptx;
|
||
if (template) {
|
||
const script = template.replace(/\{\{path\}\}/g, copy.path);
|
||
await native.run_osascript(script);
|
||
}
|
||
```
|
||
|
||
### Configurable Launch Profiles (no rebuild needed)
|
||
|
||
`launch_profiles` is the Svelte-side map stored in `event_device.data_json.launch_profiles`.
|
||
`launch_from_cache` receives the resolved `native_template` string, not the profile map.
|
||
Keys are lowercase extensions (`pptx`, `key`, `pdf`, etc.); `default` is a catch-all.
|
||
Profiles use `{{path}}` as the file path placeholder.
|
||
AppleScript strings run via `run_osascript`; prefix with `shell:` for shell commands.
|
||
|
||
See `documentation/PROJECT__AE_Events_Launcher_Native_integration.md` Section 8 for full details.
|
||
|
||
## 🛠️ Development
|
||
|
||
- **Preload:** Logic defined in `src/preload/index.ts`.
|
||
- **Handlers:** OS-level logic in `src/main/shell_handlers.ts`, `src/main/file_handlers.ts`, and `src/main/system_handlers.ts`.
|
||
- **Types:** Shared TypeScript interfaces in `src/shared/types.ts`.
|
||
|
||
### One-Time: Build the `display_control` Binary (macOS)
|
||
|
||
The `display_control` binary provides native CoreGraphics display mirror/extend without any Homebrew dependency. It must be compiled once on a Mac and committed to the repo so it is bundled into every packaged app.
|
||
|
||
```bash
|
||
# Requires Xcode Command Line Tools (one-time install):
|
||
xcode-select --install
|
||
|
||
# From the repo root:
|
||
./scripts/build-display-control.sh
|
||
|
||
# Test with a second display connected:
|
||
./resources/bin/display_control status
|
||
./resources/bin/display_control extend
|
||
./resources/bin/display_control mirror
|
||
|
||
# Commit the binary:
|
||
git add resources/bin/display_control
|
||
git commit -m "build: add display_control binary (macOS CoreGraphics)"
|
||
```
|
||
|
||
Once committed, `brew install displayplacer` is no longer required on any venue Mac. The `displayplacer` fallback remains in the handler only for `configStr` per-device overrides.
|
||
|
||
Rebuild only if `scripts/display_control.m` changes.
|
||
|
||
---
|
||
|
||
### Build Requirements (System Dependencies)
|
||
|
||
`bsdtar` (libarchive) must be present on the build host. It is used by the `postinstall` patch
|
||
to work around a hard hang in `@electron/packager` 20 on Node 26.
|
||
|
||
| OS | Install |
|
||
| --- | --- |
|
||
| Arch Linux | `sudo pacman -S libarchive` |
|
||
| macOS | `brew install libarchive` (or use the system bsdtar included in Xcode CLT) |
|
||
| Ubuntu/Debian | `sudo apt install libarchive-tools` |
|
||
|
||
### Why bsdtar — Node 26 Packaging Bug
|
||
|
||
**Symptom:** `npm run package:mac` or `npm run package:linux` prints
|
||
`Packaging app for platform ... using electron v42.x` then hangs forever (or exits 0 with no output).
|
||
|
||
**Root cause:** `yauzl` 2.10.0 (used by `extract-zip` inside `@electron/packager` 20) creates
|
||
read streams that never emit `data` events under Node 26. The zip extraction call blocks
|
||
indefinitely.
|
||
|
||
**Fix:** `scripts/patch-packager-unzip.js` (run automatically via `postinstall`) replaces the
|
||
`extractElectronZip` function in `node_modules/@electron/packager/dist/unzip.js` with a
|
||
`child_process.execSync` call to `bsdtar -xf`. `bsdtar` was chosen over `7z` because `7z`
|
||
refuses to extract macOS `.app` bundles that contain symlinks chained through other symlinks
|
||
(e.g. `Electron Framework.framework/Libraries → Versions/Current/Libraries`, where
|
||
`Versions/Current` is itself a symlink).
|
||
|
||
**This patch is re-applied automatically on every `npm install`** via the `postinstall` hook.
|
||
If a future release of `@electron/packager` or `extract-zip` fixes Node 26 compatibility,
|
||
remove the `postinstall` line from `package.json` and delete `scripts/patch-packager-unzip.js`.
|