# 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 via bundled `displayplacer` binary. macOS only. Auto-detects connected displays via `displayplacer list` when no `configStr` is given — no manual config needed. `configStr` is an optional manual override (the full `displayplacer` config string) stored in `event_device.data_json` if per-device tuning is needed. |
| `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`.

### 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`.
