feat(launcher): implement Phase 5 system management UI and relay

- Expose new native handlers in electron_relay.ts (Wallpaper, Updates, Window Control, Power).
- Overhaul Native OS Management UI with controls for window states, display layouts, and power.
- Add Application Updates component with support for local and web sources.
- Include confirmation modal for dangerous system actions (shutdown/reboot).
- Update TODO.md to mark Phase 5 integration as completed.
This commit is contained in:
Scott Idem
2026-01-30 12:49:09 -05:00
parent 5a2eaa8fac
commit 8c7784802a
5 changed files with 340 additions and 56 deletions

14
TODO.md
View File

@@ -26,12 +26,13 @@ This is a list of tasks to be completed before the next event/show/conference.
- [x] **Shared Observables:** Refactored lists to accept `liveQuery` props for flicker-free SWR transitions.
- [x] **Store Initialization:** Hardened persisted stores against `undefined` reactivity triggers.
2. **String-Only ID Standardization:**
- [ ] Audit and resort ID prioritization logic across all `.ts` and `.svelte` files.
- [ ] Preferred Order: `[obj_type]_id` || `id` || `[obj_type]_id_random` || `id_random`.
- [ ] Ensure `+layout.ts` cleans incoming raw data from the native bridge.
- [x] Audit and resort ID prioritization logic across all `.ts` and `.svelte` files. (Phase 1 Completed 2026-01-30)
- [x] Preferred Order: `[obj_type]_id` || `id` || `[obj_type]_id_random` || `id_random`.
- [x] Ensure `+layout.ts` cleans incoming raw data from the native bridge.
3. **Native Launcher Refinement (Phase 5):**
- [x] **Office Automation:** Implemented AppleScript handlers for PowerPoint/Keynote.
- [x] **Telemetry Dashboard:** Built visual CPU/RAM gauges in Launcher Config.
- [x] **Office Automation:** Implemented AppleScript handlers for PowerPoint/Keynote. (Completed 2026-01-30)
- [x] **Telemetry Dashboard:** Built visual CPU/RAM gauges in Launcher Config. (Completed 2026-01-30)
- [x] **System Management:** Added UI for Wallpaper, Recording, Display Layout, and Power Control. (Completed 2026-01-30)
3. **Codebase Consistency:**
- [x] **Field Mapping:** Renamed `default_qry_string` to `default_qry_str` project-wide for V3 API compliance.
@@ -89,3 +90,6 @@ This is a list of tasks to be completed before the next event/show/conference.
- [x] Refinement: Built Telemetry Dashboard in Launcher Config.

View File

@@ -183,6 +183,43 @@ export async function control_presentation({
return { success: false, error: `Unsupported app or action: ${app}/${action}` };
}
// 4. System Management (Phase 5+)
export async function set_wallpaper({ path }: { path: string }) {
if (!native || !native.set_wallpaper) return { success: false, error: 'Native handler set_wallpaper not available' };
return await native.set_wallpaper({ path });
}
export async function update_app(args: { source: 'url' | 'file', url?: string, path?: string }) {
if (!native || !native.update_app) return { success: false, error: 'Native handler update_app not available' };
return await native.update_app(args);
}
export async function window_control({ action, value }: { action: string, value?: any }) {
if (!native || !native.window_control) return { success: false, error: 'Native handler window_control not available' };
return await native.window_control({ action, value });
}
export async function manage_recording({ action, options }: { action: 'start' | 'stop' | 'status', options?: any }) {
if (!native || !native.manage_recording) return { success: false, error: 'Native handler manage_recording not available' };
return await native.manage_recording({ action, options });
}
export async function set_display_layout({ mode, configStr }: { mode: 'mirror' | 'extend', configStr?: string }) {
if (!native || !native.set_display_layout) return { success: false, error: 'Native handler set_display_layout not available' };
return await native.set_display_layout({ mode, configStr });
}
export async function power_control({ action }: { action: 'shutdown' | 'reboot' | 'sleep' }) {
if (!native || !native.power_control) return { success: false, error: 'Native handler power_control not available' };
return await native.power_control({ action });
}
export async function open_external({ url, app }: { url: string, app?: 'chrome' | 'firefox' | 'default' }) {
if (!native || !native.open_external) return { success: false, error: 'Native handler open_external not available' };
return await native.open_external({ url, app });
}
/**
* List Tools (Self-Documentation)
* Returns a JSON manifest of all available native bridge functions.

View File

@@ -6,6 +6,7 @@
let test_cmd_result = $state('');
let remote_app: 'powerpoint' | 'keynote' = $state('powerpoint');
let remote_status = $state('');
let system_status = $state('');
async function handle_remote_control(action: 'next' | 'prev' | 'start' | 'stop') {
remote_status = `Sending ${action}...`;
@@ -17,6 +18,20 @@
}
setTimeout(() => remote_status = '', 3000);
}
async function handle_system_action(promise: Promise<any>, label: string) {
system_status = `Executing ${label}...`;
const res = await promise;
if (res.success) {
system_status = `Success: ${label}`;
} else {
system_status = `Error: ${res.error || 'Unknown error'}`;
}
setTimeout(() => system_status = '', 3000);
}
// Modal state for dangerous actions
let show_power_confirm = $state<{ action: string, label: string } | null>(null);
</script>
{#if $ae_loc.is_native}
@@ -38,7 +53,7 @@
{:else}
<span class="fas fa-chevron-right"></span>
{/if}
Native OS Handlers & Folders
Native OS Management
</span>
<span class="badge variant-filled-success">Active</span>
</button>
@@ -48,32 +63,100 @@
class="flex flex-col gap-2 p-2 items-center justify-start w-full"
class:hidden={!$events_loc.launcher.show_section__native_os}
>
<div class="grid grid-cols-1 gap-2 w-full">
<button
onclick={() => native.open_folder($ae_loc.local_file_cache_path)}
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500 justify-start"
>
<span class="fas fa-folder-open mr-2"></span> Open Local Cache
</button>
<button
onclick={() => native.open_folder($ae_loc.host_file_temp_path)}
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500 justify-start"
>
<span class="fas fa-folder-open mr-2"></span> Open Host Temp
</button>
<button
onclick={() => native.open_folder($ae_loc.recording_path)}
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500 justify-start"
>
<span class="fas fa-folder-open mr-2"></span> Open Recording Path
</button>
{#if system_status}
<div class="text-[10px] text-center italic bg-surface-500/10 w-full py-1 rounded animate-pulse text-primary-500 border border-primary-500/20">
{system_status}
</div>
{/if}
<!-- 1. Window & UI Control -->
<div class="w-full flex flex-col gap-1 border-b border-surface-500/20 pb-2 mb-1">
<label class="text-[9px] font-bold uppercase opacity-50 ml-1">Window & UI</label>
<div class="grid grid-cols-2 gap-1">
<button
onclick={() => native.window_control({ action: 'maximize' })}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
>
<span class="fas fa-expand mr-1"></span> Maximize
</button>
<button
onclick={() => native.window_control({ action: 'devtools', value: true })}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
>
<span class="fas fa-code mr-1"></span> DevTools
</button>
<button
onclick={() => handle_system_action(native.window_control({ action: 'kiosk', value: true }), 'Kiosk Mode')}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
>
<span class="fas fa-desktop mr-1"></span> Kiosk
</button>
<button
onclick={() => handle_system_action(native.set_wallpaper({ path: $ae_loc.site_header_image_path }), 'Wallpaper')}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
disabled={!$ae_loc.site_header_image_path}
>
<span class="fas fa-image mr-1"></span> Set Wallpaper
</button>
</div>
</div>
<!-- Presentation Remote Control (Phase 5) -->
<div class="w-full border-t border-surface-500/30 pt-2 mt-2 flex flex-col gap-2">
<!-- 2. System & Power -->
<div class="w-full flex flex-col gap-1 border-b border-surface-500/20 pb-2 mb-1">
<label class="text-[9px] font-bold uppercase opacity-50 ml-1">System & Hardware</label>
<div class="grid grid-cols-2 gap-1">
<button
onclick={() => handle_system_action(native.set_display_layout({ mode: 'extend' }), 'Extend Display')}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
>
<span class="fas fa-columns mr-1"></span> Extend Displays
</button>
<button
onclick={() => handle_system_action(native.manage_recording({ action: 'status' }), 'Record Status')}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
>
<span class="fas fa-video mr-1"></span> Recording...
</button>
<button
onclick={() => show_power_confirm = { action: 'reboot', label: 'Reboot Laptop' }}
class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500"
>
<span class="fas fa-sync-alt mr-1"></span> Reboot
</button>
<button
onclick={() => show_power_confirm = { action: 'shutdown', label: 'Shutdown Laptop' }}
class="btn btn-xs preset-tonal-error hover:preset-filled-error-500"
>
<span class="fas fa-power-off mr-1"></span> Shutdown
</button>
</div>
</div>
<!-- 3. Folders -->
<div class="w-full flex flex-col gap-1 border-b border-surface-500/20 pb-2 mb-1">
<label class="text-[9px] font-bold uppercase opacity-50 ml-1">Operational Folders</label>
<div class="grid grid-cols-1 gap-1">
<button
onclick={() => native.open_folder($ae_loc.local_file_cache_path)}
class="btn btn-xs preset-tonal-primary hover:preset-filled-primary-500 justify-start"
>
<span class="fas fa-folder-open mr-2 w-4"></span> Open Local Cache
</button>
<button
onclick={() => native.open_folder($ae_loc.host_file_temp_path)}
class="btn btn-xs preset-tonal-primary hover:preset-filled-primary-500 justify-start"
>
<span class="fas fa-folder-open mr-2 w-4"></span> Open Host Temp
</button>
</div>
</div>
<!-- 4. Presentation Remote Control (Phase 5) -->
<div class="w-full flex flex-col gap-1 border-b border-surface-500/20 pb-2 mb-1">
<div class="flex flex-row justify-between items-center px-1">
<label class="text-[10px] font-bold uppercase opacity-70">Presentation Remote:</label>
<select bind:value={remote_app} class="select select-sm py-0 h-6 text-[10px] w-24 preset-tonal-surface">
<label class="text-[9px] font-bold uppercase opacity-50">Presentation Remote</label>
<select bind:value={remote_app} class="select select-sm py-0 h-5 text-[9px] w-24 preset-tonal-surface">
<option value="powerpoint">PowerPoint</option>
<option value="keynote">Keynote</option>
</select>
@@ -94,36 +177,35 @@
</button>
</div>
{#if remote_status}
<div class="text-[9px] text-center italic animate-pulse">{remote_status}</div>
<div class="text-[9px] text-center italic animate-pulse text-primary-500">{remote_status}</div>
{/if}
</div>
<div class="w-full border-t border-surface-500/30 pt-2 mt-2 flex flex-col gap-2">
<div class="flex flex-col gap-1">
<label class="text-[10px] opacity-70 ml-1">Run Manual Command:</label>
<div class="flex gap-1">
<input
type="text"
bind:value={$events_sess.launcher.manual_cmd}
placeholder="e.g. ls -la or whoami"
class="input input-sm grow text-[10px] preset-tonal-surface"
/>
<button
onclick={async () => {
test_cmd_result = 'Running...';
const res = await native.run_cmd({ cmd: $events_sess.launcher.manual_cmd || 'whoami && uptime' });
if (res && typeof res === 'object') {
test_cmd_result = (res as any).stdout || (res as any).error || 'No Output';
if ((res as any).stderr) test_cmd_result += `\nStderr: ${(res as any).stderr}`;
} else {
test_cmd_result = String(res);
}
}}
class="btn btn-sm preset-filled-secondary hover:preset-filled-primary-500 text-[10px]"
>
Run
</button>
</div>
<!-- 5. Manual Command -->
<div class="w-full flex flex-col gap-1">
<label class="text-[9px] font-bold uppercase opacity-50 ml-1">Terminal Access</label>
<div class="flex gap-1">
<input
type="text"
bind:value={$events_sess.launcher.manual_cmd}
placeholder="e.g. ls -la or whoami"
class="input input-sm grow text-[10px] preset-tonal-surface h-7"
/>
<button
onclick={async () => {
test_cmd_result = 'Running...';
const res = await native.run_cmd({ cmd: $events_sess.launcher.manual_cmd || 'whoami && uptime' });
if (res && typeof res === 'object') {
test_cmd_result = (res as any).stdout || (res as any).error || 'No Output';
if ((res as any).stderr) test_cmd_result += `\nStderr: ${(res as any).stderr}`;
} else {
test_cmd_result = String(res);
}
}}
class="btn btn-sm preset-filled-secondary hover:preset-filled-primary-500 text-[10px] h-7"
>
Run
</button>
</div>
{#if test_cmd_result}
@@ -140,4 +222,30 @@
</section>
{/if}
<!-- Power Confirmation Modal -->
{#if show_power_confirm}
<div class="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div class="card p-6 w-full max-w-sm preset-filled-surface-100-900 border border-error-500 shadow-2xl">
<h4 class="h4 text-error-500 font-bold mb-2">Confirm System Action</h4>
<p class="text-sm opacity-80 mb-6">
Are you sure you want to <strong>{show_power_confirm.action}</strong> this host machine?
This will terminate the native application and any active presentations.
</p>
<div class="flex justify-end gap-2">
<button onclick={() => show_power_confirm = null} class="btn btn-sm preset-tonal-surface">Cancel</button>
<button
onclick={() => {
const action = show_power_confirm?.action;
show_power_confirm = null;
if (action) handle_system_action(native.power_control({ action: action as any }), action);
}}
class="btn btn-sm preset-filled-error"
>
Confirm {show_power_confirm.action}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import { ae_loc, ae_api, ae_sess } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import * as native from '$lib/electron/electron_relay';
let update_source: 'url' | 'file' = $state('file');
let update_path = $state('~/OSIT/Speaker Ready System/Admin Share/Custom Applications/osit_binaries/');
let update_url = $state('https://dev-demo.oneskyit.com/updates/ae_native.zip');
let update_status = $state('');
let is_checking = $state(false);
let download_result = $state<any>(null);
async function handle_check_update() {
is_checking = true;
update_status = 'Checking for updates...';
try {
const args = update_source === 'url'
? { source: 'url' as const, url: update_url }
: { source: 'file' as const, path: update_path };
const res = await native.update_app(args);
if (res.success) {
download_result = res;
update_status = 'Update located/downloaded. ready to install.';
} else {
update_status = `Failed: ${res.error}`;
}
} catch (err: any) {
update_status = `Error: ${err.message}`;
} finally {
is_checking = false;
}
}
async function handle_install() {
update_status = 'Initiating installation...';
// Note: Real installation logic in the Electron app will likely
// terminate the process or restart it.
alert('Installation logic is OS-specific. This will typically swap the application bundle and restart.');
}
</script>
{#if $ae_loc.is_native}
<section
class:preset-outlined-tertiary-300-700={$events_loc.launcher.show_section__updates}
class="updates w-full preset-outlined-surface-300-700 transition-all mb-2"
>
<h3 class="text-center mb-2 text-sm font-semibold w-full">
<button
onclick={() => {
$events_loc.launcher.show_section__updates =
!$events_loc.launcher.show_section__updates;
}}
class="btn btn-sm w-full justify-between"
>
<span class="flex items-center gap-2">
{#if $events_loc.launcher.show_section__updates}
<span class="fas fa-chevron-down text-[10px]"></span>
{:else}
<span class="fas fa-chevron-right text-[10px]"></span>
{/if}
Application Updates
</span>
<span class="badge variant-filled-tertiary text-[10px]">v1.0.0</span>
</button>
</h3>
<div
class="flex flex-col gap-2 p-2 items-center justify-start w-full"
class:hidden={!$events_loc.launcher.show_section__updates}
>
<div class="w-full flex flex-col gap-2">
<div class="flex flex-row justify-between items-center px-1">
<label class="text-[9px] font-bold uppercase opacity-50">Source Type</label>
<div class="flex gap-2">
<label class="flex items-center gap-1 text-[10px]">
<input type="radio" bind:group={update_source} value="file" class="radio radio-sm" /> Local
</label>
<label class="flex items-center gap-1 text-[10px]">
<input type="radio" bind:group={update_source} value="url" class="radio radio-sm" /> Web
</label>
</div>
</div>
{#if update_source === 'file'}
<input
type="text"
bind:value={update_path}
placeholder="Path to update directory or file"
class="input input-sm text-[10px] preset-tonal-surface h-7 w-full"
/>
{:else}
<input
type="text"
bind:value={update_url}
placeholder="URL to update package (.zip/.app)"
class="input input-sm text-[10px] preset-tonal-surface h-7 w-full"
/>
{/if}
<button
onclick={handle_check_update}
disabled={is_checking}
class="btn btn-sm preset-filled-tertiary hover:preset-filled-primary-500 text-[10px] w-full"
>
{#if is_checking}
<span class="fas fa-spinner fa-spin mr-2"></span> Checking...
{:else}
<span class="fas fa-cloud-download-alt mr-2"></span> Check for Updates
{/if}
</button>
{#if update_status}
<div class="text-[9px] text-center italic p-1 border border-surface-500/20 rounded bg-surface-500/5">
{update_status}
</div>
{/if}
{#if download_result}
<button
onclick={handle_install}
class="btn btn-sm preset-filled-success hover:preset-filled-primary-500 text-[10px] w-full animate-bounce"
>
<span class="fas fa-magic mr-2"></span> Install & Relaunch
</button>
{/if}
</div>
</div>
</section>
{/if}

View File

@@ -27,6 +27,7 @@
import LauncherCfgNativeOS from './cfg_components/launcher_cfg_native_os.svelte';
import LauncherCfgSyncTimers from './cfg_components/launcher_cfg_sync_timers.svelte';
import LauncherCfgHealth from './cfg_components/launcher_cfg_health.svelte';
import LauncherCfgUpdates from './cfg_components/launcher_cfg_updates.svelte';
import LauncherCfgController from './cfg_components/launcher_cfg_controller.svelte';
import LauncherCfgScreenSaver from './cfg_components/launcher_cfg_screen_saver.svelte';
import LauncherCfgAppModes from './cfg_components/launcher_cfg_app_modes.svelte';
@@ -58,6 +59,7 @@
<LauncherCfgNativeOS />
<LauncherCfgSyncTimers />
<LauncherCfgHealth />
<LauncherCfgUpdates />
{/if}
<LauncherCfgController />