30 Commits

Author SHA1 Message Date
Scott Idem
99e0ebb7c3 docs: update TODO_AGENTS — current display_control status + future ideas section 2026-05-20 18:43:14 -04:00
Scott Idem
51db51d991 docs/build: update README + compiled dist for list-modes/set-mode features
README:
  - set_display_layout: note idempotent behavior (no flicker if already in state)
  - add list_display_modes and set_display_mode rows to bridge table
  - add list-modes and set-mode to Option B test commands

dist/: compiled output from tsc (system_handlers, preload, index)
2026-05-20 18:27:58 -04:00
Scott Idem
6cddd69891 fix(display): skip mirror/extend if already in requested state
mirror_displays() now checks CGDisplayMirrorsDisplay() for each secondary
before calling CGBeginDisplayConfiguration — avoids a needless display
reconfiguration (and potential flicker) when already mirrored.
extend_displays() already had this check; no change needed there.
2026-05-20 18:24:33 -04:00
Scott Idem
58060aea8a build: rebuild display_control binary (list-modes + set-mode) 2026-05-20 18:17:17 -04:00
Scott Idem
54308c6d4a feat(display): add list-modes and set-mode commands
display_control.m:
  list-modes  — JSON array of all online displays with every usable mode
                (width, height, refresh, pixel_width, pixel_height, hidpi, is_current)
  set-mode <display_index> <width> <height> [--refresh <hz>] [--hidpi] [--no-hidpi]
              — picks best matching CGDisplayMode; auto-prefers HiDPI on
                built-in and non-HiDPI on externals; highest refresh wins on ties

system_handlers.ts:
  native:list-display-modes  — runs binary, parses JSON, returns displays[]
  native:set-display-mode    — runs binary with supplied args

preload/index.ts + shared/types.ts:
  list_display_modes() / set_display_mode() exposed through bridge with full types
2026-05-20 18:16:43 -04:00
Scott Idem
b2cd0736df fix: use window.maximize() instead of explicit workArea dimensions
Electron screen.workArea is unreliable on KDE — returns full bounds,
causing the window to extend behind the taskbar. Delegating to the native
WM via mainWindow.maximize() works correctly on KDE, macOS, and Linux.
2026-05-20 18:14:26 -04:00
Scott Idem
a230ff09de fix: use workArea instead of bounds for initial window size
Respects taskbar on Linux (KDE etc.) and menu bar on macOS.
bounds caused the window to extend behind the taskbar on KDE.
workArea gives {x, y, width, height} of the usable screen region.
2026-05-20 17:54:05 -04:00
Scott Idem
7199d45719 feat: single instance lock; auto-size window to primary display bounds
- requestSingleInstanceLock(): second launch focuses existing window and quits.
- Window width/height/x/y set from screen.getPrimaryDisplay().bounds so the
  app fills the built-in display exactly on launch, regardless of resolution.
2026-05-20 17:48:09 -04:00
Scott Idem
48e24af84e build: recompile dist after display_control fail-fast fix 2026-05-20 17:45:01 -04:00
Scott Idem
cd1cd02bc2 build: commit display_control universal binary; fix packaging to extract to Resources/bin
- resources/bin/display_control: universal Mach-O (x86_64 + arm64),
  built via scripts/remote-build-display-control.sh on laptop 01.
- package:mac: add --extra-resource=resources/bin so the binary lands at
  [AppName].app/Contents/Resources/bin/ (outside app.asar) and is
  executable at runtime via process.resourcesPath.
2026-05-20 17:32:08 -04:00
Scott Idem
0ae0d644a9 build: add display_control binary (universal x86_64+arm64) 2026-05-20 17:28:39 -04:00
Scott Idem
ce2832a584 fix(build): replace scp with ssh+cat to handle space in username
scp's own user@host:path parser splits on the space in 'speaker ready'
even when the shell argument is quoted. ssh already handles it correctly.
Upload: ssh cat < file; Download: ssh cat > file.
2026-05-20 17:22:34 -04:00
Scott Idem
b6b902ad4a feat(build): add remote-build-display-control.sh for workstation → Mac builds
Copies display_control.m to laptop 01 via SSH, compiles universal binary
(x86_64 + arm64) on the remote Mac, pulls binary back to resources/bin/.
Defaults to 192.168.32.101; accepts optional IP override as $1.

README: restructure display_control build section — Option A (remote from
workstation, preferred) and Option B (direct on Mac).
2026-05-20 17:16:25 -04:00
Scott Idem
3da3b187ec build(display): universal binary — compile x86_64 + arm64 and lipo into fat binary
Compiles both arches separately then links with lipo -create.
Prevents silent failure when a binary built on Apple Silicon is deployed
to Intel venue Macs (MacBook Air 2018 fleet).
2026-05-20 17:10:56 -04:00
Scott Idem
86ea73bfbd fix(display): fail fast on invalid mode in display_control path; update docs
- 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.
2026-05-20 17:04:39 -04:00
Scott Idem
a14c7c7a3f feat(display): add display_control CoreGraphics binary, replace displayplacer as primary path
Derived from OSIT MasterKey app (LegacyUtilities.m, Ian Kohl 2019).
Uses CGConfigureDisplayMirrorOfDisplay — native macOS API, no Homebrew dependency.
Handler now tries display_control first; falls back to displayplacer for missing binary
or per-device configStr overrides. Build the binary via scripts/build-display-control.sh
on a Mac (requires Xcode CLT only), then commit resources/bin/display_control.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:48:19 -04:00
Scott Idem
9df9d884e5 fix(display): fix mirror_of_display syntax, add Homebrew fallback, update docs
- `mirror:` was wrong; correct displayplacer param is `mirror_of_display:<uuid>`
- Same fix applied to the strip regex (extend path) and mirror detection check
- Binary lookup now tries resources/bin → /opt/homebrew/bin → /usr/local/bin
  so dev/venue Macs with `brew install displayplacer` work without bundling
- Updated TODO_AGENTS.md: marks auto-detection complete, documents one-time
  brew install step, bundling path, and optional per-device configStr override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:33:25 -04:00
Scott Idem
2bf4d7c141 New build for Mac 2026-05-20 15:52:35 -04:00
Scott Idem
e37fd1ddbb docs: sync README and list_tools() to current handler reality
README: correct set_display_layout (auto-detection default, configStr optional);
update set_wallpaper signature ({path?,url?,url_external?,display?,api_key?,
account_id?}); remove stale note on launch_presentation -e flag (hardened 2026-05-11).

list_tools(): expand from 13 entries to 22 — adds copy_from_cache_to_temp,
set_wallpaper, set_display_layout, window_control, power_control,
manage_recording, update_app, open_external, get_device_config; updates
launch_from_cache params (adds native_template); adds category comments;
improves all descriptions to reflect current behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:27 -04:00
Scott Idem
a1e74829e8 fix(display): auto-detect displays for mirror/extend via displayplacer list
The handler was a stub that required an explicit configStr — the Svelte
caller never provides one, so mirror/extend silently failed every launch.

Now auto-detects connected displays:
- mirror: parses displayplacer list output, adds mirror:<primary_id> to
  secondary display(s); removes any existing mirror key first.
- extend: re-applies current layout if already extended; otherwise strips
  mirror keys and computes side-by-side origins from each display's width.
- configStr still takes priority when provided (manual per-device override).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:23:01 -04:00
Scott Idem
2c7b609295 Updated dist files 2026-05-13 19:09:09 -04:00
Scott Idem
1008a55ec3 Adjust Linux wallpaper test mode 2026-05-13 18:57:27 -04:00
Scott Idem
c8fdb8b1e7 Harden wallpaper downloads 2026-05-13 18:43:58 -04:00
Scott Idem
bb51771dc7 Expand wallpaper handling 2026-05-13 18:08:49 -04:00
Scott Idem
53d200f10e Version bump because things are in a good state. 2026-05-13 17:31:16 -04:00
Scott Idem
7693b12aeb Clarify native template bridge contract 2026-05-13 11:44:36 -04:00
Scott Idem
72d928f907 Rename launch payload to native_template 2026-05-13 11:28:09 -04:00
Scott Idem
9b98b454fd Align launcher terminology docs 2026-05-13 11:25:55 -04:00
Scott Idem
ec29a576d5 feat(deploy): add --fix-accessibility flag + document TCC requirement
macOS invalidates Accessibility permission whenever the app binary
changes (code signature shifts on each build). New --fix-accessibility
flag runs tccutil reset + a sudo sqlite3 TCC grant via SSH after the
.app is synced. Falls back gracefully if sqlite3 grant fails (SIP or
missing sudoers), logging a warning with a pointer to the manual steps.

README documents the symptom, manual fix, sudoers one-time setup,
and bundle ID (com.electron.aetherlauncher).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:09:34 -04:00
Scott Idem
1f90c819a0 docs(bridge): update types + docs for set_display_layout configStr
- Add set_display_layout (+ other missing system handler methods) to
  AetherNativeBridge interface in types.ts
- README: clarify configStr source (event_device.data_json) and
  no-op behaviour when absent
- TODO_AGENTS: correct 7z→bsdtar throughout; mark Electron-side
  set_display_layout items done; remove completed bsdtar doc item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:36:38 -04:00
24 changed files with 1694 additions and 382 deletions

131
README.md
View File

@@ -10,6 +10,22 @@ This application serves as the "Native Mode" runtime for Aether podiums and devi
- **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`.
@@ -63,6 +79,9 @@ Devices API key section). All laptops share one key per event. Delete it after t
# 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
@@ -74,6 +93,43 @@ continues, then reports which laptops need a retry.
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
@@ -210,7 +266,7 @@ to change.
| `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?, script_template?})` | Combines copy + launch. If `script_template` is provided, runs it (AppleScript or `shell:` prefixed command) instead of hardcoded extension logic. Falls back to built-in defaults when `null`. |
| `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
@@ -228,7 +284,7 @@ to change.
| Method | Description |
| --- | --- |
| `launch_presentation({path, app?, os?})` | Platform-aware launcher. Resolves `[home]`/`[tmp]` placeholders. **Note:** uses legacy `-e` flag for AppleScript; prefer `copy_from_cache_to_temp` + `run_osascript` for new flows. |
| `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)
@@ -238,9 +294,11 @@ to change.
| `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})` | Sets desktop wallpaper. macOS (AppleScript) + Linux (gsettings/Gnome). |
| `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. |
| `set_display_layout({mode, configStr?})` | Mirror/extend displays. macOS only. **Idempotent** — returns success immediately if the displays are already in the requested state (no flicker). **Primary path:** bundled `display_control` binary (native CoreGraphics, no Homebrew). **Fallback:** `displayplacer` — used when the binary is absent or when a `configStr` override is set. `configStr` is a full `displayplacer` string stored in `event_device.data_json` for per-device tuning. Build `display_control` via `scripts/build-display-control.sh` on a Mac and commit `resources/bin/display_control`. |
| `list_display_modes()` | Returns all online displays with every usable `CGDisplayMode` per display: logical size, pixel size, refresh rate, HiDPI flag, and which mode is currently active. macOS only. JSON is parsed for you — result is `{ success, displays: DisplayInfo[] }`. Use this to populate a resolution picker in the UI. |
| `set_display_mode({display_index, width, height, refresh_rate?, hidpi?})` | Sets the resolution/refresh of one display via `CGConfigureDisplayWithDisplayMode`. `display_index` matches the `index` from `list_display_modes`. `hidpi`: `true` = force HiDPI (Retina scaling), `false` = force non-HiDPI, `null`/omit = auto (prefers HiDPI on built-in, non-HiDPI on externals). Picks the highest available refresh rate when multiple modes match. macOS only. |
| `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. |
@@ -273,18 +331,19 @@ await native.run_osascript(`
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_scripts?.pptx;
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 Scripts (no rebuild needed)
### Configurable Launch Profiles (no rebuild needed)
`launch_from_cache` and `launcher_file_cont.svelte` support per-extension script templates
stored in `event_device.data_json.launch_scripts`. Keys are lowercase extensions (`pptx`, `key`,
`pdf`, etc.); `default` is a catch-all. Templates use `{{path}}` as the file path placeholder.
`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.
@@ -295,6 +354,60 @@ See `documentation/PROJECT__AE_Events_Launcher_Native_integration.md` Section 8
- **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 on a Mac (Xcode CLT required) and committed to the repo so it is bundled into every packaged app. Produces a universal binary (x86_64 + arm64) that runs on both Intel and Apple Silicon Macs.
Rebuild only if `scripts/display_control.m` changes.
#### Option A — From the Linux workstation (preferred)
Laptop 01 (`192.168.32.101`) is the designated build Mac — Xcode CLT is installed there.
The remote build script copies the source over SSH, compiles on that Mac, and pulls the binary back.
```bash
# From the repo root on the workstation:
./scripts/remote-build-display-control.sh
# Override the build Mac IP if needed:
./scripts/remote-build-display-control.sh 192.168.32.102
```
The script prints `file resources/bin/display_control` at the end. Confirm both arches appear:
```
resources/bin/display_control: Mach-O universal binary with 2 architectures: [x86_64] [arm64]
```
Then commit:
```bash
git add resources/bin/display_control
git commit -m "build: update display_control binary (universal)"
```
#### Option B — Directly on a Mac
If you have repo access on the Mac itself (Xcode CLT required — `xcode-select --install`):
```bash
./scripts/build-display-control.sh
# Expected last output line: x86_64 arm64
# Test with a second display connected:
./resources/bin/display_control status
./resources/bin/display_control extend
./resources/bin/display_control mirror
./resources/bin/display_control list-modes
./resources/bin/display_control set-mode 0 1920 1080
./resources/bin/display_control set-mode 1 1920 1080 --refresh 60 --no-hidpi
git add resources/bin/display_control
git commit -m "build: update display_control binary (universal)"
```
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.
---
### Build Requirements (System Dependencies)
`bsdtar` (libarchive) must be present on the build host. It is used by the `postinstall` patch

View File

@@ -2,12 +2,14 @@
# deploy.sh — Deploy Aether Native Launcher to onsite Mac laptops
#
# USAGE:
# ./deploy.sh <num> [num ...] Deploy to one or more laptops (e.g. 03 04 05)
# ./deploy.sh all Deploy to all laptops in devices.conf
# ./deploy.sh --seed-only <num> Update seed.json only — skip .app copy
# ./deploy.sh <num> [num ...] Deploy to one or more laptops (e.g. 03 04 05)
# ./deploy.sh all Deploy to all laptops in devices.conf
# ./deploy.sh --seed-only <num> Update seed.json only — skip .app copy
# ./deploy.sh --seed-only all
# ./deploy.sh --build <num> [num ...] Build first (npm run package:mac), then deploy
# ./deploy.sh --build <num> [num ...] Build first (npm run package:mac), then deploy
# ./deploy.sh --build all
# ./deploy.sh --fix-accessibility <num> [num ...] Re-grant macOS Accessibility permission after .app update
# ./deploy.sh --fix-accessibility all (requires NOPASSWD sudo for sqlite3 — see README)
#
# REQUIRES:
# event.env — copy from event.env.example and fill in AETHER_API_KEY
@@ -27,7 +29,10 @@ BUILD_DIR="$SCRIPT_DIR/../builds"
SEED_ONLY=false
BUILD_FIRST=false
FIX_ACCESSIBILITY=false
TARGETS=()
# Bundle ID as embedded in Info.plist by electron-packager (no --app-bundle-id override)
BUNDLE_ID="com.electron.aetherlauncher"
usage() {
grep '^#' "$0" | grep -v '^#!/' | sed 's/^# \{0,1\}//'
@@ -38,9 +43,10 @@ if [[ $# -eq 0 ]]; then usage; fi
while [[ $# -gt 0 ]]; do
case "$1" in
--seed-only) SEED_ONLY=true ;;
--build) BUILD_FIRST=true ;;
--help|-h) usage ;;
--seed-only) SEED_ONLY=true ;;
--build) BUILD_FIRST=true ;;
--fix-accessibility) FIX_ACCESSIBILITY=true ;;
--help|-h) usage ;;
all) TARGETS+=("all") ;;
*) TARGETS+=("$1") ;;
esac
@@ -162,6 +168,26 @@ deploy_laptop() {
}
EOF
# ── Accessibility permission ───────────────────────────────────────────
if [[ "$FIX_ACCESSIBILITY" == "true" ]]; then
echo " Resetting Accessibility permission (tccutil)..."
if ssh "$SSH_USER@$ip" "tccutil reset Accessibility $BUNDLE_ID" 2>/dev/null; then
echo " tccutil reset OK."
else
echo " WARNING: tccutil reset failed (non-fatal)."
fi
echo " Granting Accessibility via TCC database (requires NOPASSWD sudo)..."
# shellcheck disable=SC2016
TCC_SQL="INSERT OR REPLACE INTO access(service,client,client_type,auth_value,auth_reason,auth_version) VALUES('kTCCServiceAccessibility','$BUNDLE_ID',0,2,4,1);"
if ssh "$SSH_USER@$ip" "sudo sqlite3 '/Library/Application Support/com.apple.TCC/TCC.db' \"$TCC_SQL\"" 2>/dev/null; then
echo " ✓ Accessibility granted."
else
echo " WARNING: TCC grant failed — manual re-authorization required."
echo " See README: macOS Accessibility Permission."
fi
fi
# ── Verify ─────────────────────────────────────────────────────────────
echo " Verifying..."
ssh "$SSH_USER@$ip" "cat ~/seed.json"

View File

@@ -130,7 +130,7 @@ function registerFileHandlers() {
endpoints_in_progress = endpoints_in_progress.filter(e => e !== url);
}
});
electron_1.ipcMain.handle('native:launch-from-cache', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2, script_template = null }) => {
electron_1.ipcMain.handle('native:launch-from-cache', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2, native_template = null }) => {
try {
const source = get_organized_hashed_path(cache_root, hash, hash_prefix_length);
const expanded_temp = (0, file_utils_1.expandPath)(temp_root);
@@ -143,118 +143,46 @@ function registerFileHandlers() {
fs.mkdirSync(expanded_temp, { recursive: true });
// 1. Copy the file to temp folder with original name
fs.copyFileSync(source, target);
// 2a. Data-driven script override (no rebuild needed for script changes).
// Set via event_device.data_json.launch_scripts or $events_loc.launcher.launch_scripts.
// 2a. Data-driven launcher template (no rebuild needed for config changes).
// Svelte resolves a Launch Profile to a single native_template string.
// Format: AppleScript string with {{path}} placeholder, OR "shell:<cmd> {{path}}"
if (script_template) {
const resolved = script_template.replace(/\{\{path\}\}/g, target);
if (resolved.startsWith('shell:')) {
const cmd = resolved.slice(6).trim();
console.log(`Native: Running custom shell script for ${filename}`);
return new Promise((resolve_fn) => {
(0, child_process_1.exec)(cmd, (err, stdout, stderr) => {
if (err)
resolve_fn({ success: false, error: err.message, stderr: stderr.trim() });
else
resolve_fn({ success: true, stdout: stdout.trim() });
});
});
}
else {
// Treat as AppleScript — write to temp .scpt file (same hardened approach used below)
console.log(`Native: Running custom AppleScript for ${filename}`);
const tmp_script_path = path.join(os.tmpdir(), `ae_launch_${Date.now()}.scpt`);
return new Promise((resolve_fn) => {
try {
fs.writeFileSync(tmp_script_path, resolved.trim());
}
catch (e) {
resolve_fn({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` });
return;
}
(0, child_process_1.exec)(`osascript "${tmp_script_path}"`, (err) => {
try {
fs.unlinkSync(tmp_script_path);
}
catch { }
if (err)
resolve_fn({ success: false, error: err.message });
else
resolve_fn({ success: true });
});
});
}
if (!native_template) {
return { success: false, error: 'No native template configured for this file' };
}
// 2b. Determine file type (legacy hardcoded launch logic — used when no script_template provided)
const ext = path.extname(filename).toLowerCase().replace('.', '');
const is_pres = ['pptx', 'ppt', 'key', 'pdf', 'odp'].includes(ext);
// 3. Hardcoded launch (legacy — still the default when no script_template is configured)
if (is_pres) {
if (os.platform() === 'linux') {
console.log(`Native: Launching LibreOffice (--impress) for ${target}`);
return new Promise((resolve) => {
(0, child_process_1.exec)(`libreoffice --impress "${target}"`, (err) => {
if (err)
resolve({ success: false, error: err.message });
else
resolve({ success: true });
});
const resolved = native_template.replace(/\{\{path\}\}/g, target);
if (resolved.startsWith('shell:')) {
const cmd = resolved.slice(6).trim();
console.log(`Native: Running custom shell template for ${filename}`);
return new Promise((resolve_fn) => {
(0, child_process_1.exec)(cmd, (err, stdout, stderr) => {
if (err)
resolve_fn({ success: false, error: err.message, stderr: stderr.trim() });
else
resolve_fn({ success: true, stdout: stdout.trim() });
});
}
if (os.platform() === 'darwin') {
let script = '';
if (ext === 'key') {
script = `
tell application "Keynote"
activate
open (POSIX file "${target}")
delay 1
start (front document)
end tell
`;
}
else if (ext === 'pptx' || ext === 'ppt') {
script = `
tell application "Microsoft PowerPoint"
activate
open (POSIX file "${target}")
delay 3
end tell
tell application "System Events"
keystroke return using command down
end tell
`;
}
if (script) {
console.log(`Native: Launching ${ext} via AppleScript for ${target}`);
// Write to a temp .scpt file instead of passing via -e flag.
// The -e approach breaks on multi-line scripts and paths with spaces or quotes.
const tmp_script_path = path.join(os.tmpdir(), `ae_launch_${Date.now()}.scpt`);
return new Promise((resolve) => {
try {
fs.writeFileSync(tmp_script_path, script.trim());
}
catch (e) {
resolve({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` });
return;
}
(0, child_process_1.exec)(`osascript "${tmp_script_path}"`, (err) => {
try {
fs.unlinkSync(tmp_script_path);
}
catch { }
if (err)
resolve({ success: false, error: err.message });
else
resolve({ success: true });
});
});
}
}
});
}
// 4. Default Fallback
await electron_1.shell.openPath(target);
return { success: true };
console.log(`Native: Running custom AppleScript template for ${filename}`);
const tmp_script_path = path.join(os.tmpdir(), `ae_launch_${Date.now()}.scpt`);
return new Promise((resolve_fn) => {
try {
fs.writeFileSync(tmp_script_path, resolved.trim());
}
catch (e) {
resolve_fn({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` });
return;
}
(0, child_process_1.exec)(`osascript "${tmp_script_path}"`, (err) => {
try {
fs.unlinkSync(tmp_script_path);
}
catch { }
if (err)
resolve_fn({ success: false, error: err.message });
else
resolve_fn({ success: true });
});
});
}
catch (error) {
return { success: false, error: error.message };
@@ -265,7 +193,7 @@ function registerFileHandlers() {
// run_osascript, run_cmd, open_local_file, etc.
//
// This is the preferred building block for custom launch flows. Use launch_from_cache
// when the built-in hardcoded logic is sufficient; use copy_from_cache_to_temp when
// when Svelte has already resolved a native template; use copy_from_cache_to_temp when
// you want full control over what happens after the file lands in temp.
electron_1.ipcMain.handle('native:copy-from-cache-to-temp', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2 }) => {
try {

File diff suppressed because one or more lines are too long

39
dist/main/index.js vendored
View File

@@ -50,8 +50,8 @@ async function createWindow() {
cachedFullConfig = await (0, api_client_1.fetchFullConfig)(cachedSeed);
}
mainWindow = new electron_1.BrowserWindow({
width: 1600,
height: 900,
width: 1280,
height: 800,
title: 'OSIT Aether Launcher (Native)',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
@@ -59,6 +59,9 @@ async function createWindow() {
nodeIntegration: false,
},
});
// Let the native window manager maximize — more reliable than using screen.workArea,
// which Electron under-reports on KDE and other Linux DEs.
mainWindow.maximize();
let targetUrl = 'http://demo.localhost:5173';
if (cachedFullConfig && cachedFullConfig.native_device) {
const device = cachedFullConfig.native_device;
@@ -82,15 +85,29 @@ async function createWindow() {
(0, shell_handlers_1.registerShellHandlers)();
(0, file_handlers_1.registerFileHandlers)();
(0, system_handlers_1.registerSystemHandlers)();
electron_1.app.on('ready', createWindow);
electron_1.app.on('window-all-closed', () => {
if (process.platform !== 'darwin')
electron_1.app.quit();
});
electron_1.app.on('activate', () => {
if (mainWindow === null)
createWindow();
});
// Single instance lock — if another instance is already running, focus it and quit.
const gotTheLock = electron_1.app.requestSingleInstanceLock();
if (!gotTheLock) {
electron_1.app.quit();
}
else {
electron_1.app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized())
mainWindow.restore();
mainWindow.focus();
}
});
electron_1.app.on('ready', createWindow);
electron_1.app.on('window-all-closed', () => {
if (process.platform !== 'darwin')
electron_1.app.quit();
});
electron_1.app.on('activate', () => {
if (mainWindow === null)
createWindow();
});
}
electron_1.ipcMain.handle('get-seed-config', async () => cachedSeed || await (0, config_loader_1.loadSeedConfig)());
electron_1.ipcMain.handle('get-device-config', async () => cachedFullConfig);
electron_1.ipcMain.handle('get-jwt', async () => null);

View File

@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/main/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,uCAAuD;AACvD,2CAA6B;AAC7B,uCAAyB;AACzB,mDAAiD;AACjD,6CAA+C;AAC/C,qDAAyD;AACzD,mDAAuD;AACvD,uDAA2D;AAG3D,IAAI,UAAU,GAAyB,IAAI,CAAC;AAC5C,IAAI,UAAU,GAAsB,IAAI,CAAC;AACzC,IAAI,gBAAgB,GAAQ,IAAI,CAAC;AAEjC,KAAK,UAAU,YAAY;IACzB,UAAU,GAAG,MAAM,IAAA,8BAAc,GAAE,CAAC;IACpC,IAAI,UAAU,EAAE,CAAC;QACf,gBAAgB,GAAG,MAAM,IAAA,4BAAe,EAAC,UAAU,CAAC,CAAC;IACvD,CAAC;IAED,UAAU,GAAG,IAAI,wBAAa,CAAC;QAC7B,KAAK,EAAE,IAAI;QACX,MAAM,EAAE,GAAG;QACX,KAAK,EAAE,+BAA+B;QACtC,cAAc,EAAE;YACd,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,qBAAqB,CAAC;YACpD,gBAAgB,EAAE,IAAI;YACtB,eAAe,EAAE,KAAK;SACvB;KACF,CAAC,CAAC;IAEH,IAAI,SAAS,GAAG,4BAA4B,CAAC;IAC7C,IAAI,gBAAgB,IAAI,gBAAgB,CAAC,aAAa,EAAE,CAAC;QACvD,MAAM,MAAM,GAAG,gBAAgB,CAAC,aAAa,CAAC;QAC9C,MAAM,OAAO,GAAG,MAAM,CAAC,eAAe,IAAI,MAAM,CAAC,QAAQ,CAAC;QAC1D,MAAM,UAAU,GAAG,MAAM,CAAC,wBAAwB,IAAI,MAAM,CAAC,iBAAiB,IAAI,EAAE,CAAC;QACrF,mEAAmE;QACnE,uFAAuF;QACvF,MAAM,IAAI,GAAG,MAAM,CAAC,YAAY,IAAI,qBAAqB,CAAC;QAC1D,8DAA8D;QAC9D,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;QAC/D,SAAS,GAAG,GAAG,QAAQ,MAAM,IAAI,WAAW,OAAO,aAAa,UAAU,EAAE,CAAC;IAC/E,CAAC;IAED,mEAAmE;IACnE,IAAI,CAAC,cAAG,CAAC,UAAU;QAAE,UAAU,CAAC,WAAW,CAAC,YAAY,EAAE,CAAC;IAC3D,UAAU,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE;QACvC,UAAU,EAAE,OAAO,CAAC,gCAAgC,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;IAEH,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,GAAG,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AACxD,CAAC;AAED,IAAA,sCAAqB,GAAE,CAAC;AACxB,IAAA,oCAAoB,GAAE,CAAC;AACvB,IAAA,wCAAsB,GAAE,CAAC;AAEzB,cAAG,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC;AAE9B,cAAG,CAAC,EAAE,CAAC,mBAAmB,EAAE,GAAG,EAAE;IAC/B,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,cAAG,CAAC,IAAI,EAAE,CAAC;AAChD,CAAC,CAAC,CAAC;AAEH,cAAG,CAAC,EAAE,CAAC,UAAU,EAAE,GAAG,EAAE;IACtB,IAAI,UAAU,KAAK,IAAI;QAAE,YAAY,EAAE,CAAC;AAC1C,CAAC,CAAC,CAAC;AAEH,kBAAO,CAAC,MAAM,CAAC,iBAAiB,EAAE,KAAK,IAAI,EAAE,CAAC,UAAU,IAAI,MAAM,IAAA,8BAAc,GAAE,CAAC,CAAC;AACpF,kBAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE,CAAC,gBAAgB,CAAC,CAAC;AAClE,kBAAO,CAAC,MAAM,CAAC,SAAS,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,CAAC;AAE5C,kBAAO,CAAC,MAAM,CAAC,iBAAiB,EAAE,KAAK,IAAI,EAAE;IAC3C,MAAM,UAAU,GAAG,EAAE,CAAC,iBAAiB,EAAE,CAAC;IAC1C,MAAM,SAAS,GAAa,EAAE,CAAC;IAC/B,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QAC3C,KAAK,MAAM,GAAG,IAAI,UAAU,CAAC,IAAI,CAAE,EAAE,CAAC;YACpC,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;gBAC3C,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YAC9B,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO;QACL,QAAQ,EAAE,EAAE,CAAC,QAAQ,EAAE;QACvB,OAAO,EAAE,EAAE,CAAC,OAAO,EAAE;QACrB,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE;QACf,QAAQ,EAAE,EAAE,CAAC,QAAQ,EAAE;QACvB,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,MAAM;QACtB,SAAS,EAAE,EAAE,CAAC,QAAQ,EAAE;QACxB,QAAQ,EAAE,EAAE,CAAC,OAAO,EAAE;QACtB,YAAY,EAAE,SAAS;QACvB,cAAc,EAAE,EAAE,CAAC,OAAO,EAAE;QAC5B,aAAa,EAAE,EAAE,CAAC,MAAM,EAAE;KAC3B,CAAC;AACJ,CAAC,CAAC,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/main/index.ts"],"names":[],"mappings":"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"}

View File

@@ -224,65 +224,115 @@ end tell
});
electron_1.ipcMain.handle('native:list-tools', async () => {
return [
// --- Config & Info ---
{
name: 'get_device_config',
description: 'Returns hydrated device config injected at startup from seed.json + API.',
params: {}
},
{
name: 'get_device_info',
description: 'Returns OS metadata: platform, hostname, IPs, CPU count, free RAM, home/tmp paths.',
params: {}
},
// --- File Cache ---
{
name: 'check_cache',
description: 'Checks if a file exists in the hashed cache. verify_hash:true re-hashes to confirm integrity.',
params: { cache_root: 'string', hash: 'string', hash_prefix_length: 'number (optional, default 2)', verify_hash: 'boolean (optional)' }
},
{
name: 'download_to_cache',
description: 'Streams a file from the API into the hashed cache with SHA-256 integrity check. Cleans stale .tmp files older than 5 min.',
params: { url: 'string', cache_root: 'string', hash: 'string', api_key: 'string', account_id: 'string', hash_prefix_length: 'number (optional, default 2)' }
},
{
name: 'copy_from_cache_to_temp',
description: 'Preferred primitive. Copies a cached file to temp with its original filename. Returns { success, path }. Caller decides what to do next.',
params: { cache_root: 'string', hash: 'string', temp_root: 'string', filename: 'string', hash_prefix_length: 'number (optional, default 2)' }
},
{
name: 'launch_from_cache',
description: 'Combines copy_from_cache_to_temp + execute. Runs native_template after copying — AppleScript string with {{path}} placeholder, or "shell:<cmd>" prefix. Returns error if native_template is null.',
params: { cache_root: 'string', hash: 'string', temp_root: 'string', filename: 'string', hash_prefix_length: 'number (optional)', native_template: 'string | null' }
},
// --- Shell & OS ---
{
name: 'open_folder',
description: 'Opens a directory in the OS file explorer (Finder/Files/Explorer).',
description: 'Opens a directory in the OS file explorer (Finder on macOS).',
params: { path: 'string' }
},
{
name: 'run_cmd',
description: 'Executes an asynchronous shell command with a timeout.',
params: { cmd: 'string', timeout: 'number (optional)' }
description: 'Async shell command execution with timeout.',
params: { cmd: 'string', timeout: 'number (optional, default 30000ms)' }
},
{
name: 'run_cmd_sync',
description: 'Executes a synchronous shell command.',
description: 'Synchronous shell command execution.',
params: { cmd: 'string' }
},
{
name: 'run_osascript',
description: 'Executes a raw AppleScript string (macOS only).',
description: 'Hardened AppleScript executor — writes to temp .scpt file, handles multi-line scripts and paths with special characters. macOS only.',
params: { script: 'string' }
},
{
name: 'kill_processes',
description: 'Forcefully terminates processes by name.',
description: 'Terminates processes by name. macOS/Linux: pkill -f. Windows: taskkill /F.',
params: { process_name_li: 'string[]' }
},
{
name: 'open_local_file_v2',
description: 'Opens a local file using the default OS handler.',
params: { filePath: 'string' }
description: 'Opens a file with its default OS application via shell.openPath.',
params: { path: 'string' }
},
{
name: 'open_external',
description: 'Opens a URL in Chrome, Firefox, or the system default browser.',
params: { url: 'string', app: 'chrome | firefox | default (optional)' }
},
// --- Presentations ---
{
name: 'launch_presentation',
description: 'Phase 5: Specialized launcher for PowerPoint, Keynote, and LibreOffice with auto-focus.',
params: { path: 'string', app: 'default|powerpoint|keynote' }
description: 'Platform-aware launcher for PowerPoint, Keynote, LibreOffice. Resolves [home]/[tmp] placeholders. Hardened AppleScript (2026-05-11). Prefer copy_from_cache_to_temp + run_osascript for new flows.',
params: { path: 'string', app: 'default | powerpoint | keynote (optional)', os_platform: 'string (optional)' }
},
{
name: 'control_presentation',
description: 'Phase 5: Remote navigation for active slideshows.',
params: { app: 'powerpoint|keynote', action: 'next|prev|start|stop' }
description: 'Slide navigation for active PowerPoint or Keynote via AppleScript. macOS only.',
params: { app: 'powerpoint | keynote', action: 'next | prev | start | stop' }
},
// --- System Management ---
{
name: 'set_wallpaper',
description: 'Sets desktop wallpaper. Downloads from url (cached to ~/Library/Caches/OSIT/wallpaper/) or applies local path. url_external targets projector/second display separately. macOS only in production.',
params: { path: 'string (optional)', url: 'string (optional)', url_external: 'string (optional)', display: 'all | primary | external (optional, default all)', api_key: 'string (optional)', account_id: 'string (optional)' }
},
{
name: 'check_cache',
description: 'Checks if a file exists in the local organized cache.',
params: { cache_root: 'string', hash: 'string', hash_prefix_length: 'number' }
name: 'set_display_layout',
description: 'Mirror or extend displays via bundled displayplacer. macOS only. Auto-detects displays when no configStr given; configStr is an optional manual override.',
params: { mode: 'mirror | extend', configStr: 'string (optional)' }
},
{
name: 'download_to_cache',
description: 'Downloads a file from the API directly into the native cache.',
params: { url: 'string', cache_root: 'string', hash: 'string', api_key: 'string', account_id: 'string' }
name: 'window_control',
description: 'Electron window management.',
params: { action: 'maximize | unmaximize | minimize | restore | close | fullscreen | kiosk | devtools | reload', value: 'boolean (optional, used by fullscreen/kiosk/devtools)' }
},
{
name: 'launch_from_cache',
description: 'Atomic operation: Copies file from cache to temp with original name and launches via specialized handler.',
params: { cache_root: 'string', hash: 'string', temp_root: 'string', filename: 'string' }
name: 'power_control',
description: 'Shutdown, reboot, or sleep the host machine. macOS + Linux. May require sudo for shutdown/reboot.',
params: { action: 'shutdown | reboot | sleep' }
},
{
name: 'get_device_info',
description: 'Returns hardware and OS metadata (CPUs, RAM, IP addresses, Hostname).',
params: {}
name: 'manage_recording',
description: 'Screen recording via bundled aperture binary. macOS only.',
params: { action: 'start | stop | status', options: '{ fps?, audioDeviceId?, output? } (optional)' }
},
{
name: 'update_app',
description: 'STUB: Downloads update package but does not install. Not functional.',
params: { source: 'url | file', url: 'string (optional)', path: 'string (optional)' }
}
];
});

File diff suppressed because one or more lines are too long

View File

@@ -100,17 +100,162 @@ function registerSystemHandlers() {
return { success: true };
});
// 2. Set Wallpaper
electron_1.ipcMain.handle('native:set-wallpaper', async (event, { path: imagePath }) => {
const cleanPath = (0, file_utils_1.expandPath)(imagePath);
if (!fs.existsSync(cleanPath))
return { success: false, error: 'Image file not found' };
if (os.platform() === 'darwin') {
const script = `tell application "System Events" to set picture of every desktop to "${cleanPath}"`;
return await runExec(`osascript -e '${script}'`);
// Supports local path OR URL download. URL images are saved to a stable cache dir
// so macOS can reference them persistently after reboot.
// display: 'all' (default) | 'primary' (built-in) | 'external' (projector/second screen)
// url_external: optional second URL for the external display only.
electron_1.ipcMain.handle('native:set-wallpaper', async (event, { path: imagePath, url, url_external, display = 'all', api_key, account_id }) => {
// Cache dir: ~/Library/Caches/OSIT/wallpaper on macOS, ~/.cache/osit/wallpaper on Linux.
// Using a stable path means macOS keeps the reference across reboots.
const wallpaper_cache_dir = os.platform() === 'darwin'
? path.join(os.homedir(), 'Library', 'Caches', 'OSIT', 'wallpaper')
: path.join(os.homedir(), '.cache', 'osit', 'wallpaper');
async function download_wallpaper_image(image_url, basename) {
if (!fs.existsSync(wallpaper_cache_dir)) {
fs.mkdirSync(wallpaper_cache_dir, { recursive: true });
}
// Infer extension from URL path, fall back to .jpg
let ext = '.jpg';
try {
const url_path = new URL(image_url).pathname;
const inferred = path.extname(url_path).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp'].includes(inferred)) {
ext = inferred === '.jpeg' ? '.jpg' : inferred;
}
}
catch { }
const dest_path = path.join(wallpaper_cache_dir, basename + ext);
const headers = {};
if (api_key)
headers['x-aether-api-key'] = api_key;
if (account_id)
headers['x-account-id'] = account_id;
try {
const response = await (0, axios_1.default)({ method: 'get', url: image_url, responseType: 'stream', headers });
const content_type = (response.headers['content-type'] ?? '');
if (!content_type.startsWith('image/')) {
response.data.destroy();
return { success: false, error: `URL did not return an image (Content-Type: ${content_type})` };
}
const writer = fs.createWriteStream(dest_path);
response.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
const file_size = fs.statSync(dest_path).size;
if (file_size === 0) {
try {
fs.unlinkSync(dest_path);
}
catch { }
return { success: false, error: 'Wallpaper download incomplete (0 bytes)' };
}
return { success: true, path: dest_path };
}
catch (e) {
return { success: false, error: `Wallpaper download failed: ${e.message}` };
}
}
else if (os.platform() === 'linux') {
// Gnome/Ubuntu default
return await runExec(`gsettings set org.gnome.desktop.background picture-uri "file://${cleanPath}"`);
// HARDENED: write AppleScript to a temp file, same pattern as native:run-osascript.
// The old osascript -e approach breaks on paths with spaces or special characters.
async function apply_mac_wallpaper(img_path, display_target) {
const escaped = img_path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
let script;
if (display_target === 'primary') {
script = `tell application "System Events"\n\ttell desktop 1\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
}
else if (display_target === 'external') {
script = `tell application "System Events"\n\ttell desktop 2\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
}
else {
script = `tell application "System Events"\n\ttell every desktop\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
}
const script_path = path.join(os.tmpdir(), `ae_wallpaper_${Date.now()}.scpt`);
fs.writeFileSync(script_path, script, 'utf-8');
try {
return await runExec(`osascript "${script_path}"`);
}
finally {
try {
fs.unlinkSync(script_path);
}
catch { }
}
}
if (os.platform() === 'darwin') {
// Resolve primary image path
let primary_path = null;
if (imagePath) {
const clean = (0, file_utils_1.expandPath)(imagePath);
if (!fs.existsSync(clean))
return { success: false, error: 'Image file not found' };
primary_path = clean;
}
else if (url) {
const result = await download_wallpaper_image(url, 'wallpaper_primary');
if (!result.success || !result.path)
return { success: false, error: result.error };
primary_path = result.path;
}
if (!primary_path && url_external && display === 'external') {
const ext_result = await download_wallpaper_image(url_external, 'wallpaper_external');
if (!ext_result.success || !ext_result.path)
return { success: false, error: ext_result.error };
return await apply_mac_wallpaper(ext_result.path, 'external');
}
if (!primary_path)
return { success: false, error: 'No image source provided' };
if (url_external) {
// Different images for each display: set primary display first, then external
const primary_result = await apply_mac_wallpaper(primary_path, 'primary');
if (!primary_result.success)
return primary_result;
const ext_result = await download_wallpaper_image(url_external, 'wallpaper_external');
if (!ext_result.success || !ext_result.path)
return { success: false, error: ext_result.error };
return await apply_mac_wallpaper(ext_result.path, 'external');
}
else {
return await apply_mac_wallpaper(primary_path, display);
}
}
if (os.platform() === 'linux') {
// Dev test mode: never touch the desktop on Linux. Running gsettings during
// development would reset the dev workstation monitors on every test cycle.
// Return what would have run so the Svelte side can show a debug popup.
const would_run = [];
const cache_dir = wallpaper_cache_dir;
if (!imagePath && !url && !url_external) {
return { success: false, error: 'No image source provided' };
}
if (imagePath) {
const clean = (0, file_utils_1.expandPath)(imagePath);
if (!fs.existsSync(clean))
return { success: false, error: 'Image file not found' };
}
if (url)
would_run.push(`download: ${url}\n${path.join(cache_dir, 'wallpaper_primary.jpg')}`);
if (url_external)
would_run.push(`download: ${url_external}\n${path.join(cache_dir, 'wallpaper_external.jpg')}`);
if (imagePath) {
would_run.push(`gsettings set org.gnome.desktop.background picture-uri "file://${(0, file_utils_1.expandPath)(imagePath)}"`);
}
else if (url) {
would_run.push(`gsettings set org.gnome.desktop.background picture-uri "file://${path.join(cache_dir, 'wallpaper_primary.jpg')}"`);
}
if (url_external) {
would_run.push(`(external display: gsettings has no per-display wallpaper support)`);
}
return {
success: true,
linux_test_mode: true,
platform: 'linux',
display,
url: url ?? null,
url_external: url_external ?? null,
would_run
};
}
return { success: false, error: 'Platform not supported' };
});
@@ -139,7 +284,7 @@ function registerSystemHandlers() {
}
if (!cmd)
return { success: false, error: 'Action not supported' };
// NOTE: These commands often require root.
// NOTE: These commands often require root.
// For a kiosk, you might configure sudoers to allow this specific command without password.
return await runExec(cmd);
});
@@ -215,33 +360,128 @@ function registerSystemHandlers() {
}
return { success: false, error: 'Unknown action' };
});
// 6. Set Display Layout (Displayplacer)
// 6. Set Display Layout
// Primary path: display_control (native CoreGraphics, no external deps).
// Build from scripts/display_control.m via scripts/build-display-control.sh on a Mac.
// Commit the resulting resources/bin/display_control binary to the repo.
// Fallback: displayplacer (requires: brew install displayplacer on each venue Mac).
// Also supports per-device configStr override (displayplacer format).
electron_1.ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => {
if (os.platform() !== 'darwin')
return { success: false, error: 'Display control only supported on macOS' };
const binPath = electron_1.app.isPackaged
? path.join(process.resourcesPath, 'bin', 'displayplacer')
: path.join(__dirname, '../../resources/bin/displayplacer');
let cmd = '';
// Primary: display_control — native CoreGraphics, no Homebrew dependency.
// Derived from OSIT MasterKey app (LegacyUtilities.m). No configStr support needed —
// CoreGraphics auto-detects all connected displays.
const dc_bin = electron_1.app.isPackaged
? path.join(process.resourcesPath, 'bin', 'display_control')
: path.join(__dirname, '../../resources/bin/display_control');
if (fs.existsSync(dc_bin) && !configStr) {
if (mode !== 'mirror' && mode !== 'extend') {
return { success: false, error: `Unsupported display mode: ${mode}` };
}
return await runExec(`"${dc_bin}" ${mode}`);
}
// Fallback: displayplacer — required when display_control binary is not built yet,
// or when a per-device configStr override is set (displayplacer-format string from event_device.data_json).
// Install: brew install displayplacer
const _dp_candidates = electron_1.app.isPackaged
? [path.join(process.resourcesPath, 'bin', 'displayplacer')]
: [
path.join(__dirname, '../../resources/bin/displayplacer'),
'/opt/homebrew/bin/displayplacer', // Apple Silicon Homebrew
'/usr/local/bin/displayplacer', // Intel Homebrew
];
const dpPath = _dp_candidates.find(p => fs.existsSync(p)) ?? _dp_candidates[0];
// Explicit configStr takes priority — allows manual per-device override.
if (configStr) {
return await runExec(`"${dpPath}" ${configStr}`);
}
// Auto-detect via `displayplacer list`.
const list_result = await runExec(`"${dpPath}" list`);
if (!list_result.success || !list_result.stdout) {
return { success: false, error: `displayplacer not available. Build display_control from scripts/build-display-control.sh or run: brew install displayplacer` };
}
// The command line looks like: displayplacer "id:xxx res:... origin:(0,0) ..." "id:yyy ..."
const cmd_line = list_result.stdout.split('\n').find(l => l.trim().startsWith('displayplacer "'));
if (!cmd_line) {
return { success: false, error: 'Only one display connected or displayplacer list output unrecognised' };
}
const display_strings = [...cmd_line.matchAll(/"([^"]+)"/g)].map(m => m[1]);
if (display_strings.length < 2) {
return { success: false, error: 'Only one display found; cannot change layout' };
}
if (mode === 'mirror') {
// This usually requires querying current IDs, which is complex.
// If configStr is provided (output of 'displayplacer list'), use it.
if (configStr) {
cmd = `"${binPath}" ${configStr}`;
const primary_id_match = display_strings[0].match(/\bid:([^\s]+)/);
if (!primary_id_match) {
return { success: false, error: 'Could not parse primary display ID from displayplacer output' };
}
else {
return { success: false, error: 'Config string required for now' };
const primary_id = primary_id_match[1];
const mirror_args = display_strings.map((s, i) => {
if (i === 0)
return `"${s}"`;
const without_existing_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim();
return `"${without_existing_mirror} mirror_of_display:${primary_id}"`;
}).join(' ');
return await runExec(`"${dpPath}" ${mirror_args}`);
}
if (mode === 'extend') {
const any_mirrored = display_strings.some(s => /\bmirror_of_display:\S+/.test(s));
if (!any_mirrored) {
return await runExec(`"${dpPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`);
}
let x_offset = 0;
const extend_args = display_strings.map((s) => {
const without_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim();
const res_match = without_mirror.match(/\bres:(\d+)x\d+/);
const width = res_match ? parseInt(res_match[1]) : 1920;
const updated = without_mirror.replace(/\borigin:\([^)]+\)/, `origin:(${x_offset},0)`);
x_offset += width;
return `"${updated}"`;
}).join(' ');
return await runExec(`"${dpPath}" ${extend_args}`);
}
else if (mode === 'extend') {
if (configStr) {
cmd = `"${binPath}" ${configStr}`;
}
return { success: false, error: `Unsupported display mode: ${mode}` };
});
// 6b. List Display Modes
electron_1.ipcMain.handle('native:list-display-modes', async () => {
if (os.platform() !== 'darwin')
return { success: false, error: 'Display control only supported on macOS' };
const dc_bin = electron_1.app.isPackaged
? path.join(process.resourcesPath, 'bin', 'display_control')
: path.join(__dirname, '../../resources/bin/display_control');
if (!fs.existsSync(dc_bin)) {
return { success: false, error: 'display_control binary not found. Build via scripts/build-display-control.sh.' };
}
if (cmd) {
return await runExec(cmd);
const result = await runExec(`"${dc_bin}" list-modes`);
if (!result.success || !result.stdout) {
return { success: false, error: result.error ?? 'list-modes returned no output' };
}
return { success: false, error: 'Invalid mode or missing config' };
try {
const displays = JSON.parse(result.stdout);
return { success: true, displays };
}
catch (e) {
return { success: false, error: `Failed to parse list-modes output: ${e.message}`, raw: result.stdout };
}
});
// 6c. Set Display Mode
electron_1.ipcMain.handle('native:set-display-mode', async (event, { display_index, width, height, refresh_rate, hidpi }) => {
if (os.platform() !== 'darwin')
return { success: false, error: 'Display control only supported on macOS' };
const dc_bin = electron_1.app.isPackaged
? path.join(process.resourcesPath, 'bin', 'display_control')
: path.join(__dirname, '../../resources/bin/display_control');
if (!fs.existsSync(dc_bin)) {
return { success: false, error: 'display_control binary not found. Build via scripts/build-display-control.sh.' };
}
let cmd = `"${dc_bin}" set-mode ${display_index} ${width} ${height}`;
if (refresh_rate)
cmd += ` --refresh ${refresh_rate}`;
if (hidpi === true)
cmd += ' --hidpi';
if (hidpi === false)
cmd += ' --no-hidpi';
return await runExec(cmd);
});
// 7. Update App
electron_1.ipcMain.handle('native:update-app', async (event, { source, url, path: localPath }) => {

File diff suppressed because one or more lines are too long

View File

@@ -25,6 +25,8 @@ electron_1.contextBridge.exposeInMainWorld('aetherNative', {
window_control: (args) => electron_1.ipcRenderer.invoke('native:window-control', args),
manage_recording: (args) => electron_1.ipcRenderer.invoke('native:manage-recording', args),
set_display_layout: (args) => electron_1.ipcRenderer.invoke('native:set-display-layout', args),
list_display_modes: () => electron_1.ipcRenderer.invoke('native:list-display-modes'),
set_display_mode: (args) => electron_1.ipcRenderer.invoke('native:set-display-mode', args),
power_control: (args) => electron_1.ipcRenderer.invoke('native:power-control', args),
open_external: (args) => electron_1.ipcRenderer.invoke('native:open-external', args),
});

View File

@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/preload/index.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/preload/index.ts"],"names":[],"mappings":"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"}

View File

@@ -7,6 +7,20 @@
- We now know the API side was not the root cause. The bootstrap request shape in `src/main/api_client.ts` was wrong and has been corrected.
- The packaging blocker has been diagnosed and fixed (see below).
## Launcher Terminology Cleanup
- Align the native docs/comments with the Svelte-side terminology: **Launch Profile** = the
Svelte config object keyed by extension; **Native Template** = the AppleScript or shell
string Electron actually executes after the file lands in temp.
- Update `src/main/file_handlers.ts` comments and any bridge-facing wording so they describe the
resolved `native_template` string accurately. `launch_profiles` is only the Svelte-side map;
the IPC payload is the single executable string. Do not reintroduce `launch_scripts` as the
public term.
- Keep the source of truth in Svelte. Electron should remain a thin executor/copy layer.
- After source/docs updates, rebuild/regenerate `dist/main/file_handlers.js` from source; do not
hand-edit the generated file.
- If any parameter names remain awkward in source, preserve runtime behavior first and rename
only when the signature ripple is understood.
## What Was Fixed
- Updated the native bootstrap flow to use the direct `site_domain/search` request body expected by API V3.
- Standardized the account-bypass header to `x-no-account-id: bypass` where that narrow bypass is intended.
@@ -15,7 +29,7 @@
- Upgraded Electron from 34.x to 42.0.1.
- Replaced deprecated `electron-packager` with `@electron/packager` 20.0.0.
- Added a `package:linux` smoke test path so packaging failures can be isolated from macOS-specific behavior.
- **Fixed packaging hang on Node 26:** `yauzl` 2.10.0 (used by `extract-zip` in `@electron/packager`) emits no `data` events on Node 26 streams, causing zip extraction to hang indefinitely. Fix: patched `@electron/packager/dist/unzip.js` to use `7z` (system binary) instead of `extract-zip`. Patch is re-applied on every `npm install` via the `postinstall` script at `scripts/patch-packager-unzip.js`.
- **Fixed packaging hang on Node 26:** `yauzl` 2.10.0 (used by `extract-zip` in `@electron/packager`) emits no `data` events on Node 26 streams, causing zip extraction to hang indefinitely. Fix: patched `@electron/packager/dist/unzip.js` to use `bsdtar` (libarchive) instead of `extract-zip`. `bsdtar` was chosen over `7z` because `7z` refuses macOS `.app` bundles with chained symlinks inside framework bundles. Patch is re-applied on every `npm install` via the `postinstall` script at `scripts/patch-packager-unzip.js`.
## Verified So Far
- `npm run dev` works once the Electron binary is present locally.
@@ -31,14 +45,13 @@
## Remaining Items
1. Test that the packaged Linux binary runs end-to-end against the dev API.
2. Document that `bsdtar` (libarchive) must be present on the build host — new build-time dependency. On Arch: `sudo pacman -S libarchive`.
## Root Cause Summary (Packaging Hang)
- **Tool chain:** Node 26.1.0 + `@electron/packager` 20.0.0 + `extract-zip` 2.0.1 + `yauzl` 2.10.0
- **Symptom:** `npm run package:linux` exits 0 but produces no output. Debug log shows it starts extraction but never finishes.
- **Root cause:** `yauzl` opens a read stream for the first zip entry, but on Node 26, no `data` events are ever emitted on that stream. The `pipeline(readStream, writeStream)` call in `extract-zip` blocks forever.
- **Fix:** Replace the one-liner `extractElectronZip` function in `node_modules/@electron/packager/dist/unzip.js` with a `child_process.execSync` call to `7z x`. A `postinstall` npm script re-applies this patch after each `npm install`.
- **Build-time dependency:** `p7zip` (provides `/usr/bin/7z`) must be installed on the build host. On Arch: `pacman -S p7zip`.
- **Fix:** Replace the one-liner `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 macOS `.app` bundles with chained symlinks (e.g. `Electron Framework.framework/Libraries → Versions/Current/Libraries`). A `postinstall` npm script re-applies this patch after each `npm install`.
- **Build-time dependency:** `libarchive` (provides `bsdtar`) must be installed on the build host. On Arch: `pacman -S libarchive`; macOS: included in Xcode CLT or `brew install libarchive`; Ubuntu/Debian: `apt install libarchive-tools`.
## References
- Electron 42 release notes: https://www.electronjs.org/blog/electron-42-0
@@ -50,3 +63,86 @@
- Was on Electron 34.
- The problem is not the backend API keys or the frontend site bootstrap flow.
- The packaging fix is a node_modules patch, not upstream. If `@electron/packager` or `extract-zip` releases a Node 26-compatible version, the `postinstall` script should be removed.
---
## set_display_layout — Setup & Status (updated 2026-05-20)
**Primary approach: `display_control` (native CoreGraphics — no Homebrew required)**
- Source: `scripts/display_control.m` (derived from OSIT MasterKey app, Ian Kohl 2019)
- Build: run `scripts/build-display-control.sh` on a Mac (requires Xcode CLT only)
- Output: `resources/bin/display_control` — commit this binary to the repo
- Uses `CGConfigureDisplayMirrorOfDisplay` — same CoreGraphics API macOS uses internally
- Supports 3+ displays; auto-detects all connected displays; no config string needed
**Fallback approach: `displayplacer` (requires `brew install displayplacer` on each venue Mac)**
- Reference: [jakehilborn/displayplacer](https://github.com/jakehilborn/displayplacer)
- Still used when `display_control` binary is not present
- Also used for per-device `configStr` overrides (displayplacer-format strings in `event_device.data_json`)
**Current state (2026-05-20):**
- ✅ Correct `mirror_of_display:<uuid>` syntax used in displayplacer fallback
- ✅ Failures logged to Electron console (`[Launcher] set_display_layout:`)
- ✅ Display Mode toggle in Launcher config (Native OS section) — always visible
-`display_control` binary built (universal x86_64 + arm64), committed to repo
-**Idempotency**`mirror` and `extend` both no-op with a clean message if already in the requested state (no display flicker)
-**`list-modes`** — JSON array of all online displays + every usable `CGDisplayMode` (width, height, refresh, pixel size, HiDPI flag, is_current)
-**`set-mode`** — sets resolution/refresh via `CGConfigureDisplayWithDisplayMode`; supports `--refresh`, `--hidpi`, `--no-hidpi`; auto-prefers HiDPI on built-in, non-HiDPI on externals
- ✅ IPC handlers `native:list-display-modes` + `native:set-display-mode` wired through full bridge stack (system_handlers → preload → types → electron_relay)
- ✅ Remote build script (`scripts/remote-build-display-control.sh`) — compiles on laptop-01 via SSH from Linux workstation; uses `ssh cat` pipe pattern (avoids scp space-in-username bug)
**To rebuild `display_control` after source changes:**
```bash
# From repo root on workstation (laptop-01 must be reachable):
./scripts/remote-build-display-control.sh
# Or directly on a Mac:
./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
./resources/bin/display_control list-modes
./resources/bin/display_control set-mode 0 1920 1080
./resources/bin/display_control set-mode 1 1920 1080 --refresh 60 --no-hidpi
# Commit:
git add resources/bin/display_control
git commit -m "build: update display_control binary (universal)"
```
**Optional per-device override (displayplacer format, for edge cases):**
For rooms where auto-detection produces the wrong result, store the raw configStr in `event_device.data_json`:
```json
{
"displayplacer_config_extend": "<output of displayplacer list in extended layout>",
"displayplacer_config_mirror": "<output of displayplacer list in mirrored layout>"
}
```
`configStr` is passed from the Svelte call site and uses the displayplacer fallback path directly.
---
## Future Ideas
Capabilities worth adding as the Launcher matures. Roughly ordered by venue-day impact.
### 1. Display reconfiguration events (push IPC)
`CGDisplayRegisterReconfigurationCallback` fires when a display is connected or removed. Wrapping this in a `webContents.send('native:display-changed', payload)` push event would let the Svelte UI auto-mirror the moment a projector cable lands — eliminating the most common operator action during show setup. Currently the UI must poll `status` or the operator presses Mirror manually.
### 2. Audio output routing
When mirroring to a projector the audio output should follow. CoreAudio (`AudioObjectSetPropertyData` on `kAudioHardwarePropertyDefaultOutputDevice`) can switch the default output device programmatically. Candidate bridge method: `set_audio_output({device_name?, prefer_hdmi?})`. Could be called automatically as part of the mirror flow, or exposed as a standalone control.
### 3. Battery / power status in telemetry
`get_device_info` returns CPU and RAM but nothing about power. On venue MacBook Airs this matters operationally. IOKit (`IOPSCopyPowerSourcesInfo` / `IOPSGetPowerSourceDescription`) can surface: charge %, is-charging, time-remaining, health. Low-cost addition to the existing telemetry handler.
### 4. Presentation state feedback
`control_presentation` is fire-and-forget. AppleScript can query the current slide index and total slide count from both PowerPoint (`current slide index of active presentation`) and Keynote (`slide number of current slide of front document`). A `get_presentation_state()` bridge method returning `{ app, slide, total, presenting }` would let the Launcher UI show "Slide 7 of 42" — useful for operators monitoring multiple rooms.
### 5. Push event channel (IPC renderer notifications)
All bridge calls are currently request-response. Adding a `webContents.send` channel for unsolicited Electron → renderer events would unlock: display plug/unplug (#1 above), file download progress, network state changes, "presentation ended" detection. A thin `ipcMain.on('native:subscribe', ...)` registration pattern on the Electron side and a corresponding `ipcRenderer.on` listener in the preload would cover all use cases without breaking the existing handler structure.
### 6. Kiosk / accidental-quit hardening
A speaker or operator can accidentally Cmd+Q the launcher mid-presentation. `app.on('before-quit')` with either a confirmation dialog or an API-controlled lock flag (`event_device.data_json.kiosk_locked: true`) would prevent this. Can be toggled remotely — lock before the show, unlock after.

View File

@@ -1,6 +1,6 @@
{
"name": "aether_app_native_electron",
"version": "1.0.0",
"version": "3.0.20",
"description": "AE Native Launcher V3",
"main": "dist/main/index.js",
"scripts": {
@@ -10,7 +10,7 @@
"build": "tsc",
"watch": "tsc -w",
"package:linux": "tsc && electron-packager . aether_launcher --platform=linux --arch=x64 --out=builds --overwrite --prune=true",
"package:mac": "tsc && electron-packager . aether_launcher --platform=darwin --arch=x64,arm64 --out=builds --overwrite --prune=true --icon=resources/img/osit_logo.icns"
"package:mac": "tsc && electron-packager . aether_launcher --platform=darwin --arch=x64,arm64 --out=builds --overwrite --prune=true --icon=resources/img/osit_logo.icns --extra-resource=resources/bin"
},
"devDependencies": {
"@types/node": "^22.19.0",

BIN
resources/bin/display_control Executable file

Binary file not shown.

View File

@@ -0,0 +1,53 @@
#!/bin/bash
# scripts/build-display-control.sh
# Compile the display_control binary for macOS.
#
# Requirements: Xcode Command Line Tools
# xcode-select --install
#
# Run this on a Mac. Commit the resulting binary to resources/bin/
# so it is bundled into the packaged Electron app without any Homebrew dependency.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SRC="$SCRIPT_DIR/display_control.m"
OUT_DIR="$REPO_ROOT/resources/bin"
OUT_BIN="$OUT_DIR/display_control"
if ! command -v clang &>/dev/null; then
echo "ERROR: clang not found."
echo "Install Xcode Command Line Tools: xcode-select --install"
exit 1
fi
mkdir -p "$OUT_DIR"
TMP_X86="$OUT_DIR/display_control_x86_64"
TMP_ARM="$OUT_DIR/display_control_arm64"
echo "Building display_control (x86_64)..."
clang -arch x86_64 -framework Cocoa -framework Carbon \
-o "$TMP_X86" "$SRC"
echo "Building display_control (arm64)..."
clang -arch arm64 -framework Cocoa -framework Carbon \
-o "$TMP_ARM" "$SRC"
echo "Linking universal binary..."
lipo -create -output "$OUT_BIN" "$TMP_X86" "$TMP_ARM"
rm "$TMP_X86" "$TMP_ARM"
chmod +x "$OUT_BIN"
echo ""
echo "Built universal binary: $OUT_BIN"
lipo -archs "$OUT_BIN"
echo ""
echo "Test it:"
echo " $OUT_BIN status"
echo " $OUT_BIN extend"
echo " $OUT_BIN mirror"
echo ""
echo "Once verified, commit resources/bin/display_control to the repo."

377
scripts/display_control.m Normal file
View File

@@ -0,0 +1,377 @@
/*
* display_control.m
* Native macOS CLI for programmatic display mirror/extend control.
*
* Derived from the OSIT MasterKey app (LegacyUtilities.m, Ian Kohl 2019).
* Uses CoreGraphics APIs no external dependencies (no displayplacer/Homebrew required).
*
* Build: run scripts/build-display-control.sh on a Mac (requires Xcode Command Line Tools)
* Usage: display_control <mirror|extend|status>
*/
#import <Cocoa/Cocoa.h>
#include <Carbon/Carbon.h>
#include <math.h>
#define MAX_DISPLAYS 8
#define MAX_MODES 256
typedef struct { long src_index; size_t w, h, pw, ph; double refresh; } ModeEntry;
static int mirror_displays(void) {
CGDirectDisplayID onlineDspys[MAX_DISPLAYS] = {0};
CGDisplayCount numOnline = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, onlineDspys, &numOnline);
if (numOnline < 2) {
fprintf(stderr, "No secondary display detected (%u online).\n", numOnline);
return 1;
}
CGDirectDisplayID mainID = CGMainDisplayID();
// Idempotency: if every secondary is already mirroring mainID, nothing to do.
CGDisplayCount alreadyMirrored = 0;
for (CGDisplayCount i = 0; i < numOnline; i++) {
if (onlineDspys[i] != mainID && CGDisplayMirrorsDisplay(onlineDspys[i]) == mainID)
alreadyMirrored++;
}
if (alreadyMirrored == numOnline - 1) {
printf("Displays already mirrored.\n");
return 0;
}
CGDisplayConfigRef config;
CGError err = CGBeginDisplayConfiguration(&config);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGBeginDisplayConfiguration failed: %d\n", err);
return 1;
}
BOOL any_configured = NO;
for (CGDisplayCount i = 0; i < numOnline; i++) {
if (onlineDspys[i] != mainID) {
err = CGConfigureDisplayMirrorOfDisplay(config, onlineDspys[i], mainID);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGConfigureDisplayMirrorOfDisplay failed: %d\n", err);
CGCancelDisplayConfiguration(config);
return 1;
}
any_configured = YES;
}
}
if (!any_configured) {
CGCancelDisplayConfiguration(config);
fprintf(stderr, "No secondary displays to mirror.\n");
return 1;
}
err = CGCompleteDisplayConfiguration(config, kCGConfigurePermanently);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGCompleteDisplayConfiguration failed: %d\n", err);
return 1;
}
printf("Mirrored %u display(s).\n", numOnline - 1);
return 0;
}
static int extend_displays(void) {
CGDirectDisplayID onlineDspys[MAX_DISPLAYS] = {0};
CGDirectDisplayID activeDspys[MAX_DISPLAYS] = {0};
CGDisplayCount numOnline = 0, numActive = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, onlineDspys, &numOnline);
CGGetActiveDisplayList(MAX_DISPLAYS, activeDspys, &numActive);
if (numOnline < 2) {
fprintf(stderr, "No secondary display detected (%u online).\n", numOnline);
return 1;
}
if (numActive >= numOnline) {
printf("Displays already extended.\n");
return 0;
}
CGDirectDisplayID mainID = CGMainDisplayID();
CGDisplayConfigRef config;
CGError err = CGBeginDisplayConfiguration(&config);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGBeginDisplayConfiguration failed: %d\n", err);
return 1;
}
for (CGDisplayCount i = 0; i < numOnline; i++) {
if (onlineDspys[i] != mainID) {
// kCGNullDirectDisplay as master = un-mirror (extend)
err = CGConfigureDisplayMirrorOfDisplay(config, onlineDspys[i], kCGNullDirectDisplay);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGConfigureDisplayMirrorOfDisplay(null) failed: %d\n", err);
CGCancelDisplayConfiguration(config);
return 1;
}
}
}
err = CGCompleteDisplayConfiguration(config, kCGConfigurePermanently);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGCompleteDisplayConfiguration failed: %d\n", err);
return 1;
}
printf("Displays extended.\n");
return 0;
}
static void print_status(void) {
CGDirectDisplayID onlineDspys[MAX_DISPLAYS] = {0};
CGDirectDisplayID activeDspys[MAX_DISPLAYS] = {0};
CGDisplayCount numOnline = 0, numActive = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, onlineDspys, &numOnline);
CGGetActiveDisplayList(MAX_DISPLAYS, activeDspys, &numActive);
printf("online=%u active=%u %s\n",
numOnline, numActive,
(numOnline > 1 && numActive < numOnline) ? "mirrored" : "extended");
}
// list-modes
// Outputs a JSON array describing every online display and its available modes.
static void list_modes(void) {
CGDirectDisplayID displays[MAX_DISPLAYS];
CGDisplayCount count = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, displays, &count);
CGDirectDisplayID mainID = CGMainDisplayID();
printf("[\n");
for (CGDisplayCount d = 0; d < count; d++) {
CGDirectDisplayID dID = displays[d];
CGDisplayModeRef currentMode = CGDisplayCopyDisplayMode(dID);
size_t curW = CGDisplayModeGetWidth(currentMode);
size_t curH = CGDisplayModeGetHeight(currentMode);
double curR = CGDisplayModeGetRefreshRate(currentMode);
size_t curPW = CGDisplayModeGetPixelWidth(currentMode);
size_t curPH = CGDisplayModeGetPixelHeight(currentMode);
// Include HiDPI duplicate entries so scaled modes are visible.
CFStringRef optKeys[] = { kCGDisplayShowDuplicateLowResolutionModes };
CFBooleanRef optVals[] = { kCFBooleanTrue };
CFDictionaryRef opts = CFDictionaryCreate(NULL,
(const void **)optKeys, (const void **)optVals, 1,
&kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
CFArrayRef allModes = CGDisplayCopyAllDisplayModes(dID, opts);
CFRelease(opts);
CFIndex total = CFArrayGetCount(allModes);
// Collect usable modes first so we know the count for comma handling.
ModeEntry usable[MAX_MODES];
int usable_count = 0;
for (CFIndex m = 0; m < total && usable_count < MAX_MODES; m++) {
CGDisplayModeRef mode = (CGDisplayModeRef)CFArrayGetValueAtIndex(allModes, m);
if (!CGDisplayModeIsUsableForDesktopGUI(mode)) continue;
usable[usable_count++] = (ModeEntry){
.src_index = (long)m,
.w = CGDisplayModeGetWidth(mode),
.h = CGDisplayModeGetHeight(mode),
.pw = CGDisplayModeGetPixelWidth(mode),
.ph = CGDisplayModeGetPixelHeight(mode),
.refresh = CGDisplayModeGetRefreshRate(mode)
};
}
printf(" {\n");
printf(" \"index\": %u,\n", d);
printf(" \"id\": %u,\n", (unsigned int)dID);
printf(" \"is_main\": %s,\n", dID == mainID ? "true" : "false");
printf(" \"current_width\": %zu,\n", curW);
printf(" \"current_height\": %zu,\n", curH);
printf(" \"current_refresh\": %.2f,\n", curR);
printf(" \"current_pixel_width\": %zu,\n", curPW);
printf(" \"current_pixel_height\": %zu,\n", curPH);
printf(" \"modes\": [\n");
for (int i = 0; i < usable_count; i++) {
ModeEntry *e = &usable[i];
int isCurrent = (e->w == curW && e->h == curH &&
e->pw == curPW && e->ph == curPH &&
fabs(e->refresh - curR) < 0.5);
printf(" {\"index\":%ld,\"width\":%zu,\"height\":%zu,"
"\"refresh\":%.2f,\"pixel_width\":%zu,\"pixel_height\":%zu,"
"\"hidpi\":%s,\"is_current\":%s}%s\n",
e->src_index, e->w, e->h, e->refresh, e->pw, e->ph,
(e->pw > e->w || e->ph > e->h) ? "true" : "false",
isCurrent ? "true" : "false",
(i < usable_count - 1) ? "," : "");
}
printf(" ]\n");
printf(" }%s\n", (d < count - 1) ? "," : "");
CGDisplayModeRelease(currentMode);
CFRelease(allModes);
}
printf("]\n");
}
// set-mode
// display_idx : index from list-modes (0 = primary, 1 = first external, ...)
// req_w/h : logical width × height (what macOS calls "looks like X×Y")
// req_refresh : 0 = pick highest available; >0 = must be within 1 Hz
// force_hidpi : 1 = HiDPI only; -1 = non-HiDPI only; 0 = auto
// auto prefers HiDPI on the built-in, non-HiDPI on externals
static int set_mode(int display_idx, size_t req_w, size_t req_h,
double req_refresh, int force_hidpi) {
CGDirectDisplayID displays[MAX_DISPLAYS];
CGDisplayCount count = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, displays, &count);
if (display_idx < 0 || (CGDisplayCount)display_idx >= count) {
fprintf(stderr, "Display index %d out of range (0..%u).\n",
display_idx, count > 0 ? count - 1 : 0);
return 1;
}
CGDirectDisplayID dID = displays[display_idx];
int isMain = (dID == CGMainDisplayID());
CFStringRef optKeys[] = { kCGDisplayShowDuplicateLowResolutionModes };
CFBooleanRef optVals[] = { kCFBooleanTrue };
CFDictionaryRef opts = CFDictionaryCreate(NULL,
(const void **)optKeys, (const void **)optVals, 1,
&kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
CFArrayRef allModes = CGDisplayCopyAllDisplayModes(dID, opts);
CFRelease(opts);
CFIndex total = CFArrayGetCount(allModes);
CGDisplayModeRef bestMode = NULL;
double bestRefresh = -1.0;
int bestScore = -1; // higher = more preferred
for (CFIndex m = 0; m < total; m++) {
CGDisplayModeRef mode = (CGDisplayModeRef)CFArrayGetValueAtIndex(allModes, m);
if (!CGDisplayModeIsUsableForDesktopGUI(mode)) continue;
size_t w = CGDisplayModeGetWidth(mode);
size_t h = CGDisplayModeGetHeight(mode);
if (w != req_w || h != req_h) continue;
double refresh = CGDisplayModeGetRefreshRate(mode);
if (req_refresh > 0.0 && fabs(refresh - req_refresh) > 1.0) continue;
size_t pw = CGDisplayModeGetPixelWidth(mode);
int isHiDPI = (pw > w);
if (force_hidpi == 1 && !isHiDPI) continue;
if (force_hidpi == -1 && isHiDPI) continue;
// Score: prefer HiDPI on main display, non-HiDPI on external.
int score = (force_hidpi == 0)
? ((isMain && isHiDPI) || (!isMain && !isHiDPI)) ? 1 : 0
: 0;
if (bestMode == NULL || score > bestScore ||
(score == bestScore && refresh > bestRefresh)) {
bestMode = mode;
bestRefresh = refresh;
bestScore = score;
}
}
if (!bestMode) {
fprintf(stderr, "No matching mode for display %d: %zux%zu",
display_idx, req_w, req_h);
if (req_refresh > 0.0) fprintf(stderr, " @%.0fHz", req_refresh);
if (force_hidpi == 1) fprintf(stderr, " [HiDPI required]");
if (force_hidpi == -1) fprintf(stderr, " [non-HiDPI required]");
fprintf(stderr, ".\nRun: display_control list-modes\n");
CFRelease(allModes);
return 1;
}
size_t setW = CGDisplayModeGetWidth(bestMode);
size_t setH = CGDisplayModeGetHeight(bestMode);
double setR = CGDisplayModeGetRefreshRate(bestMode);
size_t setPW = CGDisplayModeGetPixelWidth(bestMode);
size_t setPH = CGDisplayModeGetPixelHeight(bestMode);
CGDisplayConfigRef config;
CGError err = CGBeginDisplayConfiguration(&config);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGBeginDisplayConfiguration failed: %d\n", err);
CFRelease(allModes);
return 1;
}
err = CGConfigureDisplayWithDisplayMode(config, dID, bestMode, NULL);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGConfigureDisplayWithDisplayMode failed: %d\n", err);
CGCancelDisplayConfiguration(config);
CFRelease(allModes);
return 1;
}
err = CGCompleteDisplayConfiguration(config, kCGConfigurePermanently);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGCompleteDisplayConfiguration failed: %d\n", err);
CFRelease(allModes);
return 1;
}
printf("Set display %d to %zux%zu @%.0fHz (pixel %zux%zu).\n",
display_idx, setW, setH, setR, setPW, setPH);
CFRelease(allModes);
return 0;
}
// main
int main(int argc, const char * argv[]) {
@autoreleasepool {
if (argc < 2) {
fprintf(stderr, "Usage: display_control <mirror|extend|status|list-modes|set-mode>\n");
return 1;
}
const char *cmd = argv[1];
if (strcmp(cmd, "mirror") == 0) {
return mirror_displays();
} else if (strcmp(cmd, "extend") == 0) {
return extend_displays();
} else if (strcmp(cmd, "status") == 0) {
print_status();
return 0;
} else if (strcmp(cmd, "list-modes") == 0) {
list_modes();
return 0;
} else if (strcmp(cmd, "set-mode") == 0) {
if (argc < 5) {
fprintf(stderr, "Usage: display_control set-mode <display_index> <width> <height> [--refresh <hz>] [--hidpi] [--no-hidpi]\n");
return 1;
}
int display_idx = atoi(argv[2]);
size_t req_w = (size_t)atol(argv[3]);
size_t req_h = (size_t)atol(argv[4]);
double req_refresh = 0.0;
int force_hidpi = 0;
for (int i = 5; i < argc; i++) {
if (strcmp(argv[i], "--refresh") == 0 && i + 1 < argc) {
req_refresh = atof(argv[++i]);
} else if (strcmp(argv[i], "--hidpi") == 0) {
force_hidpi = 1;
} else if (strcmp(argv[i], "--no-hidpi") == 0) {
force_hidpi = -1;
}
}
return set_mode(display_idx, req_w, req_h, req_refresh, force_hidpi);
} else {
fprintf(stderr, "Unknown command: %s\nUsage: display_control <mirror|extend|status|list-modes|set-mode>\n", cmd);
return 1;
}
}
}

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# scripts/remote-build-display-control.sh
# Build the display_control universal binary on a remote Mac via SSH,
# then pull the result back to resources/bin/display_control.
#
# Use this from the Linux workstation when you don't have a Mac locally.
# The target Mac must have Xcode Command Line Tools installed.
#
# USAGE:
# ./scripts/remote-build-display-control.sh # uses default Mac (laptop 01)
# ./scripts/remote-build-display-control.sh 192.168.32.102
#
# The default IP is laptop 01 — the designated build Mac.
# Change DEFAULT_IP below if Xcode moves to a different laptop.
#
# SSH key must already be installed on the target:
# ssh-copy-id "speaker ready"@192.168.32.101
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SRC="$SCRIPT_DIR/display_control.m"
OUT_BIN="$REPO_ROOT/resources/bin/display_control"
SSH_USER="speaker ready"
DEFAULT_IP="192.168.32.101"
IP="${1:-$DEFAULT_IP}"
REMOTE_TMP="/tmp/ae_display_control_build"
echo "═══════════════════════════════════════════════"
echo " Remote build: display_control"
echo " Build Mac: $SSH_USER @ $IP"
echo "═══════════════════════════════════════════════"
# ── Step 1: Copy source to remote ────────────────────────────────────────────
echo ""
echo "Step 1/4 — Copying source to $IP..."
ssh "$SSH_USER@$IP" "mkdir -p $REMOTE_TMP"
ssh "$SSH_USER@$IP" "cat > $REMOTE_TMP/display_control.m" < "$SRC"
# ── Step 2: Build universal binary on remote ──────────────────────────────────
echo ""
echo "Step 2/4 — Building on $IP..."
ssh "$SSH_USER@$IP" bash << 'ENDSSH'
set -e
REMOTE_TMP="/tmp/ae_display_control_build"
SRC="$REMOTE_TMP/display_control.m"
TMP_X86="$REMOTE_TMP/display_control_x86_64"
TMP_ARM="$REMOTE_TMP/display_control_arm64"
OUT="$REMOTE_TMP/display_control"
echo " Compiling x86_64..."
clang -arch x86_64 -framework Cocoa -framework Carbon -o "$TMP_X86" "$SRC"
echo " Compiling arm64..."
clang -arch arm64 -framework Cocoa -framework Carbon -o "$TMP_ARM" "$SRC"
echo " Linking universal binary..."
lipo -create -output "$OUT" "$TMP_X86" "$TMP_ARM"
rm "$TMP_X86" "$TMP_ARM"
chmod +x "$OUT"
echo " Archs: $(lipo -archs "$OUT")"
ENDSSH
# ── Step 3: Pull binary back ──────────────────────────────────────────────────
echo ""
echo "Step 3/4 — Pulling binary to resources/bin/..."
mkdir -p "$(dirname "$OUT_BIN")"
ssh "$SSH_USER@$IP" "cat $REMOTE_TMP/display_control" > "$OUT_BIN"
chmod +x "$OUT_BIN"
# ── Step 4: Clean up remote ───────────────────────────────────────────────────
echo ""
echo "Step 4/4 — Cleaning up remote..."
ssh "$SSH_USER@$IP" "rm -rf $REMOTE_TMP"
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════"
echo " Built: $OUT_BIN"
file "$OUT_BIN"
echo ""
echo " If both archs shown above, you're good:"
echo " git add resources/bin/display_control"
echo " git commit -m \"build: update display_control binary (universal)\""
echo "═══════════════════════════════════════════════"

View File

@@ -1,4 +1,4 @@
import { ipcMain, shell } from 'electron';
import { ipcMain } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -100,7 +100,7 @@ export function registerFileHandlers() {
}
});
ipcMain.handle('native:launch-from-cache', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2, script_template = null }) => {
ipcMain.handle('native:launch-from-cache', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2, native_template = null }) => {
try {
const source = get_organized_hashed_path(cache_root, hash, hash_prefix_length);
const expanded_temp = expandPath(temp_root);
@@ -117,105 +117,41 @@ export function registerFileHandlers() {
// 1. Copy the file to temp folder with original name
fs.copyFileSync(source, target);
// 2a. Data-driven script override (no rebuild needed for script changes).
// Set via event_device.data_json.launch_scripts or $events_loc.launcher.launch_scripts.
// 2a. Data-driven launcher template (no rebuild needed for config changes).
// Svelte resolves a Launch Profile to a single native_template string.
// Format: AppleScript string with {{path}} placeholder, OR "shell:<cmd> {{path}}"
if (script_template) {
const resolved = (script_template as string).replace(/\{\{path\}\}/g, target);
if (resolved.startsWith('shell:')) {
const cmd = resolved.slice(6).trim();
console.log(`Native: Running custom shell script for ${filename}`);
return new Promise((resolve_fn) => {
exec(cmd, (err, stdout, stderr) => {
if (err) resolve_fn({ success: false, error: err.message, stderr: stderr.trim() });
else resolve_fn({ success: true, stdout: stdout.trim() });
});
});
} else {
// Treat as AppleScript — write to temp .scpt file (same hardened approach used below)
console.log(`Native: Running custom AppleScript for ${filename}`);
const tmp_script_path = path.join(os.tmpdir(), `ae_launch_${Date.now()}.scpt`);
return new Promise((resolve_fn) => {
try {
fs.writeFileSync(tmp_script_path, resolved.trim());
} catch (e: any) {
resolve_fn({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` });
return;
}
exec(`osascript "${tmp_script_path}"`, (err) => {
try { fs.unlinkSync(tmp_script_path); } catch {}
if (err) resolve_fn({ success: false, error: err.message });
else resolve_fn({ success: true });
});
});
}
if (!native_template) {
return { success: false, error: 'No native template configured for this file' };
}
// 2b. Determine file type (legacy hardcoded launch logic — used when no script_template provided)
const ext = path.extname(filename).toLowerCase().replace('.', '');
const is_pres = ['pptx', 'ppt', 'key', 'pdf', 'odp'].includes(ext);
// 3. Hardcoded launch (legacy — still the default when no script_template is configured)
if (is_pres) {
if (os.platform() === 'linux') {
console.log(`Native: Launching LibreOffice (--impress) for ${target}`);
return new Promise((resolve) => {
exec(`libreoffice --impress "${target}"`, (err) => {
if (err) resolve({ success: false, error: err.message });
else resolve({ success: true });
});
const resolved = native_template.replace(/\{\{path\}\}/g, target);
if (resolved.startsWith('shell:')) {
const cmd = resolved.slice(6).trim();
console.log(`Native: Running custom shell template for ${filename}`);
return new Promise((resolve_fn) => {
exec(cmd, (err, stdout, stderr) => {
if (err) resolve_fn({ success: false, error: err.message, stderr: stderr.trim() });
else resolve_fn({ success: true, stdout: stdout.trim() });
});
}
if (os.platform() === 'darwin') {
let script = '';
if (ext === 'key') {
script = `
tell application "Keynote"
activate
open (POSIX file "${target}")
delay 1
start (front document)
end tell
`;
} else if (ext === 'pptx' || ext === 'ppt') {
script = `
tell application "Microsoft PowerPoint"
activate
open (POSIX file "${target}")
delay 3
end tell
tell application "System Events"
keystroke return using command down
end tell
`;
}
if (script) {
console.log(`Native: Launching ${ext} via AppleScript for ${target}`);
// Write to a temp .scpt file instead of passing via -e flag.
// The -e approach breaks on multi-line scripts and paths with spaces or quotes.
const tmp_script_path = path.join(os.tmpdir(), `ae_launch_${Date.now()}.scpt`);
return new Promise((resolve) => {
try {
fs.writeFileSync(tmp_script_path, script.trim());
} catch (e: any) {
resolve({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` });
return;
}
exec(`osascript "${tmp_script_path}"`, (err) => {
try { fs.unlinkSync(tmp_script_path); } catch {}
if (err) resolve({ success: false, error: err.message });
else resolve({ success: true });
});
});
}
}
});
}
// 4. Default Fallback
await shell.openPath(target);
return { success: true };
console.log(`Native: Running custom AppleScript template for ${filename}`);
const tmp_script_path = path.join(os.tmpdir(), `ae_launch_${Date.now()}.scpt`);
return new Promise((resolve_fn) => {
try {
fs.writeFileSync(tmp_script_path, resolved.trim());
} catch (e: any) {
resolve_fn({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` });
return;
}
exec(`osascript "${tmp_script_path}"`, (err) => {
try { fs.unlinkSync(tmp_script_path); } catch {}
if (err) resolve_fn({ success: false, error: err.message });
else resolve_fn({ success: true });
});
});
} catch (error: any) {
return { success: false, error: error.message };
}
@@ -226,7 +162,7 @@ export function registerFileHandlers() {
// run_osascript, run_cmd, open_local_file, etc.
//
// This is the preferred building block for custom launch flows. Use launch_from_cache
// when the built-in hardcoded logic is sufficient; use copy_from_cache_to_temp when
// when Svelte has already resolved a native template; use copy_from_cache_to_temp when
// you want full control over what happens after the file lands in temp.
ipcMain.handle('native:copy-from-cache-to-temp', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2 }) => {
try {

View File

@@ -19,8 +19,8 @@ async function createWindow() {
}
mainWindow = new BrowserWindow({
width: 1600,
height: 900,
width: 1280,
height: 800,
title: 'OSIT Aether Launcher (Native)',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
@@ -29,6 +29,10 @@ async function createWindow() {
},
});
// Let the native window manager maximize — more reliable than using screen.workArea,
// which Electron under-reports on KDE and other Linux DEs.
mainWindow.maximize();
let targetUrl = 'http://demo.localhost:5173';
if (cachedFullConfig && cachedFullConfig.native_device) {
const device = cachedFullConfig.native_device;
@@ -55,15 +59,28 @@ registerShellHandlers();
registerFileHandlers();
registerSystemHandlers();
app.on('ready', createWindow);
// Single instance lock — if another instance is already running, focus it and quit.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('ready', createWindow);
app.on('activate', () => {
if (mainWindow === null) createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (mainWindow === null) createWindow();
});
}
ipcMain.handle('get-seed-config', async () => cachedSeed || await loadSeedConfig());
ipcMain.handle('get-device-config', async () => cachedFullConfig);

View File

@@ -171,65 +171,115 @@ end tell
ipcMain.handle('native:list-tools', async () => {
return [
// --- Config & Info ---
{
name: 'get_device_config',
description: 'Returns hydrated device config injected at startup from seed.json + API.',
params: {}
},
{
name: 'get_device_info',
description: 'Returns OS metadata: platform, hostname, IPs, CPU count, free RAM, home/tmp paths.',
params: {}
},
// --- File Cache ---
{
name: 'check_cache',
description: 'Checks if a file exists in the hashed cache. verify_hash:true re-hashes to confirm integrity.',
params: { cache_root: 'string', hash: 'string', hash_prefix_length: 'number (optional, default 2)', verify_hash: 'boolean (optional)' }
},
{
name: 'download_to_cache',
description: 'Streams a file from the API into the hashed cache with SHA-256 integrity check. Cleans stale .tmp files older than 5 min.',
params: { url: 'string', cache_root: 'string', hash: 'string', api_key: 'string', account_id: 'string', hash_prefix_length: 'number (optional, default 2)' }
},
{
name: 'copy_from_cache_to_temp',
description: 'Preferred primitive. Copies a cached file to temp with its original filename. Returns { success, path }. Caller decides what to do next.',
params: { cache_root: 'string', hash: 'string', temp_root: 'string', filename: 'string', hash_prefix_length: 'number (optional, default 2)' }
},
{
name: 'launch_from_cache',
description: 'Combines copy_from_cache_to_temp + execute. Runs native_template after copying — AppleScript string with {{path}} placeholder, or "shell:<cmd>" prefix. Returns error if native_template is null.',
params: { cache_root: 'string', hash: 'string', temp_root: 'string', filename: 'string', hash_prefix_length: 'number (optional)', native_template: 'string | null' }
},
// --- Shell & OS ---
{
name: 'open_folder',
description: 'Opens a directory in the OS file explorer (Finder/Files/Explorer).',
description: 'Opens a directory in the OS file explorer (Finder on macOS).',
params: { path: 'string' }
},
{
name: 'run_cmd',
description: 'Executes an asynchronous shell command with a timeout.',
params: { cmd: 'string', timeout: 'number (optional)' }
description: 'Async shell command execution with timeout.',
params: { cmd: 'string', timeout: 'number (optional, default 30000ms)' }
},
{
name: 'run_cmd_sync',
description: 'Executes a synchronous shell command.',
description: 'Synchronous shell command execution.',
params: { cmd: 'string' }
},
{
name: 'run_osascript',
description: 'Executes a raw AppleScript string (macOS only).',
description: 'Hardened AppleScript executor — writes to temp .scpt file, handles multi-line scripts and paths with special characters. macOS only.',
params: { script: 'string' }
},
{
name: 'kill_processes',
description: 'Forcefully terminates processes by name.',
description: 'Terminates processes by name. macOS/Linux: pkill -f. Windows: taskkill /F.',
params: { process_name_li: 'string[]' }
},
{
name: 'open_local_file_v2',
description: 'Opens a local file using the default OS handler.',
params: { filePath: 'string' }
description: 'Opens a file with its default OS application via shell.openPath.',
params: { path: 'string' }
},
{
name: 'open_external',
description: 'Opens a URL in Chrome, Firefox, or the system default browser.',
params: { url: 'string', app: 'chrome | firefox | default (optional)' }
},
// --- Presentations ---
{
name: 'launch_presentation',
description: 'Phase 5: Specialized launcher for PowerPoint, Keynote, and LibreOffice with auto-focus.',
params: { path: 'string', app: 'default|powerpoint|keynote' }
description: 'Platform-aware launcher for PowerPoint, Keynote, LibreOffice. Resolves [home]/[tmp] placeholders. Hardened AppleScript (2026-05-11). Prefer copy_from_cache_to_temp + run_osascript for new flows.',
params: { path: 'string', app: 'default | powerpoint | keynote (optional)', os_platform: 'string (optional)' }
},
{
name: 'control_presentation',
description: 'Phase 5: Remote navigation for active slideshows.',
params: { app: 'powerpoint|keynote', action: 'next|prev|start|stop' }
description: 'Slide navigation for active PowerPoint or Keynote via AppleScript. macOS only.',
params: { app: 'powerpoint | keynote', action: 'next | prev | start | stop' }
},
// --- System Management ---
{
name: 'set_wallpaper',
description: 'Sets desktop wallpaper. Downloads from url (cached to ~/Library/Caches/OSIT/wallpaper/) or applies local path. url_external targets projector/second display separately. macOS only in production.',
params: { path: 'string (optional)', url: 'string (optional)', url_external: 'string (optional)', display: 'all | primary | external (optional, default all)', api_key: 'string (optional)', account_id: 'string (optional)' }
},
{
name: 'check_cache',
description: 'Checks if a file exists in the local organized cache.',
params: { cache_root: 'string', hash: 'string', hash_prefix_length: 'number' }
name: 'set_display_layout',
description: 'Mirror or extend displays via bundled displayplacer. macOS only. Auto-detects displays when no configStr given; configStr is an optional manual override.',
params: { mode: 'mirror | extend', configStr: 'string (optional)' }
},
{
name: 'download_to_cache',
description: 'Downloads a file from the API directly into the native cache.',
params: { url: 'string', cache_root: 'string', hash: 'string', api_key: 'string', account_id: 'string' }
name: 'window_control',
description: 'Electron window management.',
params: { action: 'maximize | unmaximize | minimize | restore | close | fullscreen | kiosk | devtools | reload', value: 'boolean (optional, used by fullscreen/kiosk/devtools)' }
},
{
name: 'launch_from_cache',
description: 'Atomic operation: Copies file from cache to temp with original name and launches via specialized handler.',
params: { cache_root: 'string', hash: 'string', temp_root: 'string', filename: 'string' }
name: 'power_control',
description: 'Shutdown, reboot, or sleep the host machine. macOS + Linux. May require sudo for shutdown/reboot.',
params: { action: 'shutdown | reboot | sleep' }
},
{
name: 'get_device_info',
description: 'Returns hardware and OS metadata (CPUs, RAM, IP addresses, Hostname).',
params: {}
name: 'manage_recording',
description: 'Screen recording via bundled aperture binary. macOS only.',
params: { action: 'start | stop | status', options: '{ fps?, audioDeviceId?, output? } (optional)' }
},
{
name: 'update_app',
description: 'STUB: Downloads update package but does not install. Not functional.',
params: { source: 'url | file', url: 'string (optional)', path: 'string (optional)' }
}
];
});

View File

@@ -23,7 +23,7 @@ const runExec = (cmd: string): Promise<{ success: boolean; stdout?: string; stde
let recordingProcess: any = null;
export function registerSystemHandlers() {
// 1. Window Control
ipcMain.handle('native:window-control', async (event, { action, value }) => {
const win = BrowserWindow.fromWebContents(event.sender);
@@ -35,7 +35,7 @@ export function registerSystemHandlers() {
case 'minimize': win.minimize(); break;
case 'restore': win.restore(); break;
case 'close': win.close(); break;
case 'devtools':
case 'devtools':
if (value) win.webContents.openDevTools();
else win.webContents.closeDevTools();
break;
@@ -48,18 +48,173 @@ export function registerSystemHandlers() {
});
// 2. Set Wallpaper
ipcMain.handle('native:set-wallpaper', async (event, { path: imagePath }) => {
const cleanPath = expandPath(imagePath);
if (!fs.existsSync(cleanPath)) return { success: false, error: 'Image file not found' };
// Supports local path OR URL download. URL images are saved to a stable cache dir
// so macOS can reference them persistently after reboot.
// display: 'all' (default) | 'primary' (built-in) | 'external' (projector/second screen)
// url_external: optional second URL for the external display only.
ipcMain.handle('native:set-wallpaper', async (event, {
path: imagePath,
url,
url_external,
display = 'all',
api_key,
account_id
}: {
path?: string;
url?: string;
url_external?: string;
display?: 'all' | 'primary' | 'external';
api_key?: string;
account_id?: string;
}) => {
// Cache dir: ~/Library/Caches/OSIT/wallpaper on macOS, ~/.cache/osit/wallpaper on Linux.
// Using a stable path means macOS keeps the reference across reboots.
const wallpaper_cache_dir = os.platform() === 'darwin'
? path.join(os.homedir(), 'Library', 'Caches', 'OSIT', 'wallpaper')
: path.join(os.homedir(), '.cache', 'osit', 'wallpaper');
async function download_wallpaper_image(image_url: string, basename: string): Promise<{ success: boolean; path?: string; error?: string }> {
if (!fs.existsSync(wallpaper_cache_dir)) {
fs.mkdirSync(wallpaper_cache_dir, { recursive: true });
}
// Infer extension from URL path, fall back to .jpg
let ext = '.jpg';
try {
const url_path = new URL(image_url).pathname;
const inferred = path.extname(url_path).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp'].includes(inferred)) {
ext = inferred === '.jpeg' ? '.jpg' : inferred;
}
} catch {}
const dest_path = path.join(wallpaper_cache_dir, basename + ext);
const headers: Record<string, string> = {};
if (api_key) headers['x-aether-api-key'] = api_key;
if (account_id) headers['x-account-id'] = account_id;
try {
const response = await axios({ method: 'get', url: image_url, responseType: 'stream', headers });
const content_type = (response.headers['content-type'] ?? '') as string;
if (!content_type.startsWith('image/')) {
response.data.destroy();
return { success: false, error: `URL did not return an image (Content-Type: ${content_type})` };
}
const writer = fs.createWriteStream(dest_path);
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
const file_size = fs.statSync(dest_path).size;
if (file_size === 0) {
try { fs.unlinkSync(dest_path); } catch {}
return { success: false, error: 'Wallpaper download incomplete (0 bytes)' };
}
return { success: true, path: dest_path };
} catch (e: any) {
return { success: false, error: `Wallpaper download failed: ${e.message}` };
}
}
// HARDENED: write AppleScript to a temp file, same pattern as native:run-osascript.
// The old osascript -e approach breaks on paths with spaces or special characters.
async function apply_mac_wallpaper(img_path: string, display_target: 'all' | 'primary' | 'external'): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const escaped = img_path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
let script: string;
if (display_target === 'primary') {
script = `tell application "System Events"\n\ttell desktop 1\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
} else if (display_target === 'external') {
script = `tell application "System Events"\n\ttell desktop 2\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
} else {
script = `tell application "System Events"\n\ttell every desktop\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
}
const script_path = path.join(os.tmpdir(), `ae_wallpaper_${Date.now()}.scpt`);
fs.writeFileSync(script_path, script, 'utf-8');
try {
return await runExec(`osascript "${script_path}"`);
} finally {
try { fs.unlinkSync(script_path); } catch {}
}
}
if (os.platform() === 'darwin') {
const script = `tell application "System Events" to set picture of every desktop to "${cleanPath}"`;
return await runExec(`osascript -e '${script}'`);
} else if (os.platform() === 'linux') {
// Gnome/Ubuntu default
return await runExec(`gsettings set org.gnome.desktop.background picture-uri "file://${cleanPath}"`);
// Resolve primary image path
let primary_path: string | null = null;
if (imagePath) {
const clean = expandPath(imagePath);
if (!fs.existsSync(clean)) return { success: false, error: 'Image file not found' };
primary_path = clean;
} else if (url) {
const result = await download_wallpaper_image(url, 'wallpaper_primary');
if (!result.success || !result.path) return { success: false, error: result.error };
primary_path = result.path;
}
if (!primary_path && url_external && display === 'external') {
const ext_result = await download_wallpaper_image(url_external, 'wallpaper_external');
if (!ext_result.success || !ext_result.path) return { success: false, error: ext_result.error };
return await apply_mac_wallpaper(ext_result.path, 'external');
}
if (!primary_path) return { success: false, error: 'No image source provided' };
if (url_external) {
// Different images for each display: set primary display first, then external
const primary_result = await apply_mac_wallpaper(primary_path, 'primary');
if (!primary_result.success) return primary_result;
const ext_result = await download_wallpaper_image(url_external, 'wallpaper_external');
if (!ext_result.success || !ext_result.path) return { success: false, error: ext_result.error };
return await apply_mac_wallpaper(ext_result.path, 'external');
} else {
return await apply_mac_wallpaper(primary_path, display);
}
}
if (os.platform() === 'linux') {
// Dev test mode: never touch the desktop on Linux. Running gsettings during
// development would reset the dev workstation monitors on every test cycle.
// Return what would have run so the Svelte side can show a debug popup.
const would_run: string[] = [];
const cache_dir = wallpaper_cache_dir;
if (!imagePath && !url && !url_external) {
return { success: false, error: 'No image source provided' };
}
if (imagePath) {
const clean = expandPath(imagePath);
if (!fs.existsSync(clean)) return { success: false, error: 'Image file not found' };
}
if (url) would_run.push(`download: ${url}\n → ${path.join(cache_dir, 'wallpaper_primary.jpg')}`);
if (url_external) would_run.push(`download: ${url_external}\n → ${path.join(cache_dir, 'wallpaper_external.jpg')}`);
if (imagePath) {
would_run.push(`gsettings set org.gnome.desktop.background picture-uri "file://${expandPath(imagePath)}"`);
} else if (url) {
would_run.push(`gsettings set org.gnome.desktop.background picture-uri "file://${path.join(cache_dir, 'wallpaper_primary.jpg')}"`);
}
if (url_external) {
would_run.push(`(external display: gsettings has no per-display wallpaper support)`);
}
return {
success: true,
linux_test_mode: true,
platform: 'linux',
display,
url: url ?? null,
url_external: url_external ?? null,
would_run
};
}
return { success: false, error: 'Platform not supported' };
});
@@ -81,8 +236,8 @@ export function registerSystemHandlers() {
}
if (!cmd) return { success: false, error: 'Action not supported' };
// NOTE: These commands often require root.
// NOTE: These commands often require root.
// For a kiosk, you might configure sudoers to allow this specific command without password.
return await runExec(cmd);
});
@@ -102,7 +257,7 @@ export function registerSystemHandlers() {
return await runExec(`firefox "${url}"`);
}
}
// Default system handler
await shell.openExternal(url);
return { success: true };
@@ -111,30 +266,30 @@ export function registerSystemHandlers() {
// 5. Manage Recording (Aperture Wrapper)
ipcMain.handle('native:manage-recording', async (event, { action, options }) => {
if (os.platform() !== 'darwin') return { success: false, error: 'Recording only supported on macOS' };
// Path to bundled aperture binary
// In dev: ./resources/bin/aperture
// In prod: process.resourcesPath/bin/aperture
const binPath = app.isPackaged
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin', 'aperture')
: path.join(__dirname, '../../resources/bin/aperture'); // Adjust based on structure
if (action === 'start') {
if (recordingProcess) return { success: false, error: 'Recording already in progress' };
const { fps = 30, audioDeviceId, output } = options || {};
const cleanOutput = expandPath(output || '~/tmp/recording.mp4');
const args = ['run', '--fps', fps, '--output', cleanOutput];
if (audioDeviceId) args.push('--audio-device-id', audioDeviceId);
// Spawn process
// Note: aperture is a CLI tool. We might need 'aperture' node package or the binary.
// Assuming binary usage here.
try {
console.log(`Starting recording: ${binPath} ${args.join(' ')}`);
recordingProcess = spawn(binPath, args);
recordingProcess.on('error', (err: any) => {
console.error('Recording error:', err);
recordingProcess = null;
@@ -152,7 +307,7 @@ export function registerSystemHandlers() {
} else if (action === 'stop') {
if (!recordingProcess) return { success: false, error: 'No recording in progress' };
recordingProcess.kill('SIGINT'); // Send interrupt to stop cleanly
recordingProcess = null;
return { success: true };
@@ -163,35 +318,156 @@ export function registerSystemHandlers() {
return { success: false, error: 'Unknown action' };
});
// 6. Set Display Layout (Displayplacer)
// 6. Set Display Layout
// Primary path: display_control (native CoreGraphics, no external deps).
// Build from scripts/display_control.m via scripts/build-display-control.sh on a Mac.
// Commit the resulting resources/bin/display_control binary to the repo.
// Fallback: displayplacer (requires: brew install displayplacer on each venue Mac).
// Also supports per-device configStr override (displayplacer format).
ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => {
if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' };
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin', 'displayplacer')
: path.join(__dirname, '../../resources/bin/displayplacer');
let cmd = '';
// Primary: display_control — native CoreGraphics, no Homebrew dependency.
// Derived from OSIT MasterKey app (LegacyUtilities.m). No configStr support needed —
// CoreGraphics auto-detects all connected displays.
const dc_bin = app.isPackaged
? path.join(process.resourcesPath, 'bin', 'display_control')
: path.join(__dirname, '../../resources/bin/display_control');
if (fs.existsSync(dc_bin) && !configStr) {
if (mode !== 'mirror' && mode !== 'extend') {
return { success: false, error: `Unsupported display mode: ${mode}` };
}
return await runExec(`"${dc_bin}" ${mode}`);
}
// Fallback: displayplacer — required when display_control binary is not built yet,
// or when a per-device configStr override is set (displayplacer-format string from event_device.data_json).
// Install: brew install displayplacer
const _dp_candidates = app.isPackaged
? [path.join(process.resourcesPath, 'bin', 'displayplacer')]
: [
path.join(__dirname, '../../resources/bin/displayplacer'),
'/opt/homebrew/bin/displayplacer', // Apple Silicon Homebrew
'/usr/local/bin/displayplacer', // Intel Homebrew
];
const dpPath = _dp_candidates.find(p => fs.existsSync(p)) ?? _dp_candidates[0];
// Explicit configStr takes priority — allows manual per-device override.
if (configStr) {
return await runExec(`"${dpPath}" ${configStr}`);
}
// Auto-detect via `displayplacer list`.
const list_result = await runExec(`"${dpPath}" list`);
if (!list_result.success || !list_result.stdout) {
return { success: false, error: `displayplacer not available. Build display_control from scripts/build-display-control.sh or run: brew install displayplacer` };
}
// The command line looks like: displayplacer "id:xxx res:... origin:(0,0) ..." "id:yyy ..."
const cmd_line = list_result.stdout.split('\n').find(l => l.trim().startsWith('displayplacer "'));
if (!cmd_line) {
return { success: false, error: 'Only one display connected or displayplacer list output unrecognised' };
}
const display_strings = [...cmd_line.matchAll(/"([^"]+)"/g)].map(m => m[1]);
if (display_strings.length < 2) {
return { success: false, error: 'Only one display found; cannot change layout' };
}
if (mode === 'mirror') {
// This usually requires querying current IDs, which is complex.
// If configStr is provided (output of 'displayplacer list'), use it.
if (configStr) {
cmd = `"${binPath}" ${configStr}`;
} else {
return { success: false, error: 'Config string required for now' };
}
} else if (mode === 'extend') {
if (configStr) {
cmd = `"${binPath}" ${configStr}`;
}
const primary_id_match = display_strings[0].match(/\bid:([^\s]+)/);
if (!primary_id_match) {
return { success: false, error: 'Could not parse primary display ID from displayplacer output' };
}
const primary_id = primary_id_match[1];
const mirror_args = display_strings.map((s, i) => {
if (i === 0) return `"${s}"`;
const without_existing_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim();
return `"${without_existing_mirror} mirror_of_display:${primary_id}"`;
}).join(' ');
return await runExec(`"${dpPath}" ${mirror_args}`);
}
if (cmd) {
return await runExec(cmd);
if (mode === 'extend') {
const any_mirrored = display_strings.some(s => /\bmirror_of_display:\S+/.test(s));
if (!any_mirrored) {
return await runExec(`"${dpPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`);
}
let x_offset = 0;
const extend_args = display_strings.map((s) => {
const without_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim();
const res_match = without_mirror.match(/\bres:(\d+)x\d+/);
const width = res_match ? parseInt(res_match[1]) : 1920;
const updated = without_mirror.replace(/\borigin:\([^)]+\)/, `origin:(${x_offset},0)`);
x_offset += width;
return `"${updated}"`;
}).join(' ');
return await runExec(`"${dpPath}" ${extend_args}`);
}
return { success: false, error: 'Invalid mode or missing config' };
return { success: false, error: `Unsupported display mode: ${mode}` };
});
// 6b. List Display Modes
ipcMain.handle('native:list-display-modes', async () => {
if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' };
const dc_bin = app.isPackaged
? path.join(process.resourcesPath, 'bin', 'display_control')
: path.join(__dirname, '../../resources/bin/display_control');
if (!fs.existsSync(dc_bin)) {
return { success: false, error: 'display_control binary not found. Build via scripts/build-display-control.sh.' };
}
const result = await runExec(`"${dc_bin}" list-modes`);
if (!result.success || !result.stdout) {
return { success: false, error: result.error ?? 'list-modes returned no output' };
}
try {
const displays = JSON.parse(result.stdout);
return { success: true, displays };
} catch (e: any) {
return { success: false, error: `Failed to parse list-modes output: ${e.message}`, raw: result.stdout };
}
});
// 6c. Set Display Mode
ipcMain.handle('native:set-display-mode', async (event, {
display_index,
width,
height,
refresh_rate,
hidpi
}: {
display_index: number;
width: number;
height: number;
refresh_rate?: number;
hidpi?: boolean | null;
}) => {
if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' };
const dc_bin = app.isPackaged
? path.join(process.resourcesPath, 'bin', 'display_control')
: path.join(__dirname, '../../resources/bin/display_control');
if (!fs.existsSync(dc_bin)) {
return { success: false, error: 'display_control binary not found. Build via scripts/build-display-control.sh.' };
}
let cmd = `"${dc_bin}" set-mode ${display_index} ${width} ${height}`;
if (refresh_rate) cmd += ` --refresh ${refresh_rate}`;
if (hidpi === true) cmd += ' --hidpi';
if (hidpi === false) cmd += ' --no-hidpi';
return await runExec(cmd);
});
// 7. Update App
@@ -210,10 +486,10 @@ export function registerSystemHandlers() {
url: url,
responseType: 'stream'
});
const writer = fs.createWriteStream(destPath);
response.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on('finish', () => resolve(true));
writer.on('error', reject);
@@ -237,9 +513,9 @@ export function registerSystemHandlers() {
// Real implementation depends on OS and packaging format.
// macOS: Mount DMG, copy .app to /Applications? Or Unzip .app?
// Linux: chmod +x AppImage and move?
console.log(`Ready to install update from: ${updateFile}`);
// For now, just return success so the UI knows we "downloaded" it.
return { success: true, message: 'Update downloaded/located. Installation logic requires packaging specifics.', downloadedPath: updateFile };
});

View File

@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('aetherNative', {
get_device_config: () => ipcRenderer.invoke('get-device-config'),
get_jwt: () => ipcRenderer.invoke('get-jwt'),
get_device_info: () => ipcRenderer.invoke('get-device-info'),
open_folder: (path: string) => ipcRenderer.invoke('native:open-folder', path),
run_cmd: (args: any) => ipcRenderer.invoke('native:run-cmd', args),
run_cmd_sync: (args: any) => ipcRenderer.invoke('native:run-cmd-sync', args),
@@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld('aetherNative', {
window_control: (args: any) => ipcRenderer.invoke('native:window-control', args),
manage_recording: (args: any) => ipcRenderer.invoke('native:manage-recording', args),
set_display_layout: (args: any) => ipcRenderer.invoke('native:set-display-layout', args),
list_display_modes: () => ipcRenderer.invoke('native:list-display-modes'),
set_display_mode: (args: any) => ipcRenderer.invoke('native:set-display-mode', args),
power_control: (args: any) => ipcRenderer.invoke('native:power-control', args),
open_external: (args: any) => ipcRenderer.invoke('native:open-external', args),
});

View File

@@ -1,3 +1,26 @@
export interface DisplayMode {
index: number;
width: number;
height: number;
refresh: number;
pixel_width: number;
pixel_height: number;
hidpi: boolean;
is_current: boolean;
}
export interface DisplayInfo {
index: number;
id: number;
is_main: boolean;
current_width: number;
current_height: number;
current_refresh: number;
current_pixel_width: number;
current_pixel_height: number;
modes: DisplayMode[];
}
export interface SeedConfig {
event_device_id: string;
primary_api_base_url: string;
@@ -11,7 +34,7 @@ export interface AetherNativeBridge {
get_device_config: () => Promise<any>;
get_jwt: () => Promise<string | null>;
get_device_info: () => Promise<any>;
// Shell Handlers
open_folder: (path: string) => Promise<{success: boolean, error?: string}>;
run_cmd: (args: {cmd: string, timeout?: number}) => Promise<{success: boolean, stdout: string, stderr: string, error?: string}>;
@@ -23,12 +46,24 @@ export interface AetherNativeBridge {
// File/Cache Handlers
check_cache: (args: {cache_root: string, hash: string, hash_prefix_length?: number}) => Promise<boolean>;
download_to_cache: (args: {url: string, cache_root: string, hash: string, api_key: string, account_id?: string, hash_prefix_length?: number}) => Promise<{success: boolean, error?: string}>;
launch_from_cache: (args: {cache_root: string, hash: string, temp_root: string, filename: string, hash_prefix_length?: number}) => Promise<{success: boolean, error?: string}>;
copy_from_cache_to_temp: (args: {cache_root: string, hash: string, temp_root: string, filename: string, hash_prefix_length?: number}) => Promise<{success: boolean, path?: string, error?: string}>;
launch_from_cache: (args: {cache_root: string, hash: string, temp_root: string, filename: string, hash_prefix_length?: number, native_template?: string}) => Promise<{success: boolean, error?: string}>;
// Specialized Presentation Handlers (Phase 5)
launch_presentation: (args: {path: string, app?: string}) => Promise<{success: boolean, error?: string, stdout?: string, stderr?: string}>;
control_presentation: (args: {app: 'powerpoint' | 'keynote', action: 'next' | 'prev' | 'start' | 'stop'}) => Promise<{success: boolean, error?: string, stdout?: string, stderr?: string}>;
// System Handlers (Phase 5)
window_control: (args: {action: 'maximize' | 'unmaximize' | 'minimize' | 'restore' | 'close' | 'devtools' | 'kiosk' | 'fullscreen' | 'reload', value?: boolean}) => Promise<{success: boolean, error?: string}>;
set_wallpaper: (args: {path: string}) => Promise<{success: boolean, error?: string, stdout?: string, stderr?: string}>;
power_control: (args: {action: 'shutdown' | 'reboot' | 'sleep'}) => Promise<{success: boolean, error?: string}>;
open_external: (args: {url: string, app?: 'chrome' | 'firefox'}) => Promise<{success: boolean, error?: string}>;
manage_recording: (args: {action: 'start' | 'stop' | 'status', options?: {fps?: number, audioDeviceId?: string, output?: string}}) => Promise<{success: boolean, isRecording?: boolean, pid?: number, error?: string}>;
set_display_layout: (args: {mode: 'mirror' | 'extend', configStr?: string | null}) => Promise<{success: boolean, error?: string, stdout?: string, stderr?: string}>;
list_display_modes: () => Promise<{success: boolean, displays?: DisplayInfo[], error?: string, raw?: string}>;
set_display_mode: (args: {display_index: number, width: number, height: number, refresh_rate?: number, hidpi?: boolean | null}) => Promise<{success: boolean, stdout?: string, stderr?: string, error?: string}>;
update_app: (args: {source: 'url' | 'file', url?: string, path?: string}) => Promise<{success: boolean, message?: string, downloadedPath?: string, error?: string}>;
// Self-Documentation
list_tools: () => Promise<Array<{name: string, description: string, params: object}>>;
}