Compare commits
30 Commits
9f76d6b7f4
...
v3-electro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e0ebb7c3 | ||
|
|
51db51d991 | ||
|
|
6cddd69891 | ||
|
|
58060aea8a | ||
|
|
54308c6d4a | ||
|
|
b2cd0736df | ||
|
|
a230ff09de | ||
|
|
7199d45719 | ||
|
|
48e24af84e | ||
|
|
cd1cd02bc2 | ||
|
|
0ae0d644a9 | ||
|
|
ce2832a584 | ||
|
|
b6b902ad4a | ||
|
|
3da3b187ec | ||
|
|
86ea73bfbd | ||
|
|
a14c7c7a3f | ||
|
|
9df9d884e5 | ||
|
|
2bf4d7c141 | ||
|
|
e37fd1ddbb | ||
|
|
a1e74829e8 | ||
|
|
2c7b609295 | ||
|
|
1008a55ec3 | ||
|
|
c8fdb8b1e7 | ||
|
|
bb51771dc7 | ||
|
|
53d200f10e | ||
|
|
7693b12aeb | ||
|
|
72d928f907 | ||
|
|
9b98b454fd | ||
|
|
ec29a576d5 | ||
|
|
1f90c819a0 |
131
README.md
131
README.md
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
148
dist/main/file_handlers.js
vendored
148
dist/main/file_handlers.js
vendored
@@ -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 {
|
||||
|
||||
2
dist/main/file_handlers.js.map
vendored
2
dist/main/file_handlers.js.map
vendored
File diff suppressed because one or more lines are too long
39
dist/main/index.js
vendored
39
dist/main/index.js
vendored
@@ -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);
|
||||
|
||||
2
dist/main/index.js.map
vendored
2
dist/main/index.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/main/index.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/main/index.ts"],"names":[],"mappings":"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"}
|
||||
98
dist/main/shell_handlers.js
vendored
98
dist/main/shell_handlers.js
vendored
@@ -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)' }
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
2
dist/main/shell_handlers.js.map
vendored
2
dist/main/shell_handlers.js.map
vendored
File diff suppressed because one or more lines are too long
298
dist/main/system_handlers.js
vendored
298
dist/main/system_handlers.js
vendored
@@ -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 }) => {
|
||||
|
||||
2
dist/main/system_handlers.js.map
vendored
2
dist/main/system_handlers.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/preload/index.js
vendored
2
dist/preload/index.js
vendored
@@ -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),
|
||||
});
|
||||
|
||||
2
dist/preload/index.js.map
vendored
2
dist/preload/index.js.map
vendored
@@ -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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"}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
BIN
resources/bin/display_control
Executable file
Binary file not shown.
53
scripts/build-display-control.sh
Executable file
53
scripts/build-display-control.sh
Executable 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
377
scripts/display_control.m
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
94
scripts/remote-build-display-control.sh
Executable file
94
scripts/remote-build-display-control.sh
Executable 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 "═══════════════════════════════════════════════"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)' }
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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}>>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user