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:
14
TODO.md
14
TODO.md
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user