diff --git a/documentation/NATIVE_APP_V3_REWRITE_PLAN.md b/documentation/NATIVE_APP_V3_REWRITE_PLAN.md index de7fdd6a..a6f836ba 100644 --- a/documentation/NATIVE_APP_V3_REWRITE_PLAN.md +++ b/documentation/NATIVE_APP_V3_REWRITE_PLAN.md @@ -11,6 +11,17 @@ The sole purpose of this Native App is to serve as the **AE Events Launcher**. I - Execute OS-level shell commands and scripts (e.g., launching presentation software). - Provide a "Zero-Config" experience for onsite event laptops. +### 0.1 Application Modes +The system must support three distinct operational modes: + +| Mode | Refresh Logic | File Handling | +| --- | --- | --- | +| **Default** | Slower auto-refresh timers. | Standard browser downloads. No local caching. | +| **Onsite** | Faster auto-refresh timers. | Downloads files with modified extensions (e.g., .pptxwin). | +| **Native** | Fastest auto-refresh timers. | Full background pre-caching. Launching from local temp directory with original names. | + +--- + ## 1. Minimalist Configuration Strategy To simplify laptop deployment, we will move away from large local JSON files. @@ -36,6 +47,76 @@ Each laptop will contain a `seed.json`. For development, this is located at `~/s `http://[app_base_url]/events/[event_id]/launcher/[event_location_id]` 5. **Inject:** Config and JWT are injected into the SvelteKit frontend via the Preload script. +### 1.3 Technical Flow: Startup & Background Caching +The system utilizes a multi-layered hydration and synchronization strategy to ensure files are available instantly. + +#### Step 1: Zero-Config Initialization (Main Process) +- **Seed Discovery:** Main process reads `seed.json`. +- **Identity Exchange:** Main process authenticates with the API using the `aether_api_key`. +- **Global Injections:** Once hydrated, the Main process provides the SvelteKit frontend with: + - `native_device`: Full record from `event_device` table (contains timers). + - `aether_api_key`: For authorized background downloads. + - `local_file_cache_path`: Root for the permanent hashed cache. + - `host_file_temp_path`: Root for the operational launch directory. + +#### Step 2: Session-Driven Caching (Renderer Process) +- **View Trigger:** When a user navigates to a session or location, the SvelteKit frontend populates the `event_file_obj_li` store. +- **Sync Cycle:** The `LauncherBackgroundSync` component detects the new file list and: + 1. Extracts all `event_file_id` values. + 2. Fetches full metadata (hashes) from the local Dexie IndexedDB. + 3. Asynchronously checks the Native Cache for each hash. +- **Background Download:** Missing files are downloaded directly to the hashed cache using the authorized Native API Key. +- **Timer Loop:** A background loop runs every `check_event_loop_period` (configurable via API) to ensure the cache stays in sync with server-side changes. + +#### Step 3: Hashed Cache Pattern (Filesystem) +To prevent filename collisions and handle versioning, the permanent cache follows the server's structure: +- **Root:** `[local_file_cache_path]` +- **Subdirectory:** First 2 characters of SHA256 (e.g., `ab/`) +- **Filename:** Full SHA256 with `.file` extension (e.g., `abc123...file`) + +#### Step 4: Safe Handover (Launch Sequence) +When a user clicks "Open", the system follows a non-destructive handover: +1. **Verify:** Confirm hash exists in `local_file_cache_path`. +2. **Prepare:** Copy the hashed file to `host_file_temp_path`. +3. **Restore:** Rename the copy back to its original filename (e.g., `Presentation.pptx`). +4. **Execute:** Trigger OS-level `shell.openPath()` on the temp file. +*This ensures the permanent cache remains clean while the third-party app (PowerPoint, etc.) can operate on a file with a familiar name.* + +### 1.3 Technical Flow: Startup & Background Caching +The system utilizes a multi-layered hydration and synchronization strategy to ensure files are available instantly. + +#### Step 1: Zero-Config Initialization (Main Process) +- **Seed Discovery:** Main process reads `seed.json`. +- **Identity Exchange:** Main process authenticates with the API using the `aether_api_key`. +- **Global Injections:** Once hydrated, the Main process provides the SvelteKit frontend with: + - `native_device`: Full record from `event_device` table (contains timers). + - `aether_api_key`: For authorized background downloads. + - `local_file_cache_path`: Root for the permanent hashed cache. + - `host_file_temp_path`: Root for the operational launch directory. + +#### Step 2: Session-Driven Caching (Renderer Process) +- **View Trigger:** When a user navigates to a session or location, the SvelteKit frontend populates the `event_file_obj_li` store. +- **Sync Cycle:** The `LauncherBackgroundSync` component detects the new file list and: + 1. Extracts all `event_file_id` values. + 2. Fetches full metadata (hashes) from the local Dexie IndexedDB. + 3. Asynchronously checks the Native Cache for each hash. +- **Background Download:** Missing files are downloaded directly to the hashed cache using the authorized Native API Key. +- **Timer Loop:** A background loop runs every `check_event_loop_period` (configurable via API) to ensure the cache stays in sync with server-side changes. + +#### Step 3: Hashed Cache Pattern (Filesystem) +To prevent filename collisions and handle versioning, the permanent cache follows the server's structure: +- **Root:** `[local_file_cache_path]` +- **Subdirectory:** First 2 characters of SHA256 (e.g., `ab/`) +- **Filename:** Full SHA256 with `.file` extension (e.g., `abc123...file`) + +#### Step 4: Safe Handover (Launch Sequence) +When a user clicks "Open", the system follows a non-destructive handover: +1. **Verify:** Confirm hash exists in `local_file_cache_path`. +2. **Prepare:** Copy the hashed file to `host_file_temp_path`. +3. **Restore:** Rename the copy back to its original filename (e.g., `Presentation.pptx`). +4. **Execute:** Trigger OS-level `shell.openPath()` on the temp file. +*This ensures the permanent cache remains clean while the third-party app (PowerPoint, etc.) can operate on a file with a familiar name.* + ## 2. macOS Hardening (Permissions) ... macOS requires explicit user consent for several features. The new app will handle these during the "Splash Screen" phase. diff --git a/src/lib/electron/electron_relay.js b/src/lib/electron/electron_relay.js deleted file mode 100644 index c3d72e3a..00000000 --- a/src/lib/electron/electron_relay.js +++ /dev/null @@ -1,333 +0,0 @@ -/* ### Electron Specific JavaScript ### */ -// import crypto from 'crypto'; -// import {fs} from 'fs'; -// import path from 'path'; -// const crypto = require('crypto'); -// const fs = require('fs'); -// const fs_promises = require('node:fs/promises'); -// const path = require('path'); -// const { ipcRenderer } = require('electron'); - -// function sleep(milliseconds) { -// const date = Date.now(); -// let currentDate = null; -// do { -// currentDate = Date.now(); -// } while (currentDate - date < milliseconds); -// } - -// // exports.check_hash_file_cache should no longer be needed with this. -// // Updated 2022-10-11 -// export let check_hash_file_cache_v2 = async function check_hash_file_cache_v2({local_file_cache_path, hash, check_hash=false}) { -// console.log('*** check_hash_file_cache_v2() ***'); -// console.log(`Host File Cache Path: ${local_file_cache_path}; Hash: ${hash}`); - -// let hash_filename = `${hash}.file`; - -// let subdirectory = hash_filename.substring(0,2); -// let subdirectory_path = path.join(local_file_cache_path, subdirectory); -// if (fs.existsSync(subdirectory_path)) { -// } else { -// console.log(`Hashed file subdirectory not found in cache: ${subdirectory_path}`); -// return null; -// } - -// let hash_file_cache_path = path.join(subdirectory_path, hash_filename); - -// if (fs.existsSync(hash_file_cache_path)) { -// console.log(`Hashed file exists in cache: ${hash_file_cache_path}`); - -// if (check_hash) { -// const file_buffer = fs.readFileSync(hash_file_cache_path); -// const file_hash_sha256 = crypto.createHash('sha256'); -// file_hash_sha256.update(file_buffer); - -// const file_hash_sha256_check = file_hash_sha256.digest('hex'); -// if (file_hash_sha256_check == hash) { -// console.log('File hash match', file_hash_sha256_check); -// } else { -// console.log('File hash does not match', file_hash_sha256_check); -// return false; -// } -// } - -// return true; -// } else { -// console.log(`Hashed file not found in cache: ${hash_file_cache_path}`); -// return null; -// } -// } - -// @ts-nocheck -// Updated 2022-05-07 -export let kill_processes = async function kill_processes({ process_name_li = [] }) { - console.log('*** kill_processes() ***'); - console.log(`Process Name List: ${process_name_li}`); - - let fail_flag = null; - if (process_name_li) { - for (let i = 0; i < process_name_li.length; i++) { - // separate the keys and the values - let process_name = process_name_li[i]; - let signal = null; - - if (process_name == 'osit_aperture_wrapper') { - signal = 'INT'; // INT (interrupt) correctly stops the wrapper - } - - let kill_processes_result = await native_app.kill_processes({ - process_name: process_name, - signal: signal - }); - console.log(kill_processes_result); - if (kill_processes_result) { - console.log('Killed process.'); - // return kill_processes_result; - } else { - console.log('Did not kill process. Something went wrong.'); - fail_flag = true; - // return false; - } - } - } - - return fail_flag; - - // let kill_processes_result = await native_app.kill_processes({process_name: process_name}); - // console.log(kill_processes_result); - // if (kill_processes_result) { - // console.log('Killed process.'); - // return kill_processes_result; - // } else { - // console.log('Did not kill process. Something went wrong.'); - // return false; - // } -}; - -// Updated 2022-05-06 -export let open_local_file = async function open_local_file({ file_path, filename }) { - console.log('*** open_local_file() ***'); - console.log(`File Path: ${file_path}; Filename: ${filename}`); - - console.log( - 'Process: Check local hash file cache, Download hash file to cache, and Open cached hash file after copying to temp directory' - ); - - // let check_local_file_result = await native_app.check_local_file({local_file_path: file_path, filename: filename}); - // console.log(check_local_file_result); - // if (check_local_file_result) { - // console.log('Local file found.'); - // } else { - // console.log('Local file not found. Will not attempt to open.'); - // return false; - // } - - // console.log('Local file file found and ready to be opened.'); - let open_local_file_result = await native_app.open_local_file({ - local_file_path: file_path, - filename: filename - }); - console.log(open_local_file_result); - if (open_local_file_result) { - console.log('Local file was opened.'); - return open_local_file_result; - } else { - console.log('Local file was not opened. Something went wrong.'); - return false; - } -}; - -// exports.open_local_file should no longer be needed with this. -// Updated 2022-10-11 -export let open_local_file_v2 = async function open_local_file_v2({ file_path, filename }) { - console.log('*** open_local_file_v2() ***'); - console.log(`Local File Path: ${file_path}; Filename: ${filename}`); - - console.log( - 'Process: Check local hash file cache, Download hash file to cache, and Open cached hash file after copying to temp directory' - ); - - let open_local_file_result = await ipcRenderer - .invoke('open_local_file', file_path, filename) - .then((result) => { - console.log('IPC open local file finished'); - if (result) { - console.log('Local file was opened.'); - return result; - } else { - console.log('Local file was not opened. Something went wrong.'); - console.log(result); - return false; - } - - console.log(result); - return true; - }); - - return open_local_file_result; -}; - -// // Updated 2022-05-06 -// export let open_hash_file_to_temp = async function open_hash_file_to_temp({local_file_cache_path, hash, host_file_temp_path, filename}) { -// console.log('*** open_hash_file_to_temp() ***'); -// console.log(`Host File Cache Path: ${local_file_cache_path}; Hash: ${hash}; Host File Temp Path: ${host_file_temp_path}; Filename: ${filename}`); - -// console.log('Process: Check local hash file cache, Download hash file to cache, and Open cached hash file after copying to temp directory'); - -// let open_hash_file_to_temp_result = await native_app.open_hash_file_to_temp({local_file_cache_path: local_file_cache_path, hash: hash, host_file_temp_path: host_file_temp_path, filename: filename}); -// console.log(open_hash_file_to_temp_result); -// if (open_hash_file_to_temp_result) { -// console.log('Local hash file was opened from temp directory.'); -// return open_hash_file_to_temp_result; -// } else { -// console.log('Local hash file was not opened from the temp directory. Something went wrong.'); -// return false; -// } -// } - -// // exports.open_hash_file_to_temp should no longer be needed with this. -// // Updated 2022-10-11 -// export let open_hash_file_to_temp_v2 = async function open_hash_file_to_temp_v2({local_file_cache_path, hash, host_file_temp_path, filename}) { -// console.log('*** open_hash_file_to_temp_v2() ***'); -// console.log(`Host File Cache Path: ${local_file_cache_path}; Hash: ${hash}; Host File Temp Path: ${host_file_temp_path}; Filename: ${filename}`); - -// console.log('Process: Check local hash file cache, Download hash file to cache, and Open cached hash file after copying to temp directory'); - -// let subdirectory = hash.substring(0,2); -// let subdirectory_path = path.join(local_file_cache_path, subdirectory); -// if (fs.existsSync(subdirectory_path)) { -// } else { -// console.log(`Hashed file subdirectory not found in cache: ${subdirectory_path}`); -// return null; -// } - -// let hash_filename = hash+'.file'; -// let full_cache_file_path = path.join(subdirectory_path, hash_filename); -// console.log(full_cache_file_path); - -// const file_buffer = fs.readFileSync(full_cache_file_path); -// const file_hash_sha256 = crypto.createHash('sha256'); -// file_hash_sha256.update(file_buffer); - -// const file_hash_sha256_check = file_hash_sha256.digest('hex'); -// if (file_hash_sha256_check == hash) { -// console.log('File hash match', file_hash_sha256_check); -// } else { -// console.log('File hash does not match', file_hash_sha256_check); -// // await setTimeout(async () => {console.log('Done waiting????'); open_file_clicked = false;}, 5000); -// // sleep(6000); -// // console.log('???????? WAITED X SECONDS ????????'); -// return false; -// } - -// let open_hash_file_to_temp_result = await ipcRenderer.invoke('open_hash_file_to_temp', subdirectory_path, hash, host_file_temp_path, filename).then((result) => { -// console.log('IPC open hash file to temp finished'); -// if (result) { -// console.log('Local hash file was opened from temp directory.'); -// return result; -// } else { -// console.log('Local hash file was not opened from the temp directory. Something went wrong.'); -// console.log(result); -// return false; -// } -// }) - -// return open_hash_file_to_temp_result; -// } - -// Updated 2022-05-07 -export let run_cmd = async function run_cmd({ cmd = null, return_stdout = null }) { - console.log('*** run_cmd() ***'); - - let run_cmd_result = await native_app - .run_cmd({ cmd: cmd, return_stdout: return_stdout }) - .then(function (result) { - if (result) { - console.log('Command ran'); - } else { - console.log('Command did not run. Something went wrong.'); - return false; - } - if (return_stdout) { - return result; - } else { - return true; - } - }); - - console.log('Run Command Result:', run_cmd_result); - - return run_cmd_result; -}; - -// Updated 2022-10-27 -export let run_cmd_sync = function run_cmd_sync({ cmd = null, return_stdout = null }) { - console.log('*** run_cmd_sync() ***'); - - let run_cmd_result = native_app.run_cmd_sync({ cmd: cmd, return_stdout: return_stdout }); - - // if (run_cmd_result) { - // console.log('Command ran'); - // } else { - // console.log('Command did not run. Something went wrong.'); - // // return false; - // } - - // if (return_stdout) { - // return run_cmd_result; - // } else { - // return true; - // } - - console.log('Run Command Result:', run_cmd_result); - - return run_cmd_result; -}; - -// Updated 2022-05-07 -export let run_osascript = async function run_osascript({ - cmd = null, - interactive = false, - language = null, - flags = 'h', - program_file = null -}) { - console.log('*** run_osascript() ***'); - - let run_osascript_result = await native_app.run_osascript({ - cmd: cmd, - interactive: interactive, - language: language, - flags: flags, - program_file: program_file - }); - - console.log(run_osascript_result); - - if (run_osascript_result) { - console.log('Apple Script ran'); - } else { - console.log('Apple Script did not run. Something went wrong.'); - } - - return run_osascript_result; -}; - -// Updated 2022-05-07 -export let get_device_info = async function get_device_info({ event_device_id }) { - console.log('*** get_device_info() ***'); - - console.log(event_device_id); - - let get_device_info_result = await native_app.get_device_info(); - - console.log(get_device_info_result); - - if (get_device_info_result) { - console.log('Success'); - } else { - console.log('Failed? Something went wrong.'); - } - - return get_device_info_result; -}; diff --git a/src/lib/electron/electron_relay.ts b/src/lib/electron/electron_relay.ts new file mode 100644 index 00000000..6da0bd12 --- /dev/null +++ b/src/lib/electron/electron_relay.ts @@ -0,0 +1,123 @@ +/** + * Aether Electron Relay (V3) + * Maps legacy launcher commands to the modern Aether Native Bridge. + */ + +const log_lvl = 1; + +/** + * Returns the native bridge if available. + */ +function getBridge() { + if (typeof window !== 'undefined' && (window as any).aetherNative) { + return (window as any).aetherNative; + } + return null; +} + +/** + * Open a local directory in the OS File Manager. + */ +export async function open_folder(path: string) { + const bridge = getBridge(); + if (!bridge) return { success: false, error: 'Native bridge not found' }; + + if (log_lvl) console.log(`Relay: Opening folder: ${path}`); + return await bridge.openFolder(path); +} + +/** + * Launch a file using the OS default application. + */ +export async function launch_file(path: string) { + const bridge = getBridge(); + if (!bridge) return { success: false, error: 'Native bridge not found' }; + + if (log_lvl) console.log(`Relay: Launching file: ${path}`); + return await bridge.launchFile(path); +} + +/** + * Legacy compatibility alias for launch_file. + */ +export const open_local_file_v2 = async ({ file_path, filename }: { file_path: string, filename: string }) => { + return launch_file(`${file_path}/${filename}`); +}; + +/** + * Run a shell command. + */ +export async function run_cmd({ cmd, return_stdout = true }: { cmd: string, return_stdout?: boolean }) { + const bridge = getBridge(); + if (!bridge) return { success: false, error: 'Native bridge not found' }; + + if (log_lvl) console.log(`Relay: Running command: ${cmd}`); + const result = await bridge.runCommand(cmd); + + if (return_stdout) return result.stdout; + return result.success; +} + +/** + * Legacy compatibility alias for run_cmd. + */ +export const run_cmd_sync = run_cmd; + +/** + * Kill specific processes by name. + */ +export async function kill_processes({ process_name_li = [] }: { process_name_li: string[] }) { + const bridge = getBridge(); + if (!bridge) return { success: false, error: 'Native bridge not found' }; + + for (const name of process_name_li) { + if (log_lvl) console.log(`Relay: Killing process: ${name}`); + await bridge.runCommand(`pkill -f "${name}"`); + } + return true; +} + +/** + * Check if a file hash exists in the local cache. + */ +export async function check_hash_file_cache({ cacheRoot, hash }: { cacheRoot: string, hash: string }) { + const bridge = getBridge(); + if (!bridge) return false; + return await bridge.checkCache({ cacheRoot, hash }); +} + +/** + * Download a file to the local cache. + */ +export async function download_to_cache({ url, cacheRoot, hash, apiKey }: { url: string, cacheRoot: string, hash: string, apiKey: string }) { + const bridge = getBridge(); + if (!bridge) return { success: false, error: 'Native bridge not found' }; + return await bridge.downloadToCache({ url, cacheRoot, hash, apiKey }); +} + +/** + * Launch a file from the local cache (copies to temp first). + */ +export async function launch_from_cache({ cacheRoot, hash, tempRoot, filename }: { cacheRoot: string, hash: string, tempRoot: string, filename: string }) { + const bridge = getBridge(); + if (!bridge) return { success: false, error: 'Native bridge not found' }; + return await bridge.launchFromCache({ cacheRoot, hash, tempRoot, filename }); +} + +/** + * Get system/device info. + */ +export async function get_device_info() { + const bridge = getBridge(); + if (!bridge) return null; + return await bridge.getDeviceConfig(); +} + +/** + * Placeholder for legacy AppleScript execution. + */ +export async function run_osascript({ cmd }: { cmd: string }) { + const bridge = getBridge(); + if (!bridge) return null; + return await bridge.runCommand(`osascript -e '${cmd}'`); +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index af5dab4b..9a61d0e7 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -645,11 +645,12 @@ } // *** Electron Native Mode Detection *** - // If window.native_app exists, we are running inside the Electron bridge - // @ts-ignore - native_app is injected by the Electron preload script - if (window.native_app) { + // If window.native_app or window.aetherNative exists, we are running inside the Electron bridge + // @ts-ignore - native_app is injected by legacy, aetherNative by new V3 bridge + if (window.native_app || window.aetherNative) { console.log('ELECTRON: Native environment detected. Switching to native app_mode.'); $events_loc.launcher.app_mode = 'native'; + $ae_loc.is_native = true; } } diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 80cd02ba..f3c64a09 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -148,6 +148,12 @@ export async function load({ fetch, params, parent, route, url }) { ae_loc_init['host_file_temp_path'] = native_device_config.native_device.host_file_temp_path; ae_loc_init['recording_path'] = native_device_config.native_device.recording_path; } + + // IMPORTANT: Update API settings with the native-authorized key if present + if (native_device_config.aether_api_key) { + ae_api_init['api_secret_key'] = native_device_config.aether_api_key; + ae_api_headers['x-aether-api-key'] = native_device_config.aether_api_key; + } } } catch (err) { console.error('ROOT LOAD: Failed to fetch native device config.', err); diff --git a/src/routes/events/[event_id]/(launcher)/launcher/[event_location_id]/+page.svelte b/src/routes/events/[event_id]/(launcher)/launcher/[event_location_id]/+page.svelte index 45ec54ec..f2e12221 100644 --- a/src/routes/events/[event_id]/(launcher)/launcher/[event_location_id]/+page.svelte +++ b/src/routes/events/[event_id]/(launcher)/launcher/[event_location_id]/+page.svelte @@ -45,7 +45,9 @@ run_cmd_sync, run_osascript, get_device_info - } from '$lib/electron/electron_relay.js'; + } from '$lib/electron/electron_relay'; + + import LauncherBackgroundSync from '../../launcher_background_sync.svelte'; // import Event_launcher_menu from '../../launcher_menu.svelte'; // import Event_launcher_session_view from '../../launcher_session_view.svelte'; diff --git a/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte b/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte new file mode 100644 index 00000000..64f90305 --- /dev/null +++ b/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte @@ -0,0 +1,126 @@ + + +{#if currently_syncing && log_lvl} +
{test_cmd_result}
+ {/if}
+ Most files will automatically be opened full screen.
-PowerPoint or KeyNote will attempt to display in presenter view.
-PDFs, videos, and images will attempt to be displayed mirrored.
-Please close the file when finished.
- {:else if $events_loc.launcher.app_mode == 'onsite'} - *** Please wait while this file loads... *** -Most files will automatically be opened full screen.
-PowerPoint or KeyNote will attempt to display in presenter view.
-PDFs, videos, and images will attempt to be displayed mirrored.
-Please close the file when finished.
- {:else} - *** Please wait while this file downloads... *** -Onsite in the Speaker Ready Room and conference session rooms:
-Most files will automatically be opened full screen.
+PowerPoint or KeyNote will attempt to display in presenter view.
+Please close the file when finished.