From 2ae0f7db9cc9fc5ac64a5b9931064ba8962e10d2 Mon Sep 17 00:00:00 2001 From: bzeiss Date: Tue, 18 Aug 2026 20:01:33 +0200 Subject: [PATCH 01/12] improved startup speed. --- apps/axkdeck/README.md | 51 ++ apps/axkdeck/package.json | 3 +- apps/axkdeck/src-tauri/src/lib.rs | 107 ++-- .../src-tauri/src/local_directory_exports.rs | 11 +- apps/axkdeck/src-tauri/src/local_packages.rs | 22 +- .../axkdeck/src-tauri/src/local_workspaces.rs | 23 +- apps/axkdeck/src-tauri/src/remote_settings.rs | 204 +++++++- apps/axkdeck/src-tauri/src/server_sidecar.rs | 57 ++- .../src-tauri/src/startup_diagnostics.rs | 478 ++++++++++++++++++ apps/axkdeck/src/App.svelte | 156 +++--- apps/axkdeck/src/App.test.ts | 14 +- apps/axkdeck/src/StartupShell.svelte | 198 ++++++++ apps/axkdeck/src/StartupShell.test.ts | 34 ++ apps/axkdeck/src/appProps.ts | 7 + .../src/features/dialogs/visibility.ts | 83 +++ apps/axkdeck/src/features/workspace/tabs.ts | 15 + apps/axkdeck/src/lib/diagnostics.ts | 10 + .../src/lib/startupCoordinator.test.ts | 42 ++ apps/axkdeck/src/lib/startupCoordinator.ts | 19 + .../src/lib/startupDiagnostics.test.ts | 98 ++++ apps/axkdeck/src/lib/startupDiagnostics.ts | 130 +++++ apps/axkdeck/src/main.ts | 145 +++++- apps/axkdeck/tools/check-startup-bundle.mjs | 39 ++ apps/server/CMakeLists.txt | 42 +- .../server/include/axklib/server/contract.hpp | 18 +- apps/server/src/contract.cpp | 123 +---- apps/server/src/server_lifecycle.cpp | 11 +- apps/server/src/validation_plan.cpp | 252 +++++++++ apps/server/src/validation_plan.hpp | 110 ++++ apps/server/src/validation_plan_generator.cpp | 387 ++++++++++++++ apps/server/tests/contract_test_support.cpp | 77 +++ apps/server/tests/contract_test_support.hpp | 32 ++ apps/server/tests/server_contract_test.cpp | 73 ++- vcpkg.json | 6 +- 34 files changed, 2740 insertions(+), 337 deletions(-) create mode 100644 apps/axkdeck/src-tauri/src/startup_diagnostics.rs create mode 100644 apps/axkdeck/src/StartupShell.svelte create mode 100644 apps/axkdeck/src/StartupShell.test.ts create mode 100644 apps/axkdeck/src/appProps.ts create mode 100644 apps/axkdeck/src/features/dialogs/visibility.ts create mode 100644 apps/axkdeck/src/features/workspace/tabs.ts create mode 100644 apps/axkdeck/src/lib/startupCoordinator.test.ts create mode 100644 apps/axkdeck/src/lib/startupCoordinator.ts create mode 100644 apps/axkdeck/src/lib/startupDiagnostics.test.ts create mode 100644 apps/axkdeck/src/lib/startupDiagnostics.ts create mode 100644 apps/axkdeck/tools/check-startup-bundle.mjs create mode 100644 apps/server/src/validation_plan.cpp create mode 100644 apps/server/src/validation_plan.hpp create mode 100644 apps/server/src/validation_plan_generator.cpp create mode 100644 apps/server/tests/contract_test_support.cpp create mode 100644 apps/server/tests/contract_test_support.hpp diff --git a/apps/axkdeck/README.md b/apps/axkdeck/README.md index aa751f6b..23c10b8a 100644 --- a/apps/axkdeck/README.md +++ b/apps/axkdeck/README.md @@ -346,6 +346,57 @@ Development runs also mirror `axklib-server` stdout and stderr to the terminal. Workspace setup failures remain visible in the Workspaces dialog and include the server request ID when one is available. +### Startup profiling + +Every completed desktop launch writes one structured `desktop_startup_completed` +event at the default `info` log level. It reports native setup, protected-settings +lookup, local-server startup, WebView navigation, frontend initialization, mount, +and first-painted-frame timings. Outcomes are categorical and the event never +contains filesystem paths, server URLs, credentials, user names, host names, or +raw errors. Set `AXKDECK_LOG_LEVEL=debug` to additionally record each fixed +`desktop_startup_milestone` as it occurs. Explicit `warn`, `error`, and `off` +settings continue to suppress the informational summary. + +The built-in timeline begins when Rust enters axkdeck. Use Windows Performance +Recorder (WPR) when the delay may precede that point or involve process startup, +storage, antivirus scanning, WebView2, or DLL loading. Profile a packaged build, +not a Vite development session: + +1. Record the axkdeck version and source identity, machine model, CPU, memory, + storage type, Windows version, WebView2 version, and active security software. +2. Collect at least five first-launch-after-reboot (cold) samples and five + subsequent (warm) samples with the normal local server. +3. Repeat both sets after launching from PowerShell with + `$env:AXKDECK_HTTP_SERVER='0'`. This diagnostic comparison disables the local + sidecar for that process; it is not a normal operating configuration. +4. Keep each startup trace separate and retain the corresponding + `desktop_startup_completed` log line. + +From an elevated PowerShell terminal, first confirm the available WPR profiles, +then capture one launch: + +```powershell +wpr -profiles +wpr -start GeneralProfile -filemode +# Launch axkdeck, wait for the workspace to finish its first paint, then: +wpr -stop "$env:TEMP\axkdeck-startup.etl" +``` + +Run `wpr -cancel` if a capture must be abandoned. In Windows Performance +Analyzer, inspect process lifetime, CPU usage, disk/file I/O, image/DLL loading, +and wait analysis for `axkdeck.exe`, `axklib-server.exe`, +`msedgewebview2.exe`, and the active antivirus process. Correlate those spans +with the structured log milestones: + +- delay before `native_entry` is outside the built-in timeline and belongs to + OS process creation, loading, security scanning, or runtime initialization; +- a long `credentialLookupMs` isolates protected-settings access; +- a long `sidecarStartupMs` isolates local server spawn/readiness; +- a gap between `pageLoadStartedMs` and `pageLoadFinishedMs` isolates WebView + navigation and asset loading; +- large frontend module-to-mount or mount-to-first-frame intervals isolate + renderer initialization and painting. + ## Verify and build ```bash diff --git a/apps/axkdeck/package.json b/apps/axkdeck/package.json index 55fed00b..3763b13a 100644 --- a/apps/axkdeck/package.json +++ b/apps/axkdeck/package.json @@ -9,7 +9,8 @@ }, "scripts": { "dev": "vite", - "build": "pnpm contract:check && svelte-check --tsconfig ./tsconfig.app.json && vite build", + "build": "pnpm contract:check && svelte-check --tsconfig ./tsconfig.app.json && vite build && pnpm bundle:check", + "bundle:check": "node tools/check-startup-bundle.mjs", "contract:check": "node tools/openapi-contract.mjs check", "contract:generate": "node tools/openapi-contract.mjs write", "version:check": "node tools/check-versions.mjs", diff --git a/apps/axkdeck/src-tauri/src/lib.rs b/apps/axkdeck/src-tauri/src/lib.rs index 2b6f9ee0..f41dd553 100644 --- a/apps/axkdeck/src-tauri/src/lib.rs +++ b/apps/axkdeck/src-tauri/src/lib.rs @@ -7,10 +7,12 @@ mod local_workspaces; mod remote_settings; mod retained_download; mod server_sidecar; +mod startup_diagnostics; use std::sync::Mutex; use serde::Serialize; +use tauri::webview::PageLoadEvent; use tauri::{Manager, State, WebviewWindow}; use tauri_plugin_log::{RotationStrategy, Target, TargetKind}; @@ -26,6 +28,7 @@ use local_packages::{ select_local_volume_packages, }; use local_workspaces::{WorkspaceCandidateStore, commit_local_workspace, select_local_workspace}; +use startup_diagnostics::{StartupDiagnostics, StartupMilestone, complete_startup}; const LOG_FILE_SIZE: u128 = 5 * 1024 * 1024; const RETAINED_LOG_FILES: usize = 3; @@ -320,23 +323,23 @@ fn desktop_build_info() -> DesktopBuildInfo { } #[tauri::command] -fn server_connection( - state: State<'_, Mutex>, +async fn server_connection( + state: State<'_, remote_settings::ServerConnectionState>, ) -> Result, String> { - state - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned())? - .connection() + let state = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || state.connection()) + .await + .map_err(|error| format!("wait for server connection: {error}"))? } #[tauri::command] -fn remote_server_settings( - state: State<'_, Mutex>, +async fn remote_server_settings( + state: State<'_, remote_settings::ServerConnectionState>, ) -> Result { - state - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned()) - .map(|manager| manager.settings()) + let state = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || state.settings()) + .await + .map_err(|error| format!("wait for server settings: {error}")) } #[tauri::command] @@ -347,24 +350,24 @@ fn validate_remote_server_settings( } #[tauri::command] -fn configure_remote_server( +async fn configure_remote_server( settings: remote_settings::RemoteServerSettingsInput, - state: State<'_, Mutex>, + state: State<'_, remote_settings::ServerConnectionState>, ) -> Result { - state - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned())? - .configure_remote(settings) + let state = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || state.configure_remote(settings)) + .await + .map_err(|error| format!("configure remote server worker failed: {error}"))? } #[tauri::command] -fn use_local_server( - state: State<'_, Mutex>, +async fn use_local_server( + state: State<'_, remote_settings::ServerConnectionState>, ) -> Result { - state - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned())? - .use_local() + let state = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || state.use_local()) + .await + .map_err(|error| format!("start local server worker failed: {error}"))? } #[cfg(target_os = "linux")] @@ -386,8 +389,11 @@ fn configure_linux_webkit() { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let startup = StartupDiagnostics::new(); + startup.record(StartupMilestone::NativeEntry); #[cfg(target_os = "linux")] configure_linux_webkit(); + startup.record(StartupMilestone::PlatformConfigurationCompleted); let log_level = configured_log_level(); let log_targets = vec![ Target::new(TargetKind::LogDir { @@ -396,21 +402,28 @@ pub fn run() { #[cfg(debug_assertions)] Target::new(TargetKind::Stdout), ]; + startup.record(StartupMilestone::LogPluginBuildStarted); let log_plugin = tauri_plugin_log::Builder::new() .targets(log_targets) .level(log_level) .max_file_size(LOG_FILE_SIZE) .rotation_strategy(RotationStrategy::KeepSome(RETAINED_LOG_FILES)) .build(); + startup.record(StartupMilestone::LogPluginBuildCompleted); - tauri::Builder::default() + let setup_startup = startup.clone(); + let page_load_startup = startup.clone(); + let builder = tauri::Builder::default() .plugin(log_plugin) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .manage(Mutex::new(WorkspaceCandidateStore::default())) .manage(Mutex::new(PackageSaveCandidateStore::default())) .manage(Mutex::new(DirectorySaveCandidateStore::default())) - .setup(|app| { + .manage(startup.clone()) + .setup(move |app| { + setup_startup.enable_logging(); + setup_startup.record(StartupMilestone::TauriSetupStarted); let log_directory = app .path() .app_log_dir() @@ -419,35 +432,47 @@ pub fn run() { .path() .app_local_data_dir() .map_err(|error| format!("resolve application state directory: {error}"))?; + setup_startup.record(StartupMilestone::PathsResolved); let preferences_path = application_data_directory.join("desktop-preferences.json"); + setup_startup.record(StartupMilestone::PreferencesLoadStarted); let preferences = DesktopPreferencesStore::load(preferences_path.clone()).unwrap_or_else(|error| { log::warn!("desktop preferences are unavailable and will be reset on the next update: {error}"); DesktopPreferencesStore::empty(preferences_path) }); + setup_startup.record(StartupMilestone::PreferencesLoadCompleted); app.manage(Mutex::new(preferences)); let state_directory = application_data_directory.join("server-state"); - let manager = remote_settings::ServerConnectionManager::initialize( - log_directory.clone(), - state_directory.clone(), - ) - .unwrap_or_else(|error| { - log::error!("local axklib-server initialization failed: {error}"); - remote_settings::ServerConnectionManager::unavailable( - error, - log_directory, - state_directory, - ) - }); - app.manage(Mutex::new(manager)); + let connections = remote_settings::ServerConnectionState::pending(); + connections.initialize_in_background( + log_directory, + state_directory, + setup_startup.clone(), + ); + app.manage(connections); let build = current_build_info(); log::info!( "axkdeck desktop shell initialized: version={} source={}", build.semantic_version, build.source_identity ); + setup_startup.record(StartupMilestone::TauriSetupCompleted); Ok(()) }) + .on_page_load(move |webview, payload| { + if webview.label() != "main" { + return; + } + match payload.event() { + PageLoadEvent::Started => { + page_load_startup.record(StartupMilestone::PageLoadStarted); + } + PageLoadEvent::Finished => { + page_load_startup.record(StartupMilestone::PageLoadFinished); + } + } + }) .invoke_handler(tauri::generate_handler![ + complete_startup, server_connection, remote_server_settings, validate_remote_server_settings, @@ -468,7 +493,9 @@ pub fn run() { desktop_build_info, open_allocation_inspector, save_allocation_map_json - ]) + ]); + startup.record(StartupMilestone::TauriBuilderConfigured); + builder .run(tauri::generate_context!()) .expect("failed to run axkdeck"); } diff --git a/apps/axkdeck/src-tauri/src/local_directory_exports.rs b/apps/axkdeck/src-tauri/src/local_directory_exports.rs index 2dc94701..f212af8a 100644 --- a/apps/axkdeck/src-tauri/src/local_directory_exports.rs +++ b/apps/axkdeck/src-tauri/src/local_directory_exports.rs @@ -390,7 +390,7 @@ pub(crate) async fn save_retained_directory_export( content_path: String, expected_size: u64, candidates: State<'_, Mutex>, - connections: State<'_, Mutex>, + connections: State<'_, remote_settings::ServerConnectionState>, ) -> Result<(), String> { let destination = candidates .lock() @@ -400,12 +400,11 @@ pub(crate) async fn save_retained_directory_export( .filter(|(_, created)| created.elapsed() < Duration::from_secs(300)) .map(|(path, _)| path) .ok_or_else(|| "export destination expired; choose it again".to_owned())?; - let connection = connections - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned())? - .connection()? - .ok_or_else(|| "axklib-server is unavailable".to_owned())?; + let connections = connections.inner().clone(); tauri::async_runtime::spawn_blocking(move || { + let connection = connections + .connection()? + .ok_or_else(|| "axklib-server is unavailable".to_owned())?; download_retained_directory_export(connection, destination, content_path, expected_size) }) .await diff --git a/apps/axkdeck/src-tauri/src/local_packages.rs b/apps/axkdeck/src-tauri/src/local_packages.rs index 276d8a56..78a0d3f1 100644 --- a/apps/axkdeck/src-tauri/src/local_packages.rs +++ b/apps/axkdeck/src-tauri/src/local_packages.rs @@ -459,7 +459,7 @@ pub(crate) async fn save_retained_package( content_path: String, expected_size: u64, candidates: State<'_, Mutex>, - connections: State<'_, Mutex>, + connections: State<'_, remote_settings::ServerConnectionState>, ) -> Result<(), String> { let destination = candidates .lock() @@ -469,12 +469,11 @@ pub(crate) async fn save_retained_package( .filter(|(_, created)| created.elapsed() < Duration::from_secs(300)) .map(|(path, _)| path) .ok_or_else(|| "package destination expired; choose it again".to_owned())?; - let connection = connections - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned())? - .connection()? - .ok_or_else(|| "axklib-server is unavailable".to_owned())?; + let connections = connections.inner().clone(); tauri::async_runtime::spawn_blocking(move || { + let connection = connections + .connection()? + .ok_or_else(|| "axklib-server is unavailable".to_owned())?; download_retained_file(connection, destination, content_path, expected_size) }) .await @@ -487,7 +486,7 @@ pub(crate) async fn save_retained_media( content_path: String, expected_size: u64, candidates: State<'_, Mutex>, - connections: State<'_, Mutex>, + connections: State<'_, remote_settings::ServerConnectionState>, ) -> Result<(), String> { let destination = candidates .lock() @@ -497,12 +496,11 @@ pub(crate) async fn save_retained_media( .filter(|(_, created)| created.elapsed() < Duration::from_secs(300)) .map(|(path, _)| path) .ok_or_else(|| "media destination expired; choose it again".to_owned())?; - let connection = connections - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned())? - .connection()? - .ok_or_else(|| "axklib-server is unavailable".to_owned())?; + let connections = connections.inner().clone(); tauri::async_runtime::spawn_blocking(move || { + let connection = connections + .connection()? + .ok_or_else(|| "axklib-server is unavailable".to_owned())?; download_retained_file(connection, destination, content_path, expected_size) }) .await diff --git a/apps/axkdeck/src-tauri/src/local_workspaces.rs b/apps/axkdeck/src-tauri/src/local_workspaces.rs index 0a1d982b..40b3c19f 100644 --- a/apps/axkdeck/src-tauri/src/local_workspaces.rs +++ b/apps/axkdeck/src-tauri/src/local_workspaces.rs @@ -77,13 +77,13 @@ pub(crate) async fn select_local_workspace( } #[tauri::command] -pub(crate) fn commit_local_workspace( +pub(crate) async fn commit_local_workspace( candidate_id: String, display_name: String, writable: bool, revision: u64, candidates: State<'_, Mutex>, - connections: State<'_, Mutex>, + connections: State<'_, remote_settings::ServerConnectionState>, ) -> Result<(), String> { if display_name.trim().is_empty() { return Err("enter a workspace name".to_owned()); @@ -96,12 +96,17 @@ pub(crate) fn commit_local_workspace( .filter(|(_, created)| created.elapsed() < Duration::from_secs(300)) .map(|(path, _)| path) .ok_or_else(|| "workspace selection expired; choose the folder again".to_owned())?; - let connection = connections - .lock() - .map_err(|_| "server connection settings are unavailable".to_owned())? - .connection()? - .ok_or_else(|| "local axklib-server is unavailable".to_owned())?; - server_sidecar::create_workspace(&connection, &path, display_name.trim(), writable, revision)?; - log::info!("local workspace committed: {display_name}"); + let display_name = display_name.trim().to_owned(); + let connections = connections.inner().clone(); + let logged_name = display_name.clone(); + tauri::async_runtime::spawn_blocking(move || { + let connection = connections + .connection()? + .ok_or_else(|| "local axklib-server is unavailable".to_owned())?; + server_sidecar::create_workspace(&connection, &path, &display_name, writable, revision) + }) + .await + .map_err(|error| format!("create workspace worker failed: {error}"))??; + log::info!("local workspace committed: {logged_name}"); Ok(()) } diff --git a/apps/axkdeck/src-tauri/src/remote_settings.rs b/apps/axkdeck/src-tauri/src/remote_settings.rs index 53e293c6..0efcf9a0 100644 --- a/apps/axkdeck/src-tauri/src/remote_settings.rs +++ b/apps/axkdeck/src-tauri/src/remote_settings.rs @@ -1,8 +1,12 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::sync::{Arc, Condvar, Mutex}; use url::{Host, Url}; use crate::server_sidecar::{FrontendConnection, ServerSidecar}; +use crate::startup_diagnostics::{ + CredentialOutcome, ServerOutcome, StartupDiagnostics, StartupMilestone, +}; const KEYRING_SERVICE: &str = "app.axkdeck.desktop.axklib-server"; const KEYRING_USER: &str = "remote-server-v1"; @@ -40,18 +44,143 @@ pub struct ServerConnectionManager { state_directory: PathBuf, } -impl ServerConnectionManager { - pub fn initialize(log_directory: PathBuf, state_directory: PathBuf) -> Result { - match load_remote_settings() { - Ok(Some(settings)) => Ok(Self { - sidecar: None, - connection: Some(frontend_connection(&settings)), - secure_storage_error: None, +struct ServerConnectionStateInner { + manager: Mutex>, + ready: Condvar, +} + +#[derive(Clone)] +pub struct ServerConnectionState { + inner: Arc, +} + +impl ServerConnectionState { + pub fn pending() -> Self { + Self { + inner: Arc::new(ServerConnectionStateInner { + manager: Mutex::new(None), + ready: Condvar::new(), + }), + } + } + + pub fn initialize_in_background( + &self, + log_directory: PathBuf, + state_directory: PathBuf, + startup: StartupDiagnostics, + ) { + let worker_state = self.clone(); + let worker_log_directory = log_directory.clone(); + let worker_state_directory = state_directory.clone(); + let worker = std::thread::Builder::new() + .name("axkdeck-server-startup".to_owned()) + .spawn(move || { + let manager = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ServerConnectionManager::initialize( + worker_log_directory.clone(), + worker_state_directory.clone(), + &startup, + ) + })) + .map_err(|_| "axklib-server initialization panicked".to_owned()) + .and_then(|result| result) + .unwrap_or_else(|error| { + log::error!("local axklib-server initialization failed: {error}"); + ServerConnectionManager::unavailable( + error, + worker_log_directory, + worker_state_directory, + ) + }); + worker_state.complete(manager); + }); + if let Err(error) = worker { + let message = format!("start axklib-server initialization worker: {error}"); + log::error!("{message}"); + self.complete(ServerConnectionManager::unavailable( + message, log_directory, state_directory, - }), - Ok(None) => Self::local(log_directory, state_directory, None), - Err(error) => Self::local(log_directory, state_directory, Some(error)), + )); + } + } + + pub fn connection(&self) -> Result, String> { + self.with_manager(ServerConnectionManager::connection) + } + + pub fn settings(&self) -> RemoteServerSettingsView { + self.with_manager(|manager| manager.settings()) + } + + pub fn configure_remote( + &self, + input: RemoteServerSettingsInput, + ) -> Result { + self.with_manager(|manager| manager.configure_remote(input)) + } + + pub fn use_local(&self) -> Result { + self.with_manager(ServerConnectionManager::use_local) + } + + fn complete(&self, manager: ServerConnectionManager) { + let mut current = self + .inner + .manager + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current.is_none() { + *current = Some(manager); + self.inner.ready.notify_all(); + } + } + + fn with_manager(&self, operation: impl FnOnce(&mut ServerConnectionManager) -> T) -> T { + let mut current = self + .inner + .manager + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while current.is_none() { + current = self + .inner + .ready + .wait(current) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + operation(current.as_mut().expect("server connection state completed")) + } +} + +impl ServerConnectionManager { + pub fn initialize( + log_directory: PathBuf, + state_directory: PathBuf, + startup: &StartupDiagnostics, + ) -> Result { + startup.record(StartupMilestone::CredentialLookupStarted); + let settings = load_remote_settings(); + startup.set_credential_outcome(match &settings { + Ok(Some(_)) => CredentialOutcome::Found, + Ok(None) => CredentialOutcome::Missing, + Err(_) => CredentialOutcome::Error, + }); + startup.record(StartupMilestone::CredentialLookupCompleted); + match settings { + Ok(Some(settings)) => { + startup.set_server_outcome(ServerOutcome::RemoteReady); + Ok(Self { + sidecar: None, + connection: Some(frontend_connection(&settings)), + secure_storage_error: None, + log_directory, + state_directory, + }) + } + Ok(None) => Self::local(log_directory, state_directory, None, Some(startup)), + Err(error) => Self::local(log_directory, state_directory, Some(error), Some(startup)), } } @@ -59,8 +188,14 @@ impl ServerConnectionManager { log_directory: PathBuf, state_directory: PathBuf, secure_storage_error: Option, + startup: Option<&StartupDiagnostics>, ) -> Result { - let sidecar = ServerSidecar::launch_if_available(&log_directory, &state_directory)?; + let sidecar = match startup { + Some(startup) => { + ServerSidecar::launch_at_startup(&log_directory, &state_directory, startup)? + } + None => ServerSidecar::launch_if_available(&log_directory, &state_directory)?, + }; let connection = sidecar.as_ref().map(|server| server.connection().clone()); Ok(Self { sidecar, @@ -228,7 +363,14 @@ fn delete_remote_settings() -> Result<(), String> { #[cfg(test)] mod tests { - use super::{RemoteServerSettingsInput, validate_remote_settings}; + use std::path::PathBuf; + use std::sync::mpsc; + use std::time::Duration; + + use super::{ + RemoteServerSettingsInput, ServerConnectionManager, ServerConnectionState, + validate_remote_settings, + }; fn settings(base_url: &str) -> RemoteServerSettingsInput { RemoteServerSettingsInput { @@ -275,4 +417,42 @@ mod tests { whitespace.bearer_token = "0123456789abcdef 0123456789abcdef".to_owned(); assert!(validate_remote_settings(whitespace).is_err()); } + + #[test] + fn pending_connection_state_releases_waiters_when_initialization_completes() { + let state = ServerConnectionState::pending(); + let waiter_state = state.clone(); + let (started_sender, started_receiver) = mpsc::channel(); + let (result_sender, result_receiver) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + started_sender.send(()).expect("report waiter start"); + result_sender + .send(waiter_state.settings()) + .expect("report completed settings"); + }); + + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("waiter started"); + assert!( + result_receiver + .recv_timeout(Duration::from_millis(20)) + .is_err() + ); + + state.complete(ServerConnectionManager::unavailable( + "sidecar unavailable".to_owned(), + PathBuf::from("logs"), + PathBuf::from("state"), + )); + let settings = result_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("waiter released"); + assert_eq!(settings.mode, "local"); + assert_eq!( + settings.secure_storage_error.as_deref(), + Some("sidecar unavailable") + ); + waiter.join().expect("join waiter"); + } } diff --git a/apps/axkdeck/src-tauri/src/server_sidecar.rs b/apps/axkdeck/src-tauri/src/server_sidecar.rs index 2e491989..e479ebe0 100644 --- a/apps/axkdeck/src-tauri/src/server_sidecar.rs +++ b/apps/axkdeck/src-tauri/src/server_sidecar.rs @@ -14,6 +14,7 @@ use url::{Host, Url}; #[path = "server_sidecar_runtime.rs"] mod runtime; +use crate::startup_diagnostics::{ServerOutcome, StartupDiagnostics, StartupMilestone}; use runtime::PrivateRuntimeDirectory; const CONNECTION_SCHEMA_VERSION: u32 = 1; @@ -115,23 +116,59 @@ impl ServerSidecar { log_directory: &Path, state_directory: &Path, ) -> Result, String> { + Self::launch_if_available_inner(log_directory, state_directory, None) + } + + pub fn launch_at_startup( + log_directory: &Path, + state_directory: &Path, + startup: &StartupDiagnostics, + ) -> Result, String> { + Self::launch_if_available_inner(log_directory, state_directory, Some(startup)) + } + + fn launch_if_available_inner( + log_directory: &Path, + state_directory: &Path, + startup: Option<&StartupDiagnostics>, + ) -> Result, String> { + if let Some(startup) = startup { + startup.record(StartupMilestone::SidecarStartupStarted); + } if std::env::var("AXKDECK_HTTP_SERVER").is_ok_and(|value| { matches!( value.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" ) }) { + complete_sidecar_startup(startup, ServerOutcome::Disabled); return Ok(None); } let Some(binary) = server_binary() else { + complete_sidecar_startup(startup, ServerOutcome::BinaryUnavailable); return Ok(None); }; - Self::launch(&binary, log_directory, state_directory).map(Some) + match Self::launch(&binary, log_directory, state_directory, startup) { + Ok(sidecar) => { + complete_sidecar_startup(startup, ServerOutcome::LocalReady); + Ok(Some(sidecar)) + } + Err(error) => { + complete_sidecar_startup(startup, ServerOutcome::Failed); + Err(error) + } + } } - fn launch(binary: &Path, log_directory: &Path, state_directory: &Path) -> Result { + fn launch( + binary: &Path, + log_directory: &Path, + state_directory: &Path, + startup: Option<&StartupDiagnostics>, + ) -> Result { prepare_persistent_state_directory(state_directory)?; let runtime_directory = PrivateRuntimeDirectory::create(&std::env::temp_dir())?; + record_startup(startup, StartupMilestone::SidecarStatePrepared); let connection_path = runtime_directory.connection_path(); let arguments = sidecar_arguments(state_directory, &connection_path); let mut command = Command::new(binary); @@ -147,6 +184,7 @@ impl ServerSidecar { let mut child = command .spawn() .map_err(|error| format!("start axklib-server: {error}"))?; + record_startup(startup, StartupMilestone::SidecarSpawned); let log_threads = match capture_child_logs(&mut child, log_directory) { Ok(threads) => threads, Err(error) => { @@ -166,6 +204,7 @@ impl ServerSidecar { return Err(error); } }; + record_startup(startup, StartupMilestone::SidecarReadinessReceived); if let Err(error) = validate_connection( &metadata, child.id(), @@ -179,6 +218,7 @@ impl ServerSidecar { join_log_threads(log_threads); return Err(error); } + record_startup(startup, StartupMilestone::SidecarConnectionValidated); let connection = FrontendConnection { base_url: metadata.base_url, bearer_token: metadata.bearer_token, @@ -218,6 +258,19 @@ impl ServerSidecar { } } +fn record_startup(startup: Option<&StartupDiagnostics>, milestone: StartupMilestone) { + if let Some(startup) = startup { + startup.record(milestone); + } +} + +fn complete_sidecar_startup(startup: Option<&StartupDiagnostics>, outcome: ServerOutcome) { + if let Some(startup) = startup { + startup.set_server_outcome(outcome); + startup.record(StartupMilestone::SidecarStartupCompleted); + } +} + impl Drop for ServerSidecar { fn drop(&mut self) { if let Ok(mut child) = self.child.lock() { diff --git a/apps/axkdeck/src-tauri/src/startup_diagnostics.rs b/apps/axkdeck/src-tauri/src/startup_diagnostics.rs new file mode 100644 index 00000000..8b44518a --- /dev/null +++ b/apps/axkdeck/src-tauri/src/startup_diagnostics.rs @@ -0,0 +1,478 @@ +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tauri::State; + +const FRONTEND_SCHEMA_VERSION: u32 = 2; +const MAXIMUM_FRONTEND_MILLISECONDS: f64 = 60.0 * 60.0 * 1000.0; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StartupMilestone { + NativeEntry, + PlatformConfigurationCompleted, + LogPluginBuildStarted, + LogPluginBuildCompleted, + TauriBuilderConfigured, + TauriSetupStarted, + PathsResolved, + PreferencesLoadStarted, + PreferencesLoadCompleted, + CredentialLookupStarted, + CredentialLookupCompleted, + SidecarStartupStarted, + SidecarStatePrepared, + SidecarSpawned, + SidecarReadinessReceived, + SidecarConnectionValidated, + SidecarStartupCompleted, + TauriSetupCompleted, + PageLoadStarted, + PageLoadFinished, +} + +impl StartupMilestone { + fn name(self) -> &'static str { + match self { + Self::NativeEntry => "native_entry", + Self::PlatformConfigurationCompleted => "platform_configuration_completed", + Self::LogPluginBuildStarted => "log_plugin_build_started", + Self::LogPluginBuildCompleted => "log_plugin_build_completed", + Self::TauriBuilderConfigured => "tauri_builder_configured", + Self::TauriSetupStarted => "tauri_setup_started", + Self::PathsResolved => "paths_resolved", + Self::PreferencesLoadStarted => "preferences_load_started", + Self::PreferencesLoadCompleted => "preferences_load_completed", + Self::CredentialLookupStarted => "credential_lookup_started", + Self::CredentialLookupCompleted => "credential_lookup_completed", + Self::SidecarStartupStarted => "sidecar_startup_started", + Self::SidecarStatePrepared => "sidecar_state_prepared", + Self::SidecarSpawned => "sidecar_spawned", + Self::SidecarReadinessReceived => "sidecar_readiness_received", + Self::SidecarConnectionValidated => "sidecar_connection_validated", + Self::SidecarStartupCompleted => "sidecar_startup_completed", + Self::TauriSetupCompleted => "tauri_setup_completed", + Self::PageLoadStarted => "page_load_started", + Self::PageLoadFinished => "page_load_finished", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialOutcome { + Found, + Missing, + Error, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerOutcome { + RemoteReady, + LocalReady, + Disabled, + BinaryUnavailable, + Failed, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontendStartupMetrics { + schema_version: u32, + view: StartupView, + module_evaluated_ms: f64, + diagnostics_installed_ms: f64, + interface_scale_complete_ms: f64, + shell_mounted_ms: f64, + shell_first_frame_ms: f64, + app_module_ready_ms: f64, + server_connection_complete_ms: Option, + app_mounted_ms: f64, + app_first_frame_ms: f64, + navigation_duration_ms: Option, + first_contentful_paint_ms: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum StartupView { + Workspace, + Allocation, +} + +#[derive(Clone, Copy, Debug)] +struct MilestoneEntry { + milestone: StartupMilestone, + elapsed: Duration, +} + +#[derive(Default)] +struct StartupState { + milestones: Vec, + credential_outcome: Option, + server_outcome: Option, + logging_enabled: bool, + completed: bool, +} + +#[derive(Clone)] +pub struct StartupDiagnostics { + origin: Instant, + state: Arc>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct StartupMilestoneEvent { + event: &'static str, + schema_version: u32, + milestone: &'static str, + elapsed_ms: u128, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StartupSummary { + event: &'static str, + schema_version: u32, + total_duration_ms: u128, + platform_configuration_completed_ms: Option, + log_plugin_build_ms: Option, + tauri_builder_configured_ms: Option, + setup_duration_ms: Option, + preferences_load_ms: Option, + credential_lookup_ms: Option, + sidecar_startup_ms: Option, + page_load_started_ms: Option, + page_load_finished_ms: Option, + credential_outcome: Option, + server_outcome: Option, + platform: &'static str, + architecture: &'static str, + build_profile: &'static str, + source_identity: &'static str, + frontend: FrontendStartupSummary, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FrontendStartupSummary { + view: StartupView, + module_evaluated_ms: f64, + diagnostics_installed_ms: f64, + interface_scale_complete_ms: f64, + shell_mounted_ms: f64, + shell_first_frame_ms: f64, + app_module_ready_ms: f64, + server_connection_complete_ms: Option, + app_mounted_ms: f64, + app_first_frame_ms: f64, + navigation_duration_ms: Option, + first_contentful_paint_ms: Option, +} + +impl StartupDiagnostics { + pub fn new() -> Self { + Self { + origin: Instant::now(), + state: Arc::new(Mutex::new(StartupState::default())), + } + } + + pub fn record(&self, milestone: StartupMilestone) { + self.record_at(milestone, self.origin.elapsed()); + } + + fn record_at(&self, milestone: StartupMilestone, elapsed: Duration) { + let should_log = { + let Ok(mut state) = self.state.lock() else { + return; + }; + if state + .milestones + .iter() + .any(|entry| entry.milestone == milestone) + { + return; + } + state.milestones.push(MilestoneEntry { milestone, elapsed }); + state.logging_enabled + }; + if should_log { + log_milestone(milestone, elapsed); + } + } + + pub fn enable_logging(&self) { + let entries = { + let Ok(mut state) = self.state.lock() else { + return; + }; + if state.logging_enabled { + return; + } + state.logging_enabled = true; + state.milestones.clone() + }; + for entry in entries { + log_milestone(entry.milestone, entry.elapsed); + } + } + + pub fn set_credential_outcome(&self, outcome: CredentialOutcome) { + if let Ok(mut state) = self.state.lock() { + state.credential_outcome = Some(outcome); + } + } + + pub fn set_server_outcome(&self, outcome: ServerOutcome) { + if let Ok(mut state) = self.state.lock() { + state.server_outcome = Some(outcome); + } + } + + fn complete(&self, metrics: FrontendStartupMetrics) -> Result<(), String> { + validate_frontend_metrics(&metrics)?; + let summary = self.summary(metrics, self.origin.elapsed())?; + let encoded = serde_json::to_string(&summary) + .map_err(|error| format!("encode desktop startup summary: {error}"))?; + log::info!("{encoded}"); + Ok(()) + } + + fn summary( + &self, + metrics: FrontendStartupMetrics, + elapsed: Duration, + ) -> Result { + let mut state = self + .state + .lock() + .map_err(|_| "desktop startup diagnostics are unavailable".to_owned())?; + if state.completed { + return Err("desktop startup diagnostics were already completed".to_owned()); + } + state.completed = true; + let duration = |start, end| milestone_duration(&state.milestones, start, end); + let milestone = |target| milestone_elapsed(&state.milestones, target); + Ok(StartupSummary { + event: "desktop_startup_completed", + schema_version: FRONTEND_SCHEMA_VERSION, + total_duration_ms: elapsed.as_millis(), + platform_configuration_completed_ms: milestone( + StartupMilestone::PlatformConfigurationCompleted, + ), + log_plugin_build_ms: duration( + StartupMilestone::LogPluginBuildStarted, + StartupMilestone::LogPluginBuildCompleted, + ), + tauri_builder_configured_ms: milestone(StartupMilestone::TauriBuilderConfigured), + setup_duration_ms: duration( + StartupMilestone::TauriSetupStarted, + StartupMilestone::TauriSetupCompleted, + ), + preferences_load_ms: duration( + StartupMilestone::PreferencesLoadStarted, + StartupMilestone::PreferencesLoadCompleted, + ), + credential_lookup_ms: duration( + StartupMilestone::CredentialLookupStarted, + StartupMilestone::CredentialLookupCompleted, + ), + sidecar_startup_ms: duration( + StartupMilestone::SidecarStartupStarted, + StartupMilestone::SidecarStartupCompleted, + ), + page_load_started_ms: milestone(StartupMilestone::PageLoadStarted), + page_load_finished_ms: milestone(StartupMilestone::PageLoadFinished), + credential_outcome: state.credential_outcome, + server_outcome: state.server_outcome, + platform: std::env::consts::OS, + architecture: std::env::consts::ARCH, + build_profile: if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + source_identity: env!("AXKDECK_SOURCE_IDENTITY"), + frontend: FrontendStartupSummary { + view: metrics.view, + module_evaluated_ms: metrics.module_evaluated_ms, + diagnostics_installed_ms: metrics.diagnostics_installed_ms, + interface_scale_complete_ms: metrics.interface_scale_complete_ms, + shell_mounted_ms: metrics.shell_mounted_ms, + shell_first_frame_ms: metrics.shell_first_frame_ms, + app_module_ready_ms: metrics.app_module_ready_ms, + server_connection_complete_ms: metrics.server_connection_complete_ms, + app_mounted_ms: metrics.app_mounted_ms, + app_first_frame_ms: metrics.app_first_frame_ms, + navigation_duration_ms: metrics.navigation_duration_ms, + first_contentful_paint_ms: metrics.first_contentful_paint_ms, + }, + }) + } +} + +fn milestone_elapsed(entries: &[MilestoneEntry], target: StartupMilestone) -> Option { + entries + .iter() + .find(|entry| entry.milestone == target) + .map(|entry| entry.elapsed.as_millis()) +} + +fn milestone_duration( + entries: &[MilestoneEntry], + start: StartupMilestone, + end: StartupMilestone, +) -> Option { + let start = entries + .iter() + .find(|entry| entry.milestone == start)? + .elapsed; + let end = entries.iter().find(|entry| entry.milestone == end)?.elapsed; + end.checked_sub(start).map(|duration| duration.as_millis()) +} + +fn validate_frontend_metrics(metrics: &FrontendStartupMetrics) -> Result<(), String> { + if metrics.schema_version != FRONTEND_SCHEMA_VERSION { + return Err("unsupported desktop startup metrics schema".to_owned()); + } + let ordered = [ + metrics.module_evaluated_ms, + metrics.shell_mounted_ms, + metrics.shell_first_frame_ms, + metrics.app_module_ready_ms, + metrics.app_mounted_ms, + metrics.app_first_frame_ms, + ]; + if ordered + .iter() + .any(|value| !value.is_finite() || *value < 0.0 || *value > MAXIMUM_FRONTEND_MILLISECONDS) + || ordered.windows(2).any(|pair| pair[0] > pair[1]) + { + return Err("desktop startup metrics are invalid or unordered".to_owned()); + } + for setup in [ + metrics.diagnostics_installed_ms, + metrics.interface_scale_complete_ms, + ] { + if !setup.is_finite() + || !(metrics.module_evaluated_ms..=metrics.app_mounted_ms).contains(&setup) + { + return Err("desktop startup setup metric is invalid".to_owned()); + } + } + if let Some(server) = metrics.server_connection_complete_ms { + if !server.is_finite() + || !(metrics.module_evaluated_ms..=metrics.app_mounted_ms).contains(&server) + { + return Err("desktop startup server metric is invalid".to_owned()); + } + } + for optional in [ + metrics.navigation_duration_ms, + metrics.first_contentful_paint_ms, + ] + .into_iter() + .flatten() + { + if !optional.is_finite() || !(0.0..=MAXIMUM_FRONTEND_MILLISECONDS).contains(&optional) { + return Err("desktop startup browser metrics are invalid".to_owned()); + } + } + Ok(()) +} + +fn log_milestone(milestone: StartupMilestone, elapsed: Duration) { + let event = StartupMilestoneEvent { + event: "desktop_startup_milestone", + schema_version: FRONTEND_SCHEMA_VERSION, + milestone: milestone.name(), + elapsed_ms: elapsed.as_millis(), + }; + if let Ok(encoded) = serde_json::to_string(&event) { + log::debug!("{encoded}"); + } +} + +#[tauri::command] +pub fn complete_startup( + metrics: FrontendStartupMetrics, + diagnostics: State<'_, StartupDiagnostics>, +) -> Result<(), String> { + diagnostics.complete(metrics) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_metrics() -> FrontendStartupMetrics { + FrontendStartupMetrics { + schema_version: FRONTEND_SCHEMA_VERSION, + view: StartupView::Workspace, + module_evaluated_ms: 2.0, + diagnostics_installed_ms: 4.0, + interface_scale_complete_ms: 6.0, + shell_mounted_ms: 3.0, + shell_first_frame_ms: 8.0, + app_module_ready_ms: 9.0, + server_connection_complete_ms: Some(8.5), + app_mounted_ms: 10.0, + app_first_frame_ms: 12.0, + navigation_duration_ms: Some(10.0), + first_contentful_paint_ms: Some(11.0), + } + } + + #[test] + fn validates_ordered_bounded_frontend_metrics() { + assert!(validate_frontend_metrics(&valid_metrics()).is_ok()); + let mut unordered = valid_metrics(); + unordered.app_mounted_ms = 3.0; + assert!(validate_frontend_metrics(&unordered).is_err()); + let mut unsupported = valid_metrics(); + unsupported.schema_version = 1; + assert!(validate_frontend_metrics(&unsupported).is_err()); + } + + #[test] + fn summarizes_each_startup_once_without_sensitive_values() { + let diagnostics = StartupDiagnostics::new(); + diagnostics.record_at( + StartupMilestone::TauriSetupStarted, + Duration::from_millis(2), + ); + diagnostics.record_at( + StartupMilestone::TauriSetupCompleted, + Duration::from_millis(7), + ); + diagnostics.set_credential_outcome(CredentialOutcome::Missing); + diagnostics.set_server_outcome(ServerOutcome::LocalReady); + let summary = diagnostics + .summary(valid_metrics(), Duration::from_millis(20)) + .expect("summarize startup"); + let encoded = serde_json::to_string(&summary).expect("encode startup summary"); + assert_eq!(summary.setup_duration_ms, Some(5)); + assert!(encoded.contains("desktop_startup_completed")); + for forbidden in ["bearerToken", "baseUrl", "logDirectory", "stateDirectory"] { + assert!(!encoded.contains(forbidden)); + } + assert!( + diagnostics + .summary(valid_metrics(), Duration::from_millis(21)) + .is_err() + ); + } + + #[test] + fn records_each_fixed_milestone_only_once() { + let diagnostics = StartupDiagnostics::new(); + diagnostics.record_at(StartupMilestone::NativeEntry, Duration::from_millis(1)); + diagnostics.record_at(StartupMilestone::NativeEntry, Duration::from_millis(9)); + let state = diagnostics.state.lock().expect("startup state"); + assert_eq!(state.milestones.len(), 1); + assert_eq!(state.milestones[0].elapsed, Duration::from_millis(1)); + } +} diff --git a/apps/axkdeck/src/App.svelte b/apps/axkdeck/src/App.svelte index f1697303..9d7b4bfc 100644 --- a/apps/axkdeck/src/App.svelte +++ b/apps/axkdeck/src/App.svelte @@ -1,11 +1,12 @@ + +
+
+
axkdeck
+ +
+ +
+
Programs0 items
+ {#if status === 'unavailable'} + + {:else} +

Starting services...

+ {/if} +
+ +
+ {status === 'starting' + ? 'Starting services...' + : 'Service unavailable'} +
+
+ +{#if warningOpen} + +{/if} + + diff --git a/apps/axkdeck/src/StartupShell.test.ts b/apps/axkdeck/src/StartupShell.test.ts new file mode 100644 index 00000000..704d353b --- /dev/null +++ b/apps/axkdeck/src/StartupShell.test.ts @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import StartupShell from './StartupShell.svelte'; + +describe('StartupShell', () => { + it('paints a stable starting state while retaining the startup warning', () => { + render(StartupShell, { + props: { onacknowledge: vi.fn(), onretry: vi.fn(), onopensettings: vi.fn() }, + }); + expect(screen.getAllByText('Starting services...')).toHaveLength(2); + expect(screen.getByRole('dialog', { name: 'Experimental software' })).toBeTruthy(); + }); + + it('offers recovery without leaving a blank window', async () => { + const onretry = vi.fn(); + const onopensettings = vi.fn(); + render(StartupShell, { + props: { + status: 'unavailable', + message: 'Connection timed out', + warningOpen: false, + onacknowledge: vi.fn(), + onretry, + onopensettings, + }, + }); + expect(screen.getByRole('alert').textContent).toContain('Connection timed out'); + await fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await fireEvent.click(screen.getByRole('button', { name: 'Connection settings' })); + expect(onretry).toHaveBeenCalledOnce(); + expect(onopensettings).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/axkdeck/src/appProps.ts b/apps/axkdeck/src/appProps.ts new file mode 100644 index 00000000..e704fe91 --- /dev/null +++ b/apps/axkdeck/src/appProps.ts @@ -0,0 +1,7 @@ +import type { InterfaceScaleController } from './lib/interfaceScale'; + +export interface AppProps { + interfaceScaling?: InterfaceScaleController | null; + initialExperimentalWarningOpen?: boolean; + openConnectionSettingsOnStart?: boolean; +} diff --git a/apps/axkdeck/src/features/dialogs/visibility.ts b/apps/axkdeck/src/features/dialogs/visibility.ts new file mode 100644 index 00000000..7e470e16 --- /dev/null +++ b/apps/axkdeck/src/features/dialogs/visibility.ts @@ -0,0 +1,83 @@ +interface ImageSessionDialogState { + companionRequest: unknown; + hardDiskDirectory: unknown; +} + +interface MutationDialogState { + volumeAction: unknown; + placementRepairRequest: unknown; + objectRenameRequest: unknown; + sampleBankCreationRequest: unknown; + sampleBankAssignmentRequest: unknown; +} + +interface DeletionDialogState { + objectRequest: unknown; + cleanupRequest: unknown; +} + +interface MediaDropDialogState { + notice: unknown; + dragActive: boolean; +} + +interface RequestDialogState { + request: unknown; +} + +interface ExportDialogState { + packageRequest: unknown; + audioRequest: unknown; + sequenceRequest: unknown; +} + +interface AppDialogState { + pickerRequest: unknown; + imageSession: ImageSessionDialogState; + workspaceManagerOpen: boolean; + connectionSettings: unknown; + mutation: MutationDialogState; + packageImport: RequestDialogState; + packageBatchImport: RequestDialogState; + exports: ExportDialogState; + volumePackages: RequestDialogState; + volumeFloppies: RequestDialogState; + mediaExports: RequestDialogState; + deletion: DeletionDialogState; + programGeneration: RequestDialogState; + audioImport: RequestDialogState; + sequenceImport: RequestDialogState; + tx16wImport: RequestDialogState; + mediaDrop: MediaDropDialogState; +} + +export function hasOpenAppDialog(state: AppDialogState): boolean { + return [ + state.pickerRequest, + state.imageSession.companionRequest, + state.imageSession.hardDiskDirectory, + state.workspaceManagerOpen, + state.connectionSettings, + state.mutation.volumeAction, + state.mutation.placementRepairRequest, + state.mutation.objectRenameRequest, + state.mutation.sampleBankCreationRequest, + state.mutation.sampleBankAssignmentRequest, + state.packageImport.request, + state.packageBatchImport.request, + state.exports.packageRequest, + state.volumePackages.request, + state.volumeFloppies.request, + state.exports.audioRequest, + state.exports.sequenceRequest, + state.mediaExports.request, + state.deletion.objectRequest, + state.deletion.cleanupRequest, + state.programGeneration.request, + state.audioImport.request, + state.sequenceImport.request, + state.tx16wImport.request, + state.mediaDrop.notice, + state.mediaDrop.dragActive, + ].some(Boolean); +} diff --git a/apps/axkdeck/src/features/workspace/tabs.ts b/apps/axkdeck/src/features/workspace/tabs.ts new file mode 100644 index 00000000..347be5cc --- /dev/null +++ b/apps/axkdeck/src/features/workspace/tabs.ts @@ -0,0 +1,15 @@ +import type { WorkspaceView } from '../../lib/types'; + +export interface WorkspaceTab { + id: WorkspaceView; + label: string; + icon: 'music' | 'layers' | 'archive' | 'waveform' | 'list'; +} + +export const workspaceTabs: WorkspaceTab[] = [ + { id: 'programs', label: 'Programs', icon: 'music' }, + { id: 'sample-banks', label: 'Sample Banks', icon: 'layers' }, + { id: 'samples', label: 'Samples', icon: 'archive' }, + { id: 'wave-data', label: 'Wave Data', icon: 'waveform' }, + { id: 'sequences', label: 'Sequences', icon: 'list' }, +]; diff --git a/apps/axkdeck/src/lib/diagnostics.ts b/apps/axkdeck/src/lib/diagnostics.ts index be978122..57d024f2 100644 --- a/apps/axkdeck/src/lib/diagnostics.ts +++ b/apps/axkdeck/src/lib/diagnostics.ts @@ -62,6 +62,16 @@ export function reportDiagnostic( } } +export function reportMutationTiming(operation: string, started: number, itemCount: number): void { + if (!diagnosticsEnabled()) return; + reportDiagnostic('image_mutation_completed', { + operation, + itemCount, + durationMs: Math.round(performance.now() - started), + strategy: 'journaled-in-place', + }); +} + export async function installDiagnostics(): Promise { window.addEventListener('error', (event) => { reportError('Unhandled frontend error', event.error ?? event.message); diff --git a/apps/axkdeck/src/lib/startupCoordinator.test.ts b/apps/axkdeck/src/lib/startupCoordinator.test.ts new file mode 100644 index 00000000..11da47a1 --- /dev/null +++ b/apps/axkdeck/src/lib/startupCoordinator.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { prepareServerConnection, prepareStartup } from './startupCoordinator'; + +describe('startup coordinator', () => { + it('starts the connection before paint and defers the application module until after paint', async () => { + let finishPaint!: () => void; + const paint = new Promise((resolve) => (finishPaint = resolve)); + const connection = Promise.resolve('connected'); + const loadModule = vi.fn().mockResolvedValue('module'); + + const prepared = prepareStartup(connection, () => paint, loadModule); + expect(loadModule).not.toHaveBeenCalled(); + + finishPaint(); + await Promise.resolve(); + expect(loadModule).toHaveBeenCalledOnce(); + await expect(prepared).resolves.toEqual({ connection: 'connected', module: 'module' }); + }); + + it('restarts a failed local server before retrying its connection', async () => { + const calls: string[] = []; + const useLocalServer = vi.fn(async () => { + calls.push('restart'); + }); + const loadConnection = vi.fn(async () => { + calls.push('connect'); + return 'connected'; + }); + + await expect(prepareServerConnection(true, useLocalServer, loadConnection)).resolves.toBe('connected'); + expect(calls).toEqual(['restart', 'connect']); + }); + + it('does not alter local server configuration during normal startup', async () => { + const useLocalServer = vi.fn(); + const loadConnection = vi.fn().mockResolvedValue('connected'); + + await expect(prepareServerConnection(false, useLocalServer, loadConnection)).resolves.toBe('connected'); + expect(useLocalServer).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/axkdeck/src/lib/startupCoordinator.ts b/apps/axkdeck/src/lib/startupCoordinator.ts new file mode 100644 index 00000000..c0029cd4 --- /dev/null +++ b/apps/axkdeck/src/lib/startupCoordinator.ts @@ -0,0 +1,19 @@ +export async function prepareStartup( + connection: Promise, + waitForShellPaint: () => Promise, + loadModule: () => Promise, +): Promise<{ connection: TConnection; module: TModule }> { + await waitForShellPaint(); + const modulePromise = loadModule(); + const [connectionResult, moduleResult] = await Promise.all([connection, modulePromise]); + return { connection: connectionResult, module: moduleResult }; +} + +export async function prepareServerConnection( + restartLocal: boolean, + useLocalServer: () => Promise, + loadConnection: () => Promise, +): Promise { + if (restartLocal) await useLocalServer(); + return loadConnection(); +} diff --git a/apps/axkdeck/src/lib/startupDiagnostics.test.ts b/apps/axkdeck/src/lib/startupDiagnostics.test.ts new file mode 100644 index 00000000..89b124f0 --- /dev/null +++ b/apps/axkdeck/src/lib/startupDiagnostics.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { FrontendStartupRecorder } from './startupDiagnostics'; + +describe('frontend startup diagnostics', () => { + it('distinguishes the shell paint from application readiness', async () => { + let now = 4; + const recorder = new FrontendStartupRecorder({ + now: () => now, + getEntriesByType: () => [], + }); + const frames: FrameRequestCallback[] = []; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + const submit = vi.fn().mockResolvedValue(undefined); + + now = 9; + recorder.markShellMounted(); + now = 12; + recorder.markDiagnosticsInstalled(); + now = 14; + recorder.markInterfaceScaleComplete(); + + const shellPaint = recorder.waitForShellFirstFrame(requestFrame); + now = 20; + frames.shift()?.(now); + await Promise.resolve(); + now = 24; + frames.shift()?.(now); + await shellPaint; + + now = 29; + recorder.markServerConnectionComplete(); + now = 34; + recorder.markAppModuleReady(); + now = 38; + recorder.markAppMounted(); + + const reporting = recorder.reportAfterAppFirstFrame('workspace', submit, requestFrame); + now = 42; + frames.shift()?.(now); + await Promise.resolve(); + now = 47; + frames.shift()?.(now); + await reporting; + + expect(submit).toHaveBeenCalledWith({ + schemaVersion: 2, + view: 'workspace', + moduleEvaluatedMs: 4, + diagnosticsInstalledMs: 12, + interfaceScaleCompleteMs: 14, + shellMountedMs: 9, + shellFirstFrameMs: 24, + appModuleReadyMs: 34, + serverConnectionCompleteMs: 29, + appMountedMs: 38, + appFirstFrameMs: 47, + navigationDurationMs: null, + firstContentfulPaintMs: null, + }); + }); + + it('allows views that do not require a server connection', async () => { + const recorder = new FrontendStartupRecorder({ + now: () => 20, + getEntriesByType: (type) => { + if (type === 'navigation') return [{ duration: 18 }] as PerformanceEntry[]; + if (type === 'paint') { + return [{ name: 'first-contentful-paint', startTime: 12 }] as PerformanceEntry[]; + } + return []; + }, + }); + recorder.markShellMounted(); + recorder.markDiagnosticsInstalled(); + recorder.markInterfaceScaleComplete(); + const requestFrame = (callback: FrameRequestCallback) => { + callback(20); + return 1; + }; + await recorder.waitForShellFirstFrame(requestFrame); + recorder.markAppModuleReady(); + recorder.markAppMounted(); + const submit = vi.fn().mockResolvedValue(undefined); + + await recorder.reportAfterAppFirstFrame('allocation', submit, requestFrame); + + expect(submit.mock.calls[0]?.[0]).toMatchObject({ + view: 'allocation', + serverConnectionCompleteMs: null, + navigationDurationMs: 18, + firstContentfulPaintMs: 12, + }); + }); +}); diff --git a/apps/axkdeck/src/lib/startupDiagnostics.ts b/apps/axkdeck/src/lib/startupDiagnostics.ts new file mode 100644 index 00000000..7f915b90 --- /dev/null +++ b/apps/axkdeck/src/lib/startupDiagnostics.ts @@ -0,0 +1,130 @@ +import { invoke } from '@tauri-apps/api/core'; + +export type StartupView = 'workspace' | 'allocation'; + +export interface FrontendStartupMetrics { + schemaVersion: 2; + view: StartupView; + moduleEvaluatedMs: number; + diagnosticsInstalledMs: number; + interfaceScaleCompleteMs: number; + shellMountedMs: number; + shellFirstFrameMs: number; + appModuleReadyMs: number; + serverConnectionCompleteMs: number | null; + appMountedMs: number; + appFirstFrameMs: number; + navigationDurationMs: number | null; + firstContentfulPaintMs: number | null; +} + +interface StartupPerformance { + now(): number; + getEntriesByType(type: string): PerformanceEntry[]; +} + +type StartupSubmitter = (metrics: FrontendStartupMetrics) => Promise; +type FrameRequester = (callback: FrameRequestCallback) => number; + +function finiteTiming(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +} + +function entryTiming(entries: PerformanceEntry[], name: string, property: 'duration' | 'startTime'): number | null { + const entry = entries.find((candidate) => candidate.name === name) ?? entries[0]; + return finiteTiming(entry?.[property]); +} + +function afterTwoFrames(requestFrame: FrameRequester): Promise { + return new Promise((resolve) => requestFrame(() => requestFrame(() => resolve()))); +} + +export class FrontendStartupRecorder { + private readonly moduleEvaluatedMs: number; + private diagnosticsInstalledMs: number | null = null; + private interfaceScaleCompleteMs: number | null = null; + private shellMountedMs: number | null = null; + private shellFirstFrameMs: number | null = null; + private appModuleReadyMs: number | null = null; + private serverConnectionCompleteMs: number | null = null; + private appMountedMs: number | null = null; + private shellPaint: Promise | null = null; + private reporting: Promise | null = null; + + constructor(private readonly performanceSource: StartupPerformance) { + this.moduleEvaluatedMs = this.performanceSource.now(); + } + + markDiagnosticsInstalled(): void { + this.diagnosticsInstalledMs ??= this.performanceSource.now(); + } + + markInterfaceScaleComplete(): void { + this.interfaceScaleCompleteMs ??= this.performanceSource.now(); + } + + markShellMounted(): void { + this.shellMountedMs ??= this.performanceSource.now(); + } + + waitForShellFirstFrame(requestFrame: FrameRequester = window.requestAnimationFrame.bind(window)): Promise { + if (!this.shellPaint) { + this.shellPaint = afterTwoFrames(requestFrame).then(() => { + this.shellFirstFrameMs ??= this.performanceSource.now(); + }); + } + return this.shellPaint; + } + + markAppModuleReady(): void { + this.appModuleReadyMs ??= this.performanceSource.now(); + } + + markServerConnectionComplete(): void { + this.serverConnectionCompleteMs ??= this.performanceSource.now(); + } + + markAppMounted(): void { + this.appMountedMs ??= this.performanceSource.now(); + } + + reportAfterAppFirstFrame( + view: StartupView, + submit: StartupSubmitter = (metrics) => invoke('complete_startup', { metrics }), + requestFrame: FrameRequester = window.requestAnimationFrame.bind(window), + ): Promise { + if (!this.reporting) this.reporting = this.report(view, submit, requestFrame); + return this.reporting; + } + + private async report(view: StartupView, submit: StartupSubmitter, requestFrame: FrameRequester): Promise { + await afterTwoFrames(requestFrame); + const appFirstFrameMs = this.performanceSource.now(); + const fallback = appFirstFrameMs; + await submit({ + schemaVersion: 2, + view, + moduleEvaluatedMs: this.moduleEvaluatedMs, + diagnosticsInstalledMs: this.diagnosticsInstalledMs ?? fallback, + interfaceScaleCompleteMs: this.interfaceScaleCompleteMs ?? fallback, + shellMountedMs: this.shellMountedMs ?? fallback, + shellFirstFrameMs: this.shellFirstFrameMs ?? fallback, + appModuleReadyMs: this.appModuleReadyMs ?? fallback, + serverConnectionCompleteMs: this.serverConnectionCompleteMs, + appMountedMs: this.appMountedMs ?? fallback, + appFirstFrameMs, + navigationDurationMs: entryTiming( + this.performanceSource.getEntriesByType('navigation'), + 'navigation', + 'duration', + ), + firstContentfulPaintMs: entryTiming( + this.performanceSource.getEntriesByType('paint'), + 'first-contentful-paint', + 'startTime', + ), + }); + } +} + +export const frontendStartup = new FrontendStartupRecorder(window.performance); diff --git a/apps/axkdeck/src/main.ts b/apps/axkdeck/src/main.ts index 6f5b5647..f293584d 100644 --- a/apps/axkdeck/src/main.ts +++ b/apps/axkdeck/src/main.ts @@ -1,23 +1,73 @@ import './app.css'; -import { mount } from 'svelte'; -import App from './App.svelte'; -import AllocationInspector from './AllocationInspector.svelte'; import { invoke } from '@tauri-apps/api/core'; +import { mount, unmount } from 'svelte'; +import StartupShell from './StartupShell.svelte'; import { installDiagnostics, reportDiagnostic, reportError, reportInfo } from './lib/diagnostics'; import { createInterfaceScaleController, type InterfaceScaleController } from './lib/interfaceScale'; +import { prepareServerConnection, prepareStartup } from './lib/startupCoordinator'; +import { frontendStartup, type StartupView } from './lib/startupDiagnostics'; import { createTauriInterfaceScaleAdapter } from './lib/tauriInterfaceScale'; +type ServerConnection = NonNullable; +type AppModule = typeof import('./App.svelte'); + const target = document.getElementById('app'); +if (!target) throw new Error('Unable to find the application mount point.'); -if (!target) { - throw new Error('Unable to find the application mount point.'); +async function connectServer(restartLocal: boolean): Promise { + try { + const connection = await prepareServerConnection( + restartLocal, + () => invoke('use_local_server'), + async () => (await invoke('server_connection')) ?? null, + ); + window.__AXKLIB_SERVER__ = connection ?? undefined; + frontendStartup.markServerConnectionComplete(); + reportInfo( + connection ? `Connected to ${connection.mode} axklib-server.` : 'No axklib-server connection is available.', + ); + return connection; + } catch (error) { + window.__AXKLIB_SERVER__ = undefined; + frontendStartup.markServerConnectionComplete(); + reportError('axklib-server is unavailable', error); + return null; + } } async function bootstrap(mountTarget: HTMLElement): Promise { - await installDiagnostics(); const isDesktop = '__TAURI_INTERNALS__' in window; let interfaceScaling: InterfaceScaleController | null = null; - if (isDesktop) { + let warningOpen = true; + let shellStatus: 'starting' | 'unavailable' = 'starting'; + let shellMessage = ''; + let shell: ReturnType | null = null; + const renderShell = () => { + shell = mount(StartupShell, { + target: mountTarget, + props: { + status: shellStatus, + message: shellMessage, + warningOpen, + onacknowledge: () => { + warningOpen = false; + void replaceShell(); + }, + onretry: () => void startWorkspace(false, true), + onopensettings: () => void startWorkspace(true), + }, + }); + }; + const replaceShell = async () => { + if (shell) await unmount(shell); + renderShell(); + }; + renderShell(); + frontendStartup.markShellMounted(); + const shellFirstFrame = frontendStartup.waitForShellFirstFrame(); + const diagnosticsReady = installDiagnostics().finally(() => frontendStartup.markDiagnosticsInstalled()); + const interfaceScalingReady = (async () => { + if (!isDesktop) return; try { interfaceScaling = await createInterfaceScaleController( createTauriInterfaceScaleAdapter(), @@ -27,27 +77,78 @@ async function bootstrap(mountTarget: HTMLElement): Promise { } catch (error) { reportDiagnostic('interface_scale_initialization_failed', { message: String(error) }, 'warn'); } - } - if (isDesktop) { + })().finally(() => frontendStartup.markInterfaceScaleComplete()); + + const view: StartupView = + new URLSearchParams(window.location.search).get('view') === 'allocation' ? 'allocation' : 'workspace'; + if (view === 'allocation') { try { - window.__AXKLIB_SERVER__ = - (await invoke('server_connection')) ?? undefined; - reportInfo( - window.__AXKLIB_SERVER__ - ? `Connected to ${window.__AXKLIB_SERVER__.mode} axklib-server.` - : 'No axklib-server connection is available.', + const moduleReady = shellFirstFrame.then(() => + import('./AllocationInspector.svelte').then((module) => { + frontendStartup.markAppModuleReady(); + return module; + }), ); + const [module] = await Promise.all([moduleReady, diagnosticsReady, interfaceScalingReady]); + if (shell) await unmount(shell); + mount(module.default, { target: mountTarget }); + frontendStartup.markAppMounted(); } catch (error) { - window.__AXKLIB_SERVER__ = undefined; - reportError('axklib-server is unavailable', error); + shellStatus = 'unavailable'; + shellMessage = `The allocation inspector could not be loaded: ${String(error)}`; + await replaceShell(); } + return; } - const view = new URLSearchParams(window.location.search).get('view'); - if (view === 'allocation') { - mount(AllocationInspector, { target: mountTarget }); - } else { - mount(App, { target: mountTarget, props: { interfaceScaling } }); + + async function startWorkspace(openSettings: boolean, restartLocal = false): Promise { + const refreshShell = shellStatus !== 'starting' || shellMessage.length > 0; + shellStatus = 'starting'; + shellMessage = ''; + if (refreshShell) await replaceShell(); + const connectionPromise = isDesktop ? connectServer(restartLocal) : Promise.resolve(null); + let prepared: { connection: ServerConnection | null; module: AppModule }; + try { + const workspaceReady = prepareStartup( + connectionPromise, + () => shellFirstFrame, + () => + import('./App.svelte').then((module) => { + frontendStartup.markAppModuleReady(); + return module; + }), + ); + [prepared] = await Promise.all([workspaceReady, diagnosticsReady, interfaceScalingReady]); + } catch (error) { + shellStatus = 'unavailable'; + shellMessage = `The application workspace could not be loaded: ${String(error)}`; + await replaceShell(); + return; + } + if (isDesktop && !prepared.connection && !openSettings) { + shellStatus = 'unavailable'; + shellMessage = 'Check the local service or configure a remote axklib-server connection.'; + await replaceShell(); + return; + } + if (shell) await unmount(shell); + mount(prepared.module.default, { + target: mountTarget, + props: { + interfaceScaling, + initialExperimentalWarningOpen: warningOpen, + openConnectionSettingsOnStart: openSettings, + }, + }); + frontendStartup.markAppMounted(); + if (isDesktop) { + void frontendStartup.reportAfterAppFirstFrame('workspace').catch((error) => { + reportDiagnostic('desktop_startup_report_failed', { message: String(error) }, 'warn'); + }); + } } + + await startWorkspace(false); } void bootstrap(target); diff --git a/apps/axkdeck/tools/check-startup-bundle.mjs b/apps/axkdeck/tools/check-startup-bundle.mjs new file mode 100644 index 00000000..fe158471 --- /dev/null +++ b/apps/axkdeck/tools/check-startup-bundle.mjs @@ -0,0 +1,39 @@ +import { readFile, stat } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const MAXIMUM_STARTUP_BYTES = 150 * 1024; + +function localAssetPath(directory, asset) { + return path.join(directory, asset.replace(/^\//, '')); +} + +export async function checkStartupBundle(directory) { + const indexPath = path.join(directory, 'index.html'); + const html = await readFile(indexPath, 'utf8'); + const entry = html.match(/]+src="([^"]+)"/)?.[1]; + if (!entry) throw new Error(`No JavaScript entry was found in ${indexPath}`); + const entryPath = localAssetPath(directory, entry); + const entryBytes = (await stat(entryPath)).size; + const preloads = [...html.matchAll(/]+rel="modulepreload"[^>]+href="([^"]+)"/g)].map((match) => match[1]); + const preloadBytes = await Promise.all( + preloads.map(async (asset) => (await stat(localAssetPath(directory, asset))).size), + ); + const startupBytes = entryBytes + preloadBytes.reduce((total, bytes) => total + bytes, 0); + if (startupBytes > MAXIMUM_STARTUP_BYTES) { + throw new Error( + `Startup JavaScript is ${startupBytes} bytes; the ${MAXIMUM_STARTUP_BYTES}-byte budget requires the workspace to remain dynamically loaded.`, + ); + } + const source = await readFile(entryPath, 'utf8'); + if (!source.includes('App-') || !source.includes('import(')) { + throw new Error('The generated startup entry does not dynamically load the workspace application.'); + } + process.stdout.write( + `Startup bundle check passed: ${startupBytes} bytes (${path.basename(entryPath)} plus ${preloads.length} preload${preloads.length === 1 ? '' : 's'})\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + await checkStartupBundle(fileURLToPath(new URL('../dist', import.meta.url))); +} diff --git a/apps/server/CMakeLists.txt b/apps/server/CMakeLists.txt index b62ce376..be3ed549 100644 --- a/apps/server/CMakeLists.txt +++ b/apps/server/CMakeLists.txt @@ -4,11 +4,12 @@ get_filename_component(AXK_REPOSITORY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." A find_package(Crow CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) -find_package(nlohmann_json_schema_validator CONFIG REQUIRED) set(AXK_SERVER_OPENAPI_BASE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/contracts/openapi-v1.base.json") set(AXK_SERVER_OPENAPI_PATH "${CMAKE_CURRENT_SOURCE_DIR}/contracts/openapi-v1.json") set(AXK_SERVER_GENERATED_OPENAPI_PATH "${CMAKE_CURRENT_BINARY_DIR}/generated/openapi-v1.json") +set(AXK_SERVER_GENERATED_VALIDATION_PLAN_PATH + "${CMAKE_CURRENT_BINARY_DIR}/generated/validation_plan.cpp") set_property( DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${AXK_SERVER_OPENAPI_BASE_PATH}" "${AXK_SERVER_OPENAPI_PATH}" @@ -37,13 +38,40 @@ while(AXK_SERVER_OPENAPI_HEX_OFFSET LESS AXK_SERVER_OPENAPI_HEX_LENGTH) endwhile() configure_file(src/embedded_contract.cpp.in "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_contract.cpp" @ONLY) -add_library(axk_server_contract STATIC src/contract.cpp) +add_executable(axk_server_validation_plan_generator EXCLUDE_FROM_ALL src/validation_plan_generator.cpp) +target_compile_features(axk_server_validation_plan_generator PRIVATE cxx_std_23) +target_include_directories(axk_server_validation_plan_generator PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") +target_link_libraries(axk_server_validation_plan_generator PRIVATE nlohmann_json::nlohmann_json) +axk_set_project_warnings(axk_server_validation_plan_generator) +axk_enable_sanitizers(axk_server_validation_plan_generator) +axk_enable_coverage(axk_server_validation_plan_generator) +add_custom_command( + OUTPUT "${AXK_SERVER_GENERATED_VALIDATION_PLAN_PATH}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/generated" + COMMAND "$" "${AXK_SERVER_OPENAPI_PATH}" + "${AXK_SERVER_GENERATED_VALIDATION_PLAN_PATH}" + DEPENDS axk_server_validation_plan_generator "${AXK_SERVER_OPENAPI_PATH}" + "${CMAKE_CURRENT_SOURCE_DIR}/src/validation_plan.hpp" + COMMENT "Compiling the axklib-server OpenAPI validation plan" + VERBATIM +) + +add_library( + axk_server_contract + STATIC + src/contract.cpp + src/validation_plan.cpp + "${AXK_SERVER_GENERATED_VALIDATION_PLAN_PATH}" +) target_compile_features(axk_server_contract PUBLIC cxx_std_23) -target_include_directories(axk_server_contract PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_include_directories( + axk_server_contract + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" +) target_link_libraries( axk_server_contract PUBLIC axklib::application nlohmann_json::nlohmann_json - PRIVATE nlohmann_json_schema_validator::validator ) axk_set_project_warnings(axk_server_contract) axk_enable_sanitizers(axk_server_contract) @@ -189,12 +217,14 @@ if(BUILD_TESTING) endfunction() find_package(GTest CONFIG REQUIRED) + find_package(nlohmann_json_schema_validator CONFIG REQUIRED) find_package(Python3 COMPONENTS Interpreter REQUIRED) include(GoogleTest) add_executable( axk_server_tests tests/archive_download_budget_test.cpp tests/config_test.cpp + tests/contract_test_support.cpp tests/download_reader_test.cpp tests/event_delivery_budget_test.cpp tests/event_dispatcher_test.cpp @@ -212,7 +242,7 @@ if(BUILD_TESTING) target_compile_definitions(axk_server_tests PRIVATE AXK_SOURCE_ROOT="${AXK_REPOSITORY_ROOT}") target_link_libraries( axk_server_tests - PRIVATE axk_server_adapter GTest::gtest_main + PRIVATE axk_server_adapter GTest::gtest_main nlohmann_json_schema_validator::validator ) target_include_directories(axk_server_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") axk_set_project_warnings(axk_server_tests) @@ -279,7 +309,7 @@ endif() install(TARGETS axklib_server RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" COMPONENT server) install(FILES "${AXK_REPOSITORY_ROOT}/LICENSE" DESTINATION "${CMAKE_INSTALL_DATADIR}/licenses/axklib" COMPONENT server) -foreach(package crow asio json-schema-validator hash-library openssl nlohmann-json libsndfile soxr utfcpp +foreach(package crow asio hash-library openssl nlohmann-json libsndfile soxr utfcpp libflac libogg libvorbis opus fatfs) install( FILES "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/share/${package}/copyright" diff --git a/apps/server/include/axklib/server/contract.hpp b/apps/server/include/axklib/server/contract.hpp index 90009569..ce192ca5 100644 --- a/apps/server/include/axklib/server/contract.hpp +++ b/apps/server/include/axklib/server/contract.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #include @@ -12,30 +11,15 @@ namespace axk::server { class OpenApiValidator { public: - explicit OpenApiValidator(nlohmann::json document); - ~OpenApiValidator(); - - OpenApiValidator(const OpenApiValidator &) = delete; - OpenApiValidator &operator=(const OpenApiValidator &) = delete; - OpenApiValidator(OpenApiValidator &&) noexcept; - OpenApiValidator &operator=(OpenApiValidator &&) noexcept; + OpenApiValidator() = default; [[nodiscard]] app::Result validate(std::string_view schema_name, const nlohmann::json &value) const; [[nodiscard]] nlohmann::json application_value(std::string_view schema_name, const nlohmann::json &wire_value) const; [[nodiscard]] nlohmann::json wire_value(std::string_view schema_name, const nlohmann::json &application_value) const; - - private: - struct Impl; - std::unique_ptr impl_; }; [[nodiscard]] nlohmann::json build_openapi_document(std::string_view base_document, const app::OperationRegistry ®istry); -[[nodiscard]] app::Result validate_openapi_value(const nlohmann::json &document, std::string_view schema_name, - const nlohmann::json &value); -[[nodiscard]] app::Result validate_openapi_schema(const nlohmann::json &document, const nlohmann::json &schema, - const nlohmann::json &value); - } // namespace axk::server diff --git a/apps/server/src/contract.cpp b/apps/server/src/contract.cpp index 1c18f67a..5c9b9126 100644 --- a/apps/server/src/contract.cpp +++ b/apps/server/src/contract.cpp @@ -3,14 +3,13 @@ #include #include #include -#include #include #include #include #include #include -#include +#include "validation_plan.hpp" namespace { @@ -155,68 +154,6 @@ Json schema_example(const Json &document, const Json &schema, std::size_t depth return result; } -Json validation_root(const Json &document, const Json &schema) { - auto root = schema; - root["$schema"] = "http://json-schema.org/draft-07/schema#"; - root["components"] = document.at("components"); - return root; -} - -axk::app::Result validate_value(const Json &document, const Json &schema, const Json &value) { - try { - nlohmann::json_schema::json_validator validator; - validator.set_root_schema(validation_root(document, schema)); - static_cast(validator.validate(value)); - } catch (const std::exception &) { - return std::unexpected( - axk::app::Error{"invalid_request", "request body does not match the declared OpenAPI schema"}); - } - return {}; -} - -Json translate_value(const Json &document, const Json &schema, const Json &value, bool to_application) { - const auto &resolved = resolve_schema_reference(document, schema); - if (const auto mapping = resolved.find("x-axklib-application-enum"); - mapping != resolved.end() && mapping->is_object() && value.is_string()) { - const auto text = value.get(); - if (to_application) { - if (const auto found = mapping->find(text); found != mapping->end() && found->is_string()) - return *found; - } else { - for (const auto &[wire, application] : mapping->items()) { - if (application.is_string() && application.get_ref() == text) - return wire; - } - } - return value; - } - - auto result = value; - for (const auto keyword : {"allOf", "oneOf", "anyOf"}) { - const auto variants = resolved.find(keyword); - if (variants == resolved.end() || !variants->is_array()) - continue; - for (const auto &variant : *variants) - result = translate_value(document, variant, result, to_application); - } - if (result.is_object()) { - const auto properties = resolved.find("properties"); - if (properties != resolved.end() && properties->is_object()) { - for (const auto &[name, property_schema] : properties->items()) { - if (result.contains(name)) - result[name] = translate_value(document, property_schema, result.at(name), to_application); - } - } - } else if (result.is_array()) { - const auto items = resolved.find("items"); - if (items != resolved.end()) { - for (auto &item : result) - item = translate_value(document, *items, item, to_application); - } - } - return result; -} - void require_schema(const Json &document, std::string_view name) { auto &schemas = document["components"]["schemas"]; if (!schemas.contains(name)) @@ -358,49 +295,33 @@ void add_infrastructure_error_responses(Json &document) { } // namespace -struct axk::server::OpenApiValidator::Impl { - explicit Impl(Json value) : document(std::move(value)) { - for (const auto &[name, schema] : document.at("components").at("schemas").items()) { - auto validator = std::make_unique(); - validator->set_root_schema(validation_root(document, schema)); - validators.emplace(name, std::move(validator)); - } - } - - Json document; - std::map, std::less<>> validators; -}; - -axk::server::OpenApiValidator::OpenApiValidator(nlohmann::json document) - : impl_(std::make_unique(std::move(document))) {} - -axk::server::OpenApiValidator::~OpenApiValidator() = default; -axk::server::OpenApiValidator::OpenApiValidator(OpenApiValidator &&) noexcept = default; -axk::server::OpenApiValidator &axk::server::OpenApiValidator::operator=(OpenApiValidator &&) noexcept = default; - axk::app::Result axk::server::OpenApiValidator::validate(std::string_view schema_name, const nlohmann::json &value) const { - const auto found = impl_->validators.find(schema_name); - if (found == impl_->validators.end()) + const auto &plan = detail::generated_validation_plan(); + const auto *schema = detail::find_schema(plan, schema_name); + if (schema == nullptr) return std::unexpected(app::Error{"contract_error", "OpenAPI schema is not available"}); - try { - static_cast(found->second->validate(value)); - } catch (const std::exception &) { + if (!detail::validate(plan, *schema, value)) return std::unexpected(app::Error{"invalid_request", "request body does not match its declared schema"}); - } return {}; } nlohmann::json axk::server::OpenApiValidator::application_value(std::string_view schema_name, const nlohmann::json &wire_value) const { - const auto &schema = impl_->document.at("components").at("schemas").at(schema_name); - return translate_value(impl_->document, schema, wire_value, true); + const auto &plan = detail::generated_validation_plan(); + const auto *schema = detail::find_schema(plan, schema_name); + if (schema == nullptr) + throw std::out_of_range{"OpenAPI schema is not available"}; + return detail::translate(plan, *schema, wire_value, true); } nlohmann::json axk::server::OpenApiValidator::wire_value(std::string_view schema_name, const nlohmann::json &application_value) const { - const auto &schema = impl_->document.at("components").at("schemas").at(schema_name); - return translate_value(impl_->document, schema, application_value, false); + const auto &plan = detail::generated_validation_plan(); + const auto *schema = detail::find_schema(plan, schema_name); + if (schema == nullptr) + throw std::out_of_range{"OpenAPI schema is not available"}; + return detail::translate(plan, *schema, application_value, false); } nlohmann::json axk::server::build_openapi_document(std::string_view base_document, @@ -472,17 +393,3 @@ nlohmann::json axk::server::build_openapi_document(std::string_view base_documen add_request_id_headers(document); return document; } - -axk::app::Result axk::server::validate_openapi_value(const nlohmann::json &document, std::string_view schema_name, - const nlohmann::json &value) { - try { - return validate_value(document, schema_reference(schema_name), value); - } catch (const std::exception &) { - return std::unexpected(app::Error{"contract_error", "OpenAPI schema is not available"}); - } -} - -axk::app::Result axk::server::validate_openapi_schema(const nlohmann::json &document, - const nlohmann::json &schema, const nlohmann::json &value) { - return validate_value(document, schema, value); -} diff --git a/apps/server/src/server_lifecycle.cpp b/apps/server/src/server_lifecycle.cpp index 1b904ded..c723c55b 100644 --- a/apps/server/src/server_lifecycle.cpp +++ b/apps/server/src/server_lifecycle.cpp @@ -244,12 +244,11 @@ ServerApplication::ServerApplication(axk::server::Config config, axk::app::Opera {config_.maximum_media_build_object_bytes, config_.maximum_media_build_payload_bytes, config_.maximum_media_build_output_bytes})), openapi_document_(axk::server::build_openapi_document(axk::server::embedded_openapi(), registry_)), - openapi_validator_(openapi_document_), - jobs_( - registry_, config_.job_worker_threads, config_.write_job_worker_threads, config_.maximum_queued_jobs, - config_.replay_events_per_job, config_.maximum_retained_jobs, - std::chrono::seconds{config_.job_retention_seconds}, [] { return axk::app::JobManager::Clock::now(); }, - &uploads_, &path_reservations_), + openapi_validator_(), jobs_( + registry_, config_.job_worker_threads, config_.write_job_worker_threads, + config_.maximum_queued_jobs, config_.replay_events_per_job, + config_.maximum_retained_jobs, std::chrono::seconds{config_.job_retention_seconds}, + [] { return axk::app::JobManager::Clock::now(); }, &uploads_, &path_reservations_), event_tickets_(std::chrono::seconds{config_.event_ticket_ttl_seconds}, config_.maximum_event_tickets), event_dispatcher_(config_.maximum_websocket_delivery_events, [this](const axk::app::JobEvent &event) { broadcast(event); }) { diff --git a/apps/server/src/validation_plan.cpp b/apps/server/src/validation_plan.cpp new file mode 100644 index 00000000..d8e247e9 --- /dev/null +++ b/apps/server/src/validation_plan.cpp @@ -0,0 +1,252 @@ +#include "validation_plan.hpp" + +#include +#include +#include +#include +#include + +namespace axk::server::detail { +namespace { + +constexpr bool has_type(const SchemaNode &schema, JsonType type) { + return (schema.types & static_cast(type)) != 0U; +} + +const SchemaNode &resolved(const ValidationPlan &plan, const SchemaNode &schema) { + const SchemaNode *current = &schema; + for (std::size_t depth = 0U; depth < 64U && current->reference != no_schema; ++depth) + current = &plan.nodes[current->reference]; + return *current; +} + +bool ascii_range(std::string_view value, unsigned char low, unsigned char high) { + return std::ranges::all_of(value, [=](unsigned char character) { return character >= low && character <= high; }); +} + +bool digits_01_16(std::string_view value) { + return value.size() == 2U && ((value[0] == '0' && value[1] >= '1' && value[1] <= '9') || + (value[0] == '1' && value[1] >= '0' && value[1] <= '6')); +} + +std::size_t utf8_length(std::string_view value) { + return static_cast( + std::ranges::count_if(value, [](unsigned char character) { return (character & 0xc0U) != 0x80U; })); +} + +bool matches_pattern(Pattern pattern, std::string_view value) { + switch (pattern) { + case Pattern::none: + return true; + case Pattern::lowercase_sha256: + return value.size() == 64U && std::ranges::all_of(value, [](unsigned char character) { + return std::isdigit(character) != 0 || (character >= 'a' && character <= 'f'); + }); + case Pattern::sampler_name: + return (value.size() == 1U && ascii_range(value, '!', '~')) || + (value.size() >= 2U && value.size() <= 8U && + static_cast(value.front()) >= static_cast('!') && + static_cast(value.front()) <= static_cast('~') && + static_cast(value.back()) >= static_cast('!') && + static_cast(value.back()) <= static_cast('~') && + ascii_range(value, ' ', '~')); + case Pattern::printable_text: + return !value.empty() && ascii_range(value, ' ', '~'); + case Pattern::media_extension: + return value == ".iso" || value == ".ima" || value == ".zip"; + case Pattern::package_extension: + return value.size() > 4U && value.starts_with(".axk") && + std::ranges::all_of(value.substr(4U), + [](unsigned char character) { return std::islower(character) != 0; }); + case Pattern::identifier: + return !value.empty() && std::ranges::all_of(value, [](unsigned char character) { + return std::isalnum(character) != 0 || character == '_' || character == '.' || character == '-'; + }); + case Pattern::midi_manufacturer_id: { + const auto hexadecimal = [](unsigned char character) { + return std::isdigit(character) != 0 || (character >= 'A' && character <= 'F'); + }; + return (value.size() == 2U && std::ranges::all_of(value, hexadecimal)) || + (value.size() == 8U && value.starts_with("00-") && value[5] == '-' && + hexadecimal(static_cast(value[3])) && + hexadecimal(static_cast(value[4])) && + hexadecimal(static_cast(value[6])) && hexadecimal(static_cast(value[7]))); + } + case Pattern::receive_channel: + return digits_01_16(value) || + (value.size() == 3U && (value.front() == 'A' || value.front() == 'B') && digits_01_16(value.substr(1U))); + case Pattern::api_path: + return value.starts_with("/api/v1/"); + case Pattern::archive_content_path: { + constexpr std::string_view prefix{"/api/v1/download-archives/"}; + constexpr std::string_view suffix{"/content"}; + if (!value.starts_with(prefix) || !value.ends_with(suffix)) + return false; + const auto identifier = value.substr(prefix.size(), value.size() - prefix.size() - suffix.size()); + return !identifier.empty() && + std::ranges::all_of(identifier, [](unsigned char character) { return std::isalnum(character) != 0; }); + } + } + return false; +} + +bool matches_literal(const Literal &literal, const nlohmann::json &value) { + switch (literal.kind) { + case LiteralKind::null: + return value.is_null(); + case LiteralKind::boolean: + return value.is_boolean() && value.get() == literal.boolean; + case LiteralKind::integer: + if (value.is_number_unsigned()) { + return literal.integer >= 0 && value.get() == static_cast(literal.integer); + } + return value.is_number_integer() && value.get() == literal.integer; + case LiteralKind::string: + return value.is_string() && value.get_ref() == literal.text; + } + return false; +} + +bool matches_type(const SchemaNode &schema, const nlohmann::json &value) { + if (schema.types == 0U) + return true; + return (has_type(schema, JsonType::null) && value.is_null()) || + (has_type(schema, JsonType::boolean) && value.is_boolean()) || + (has_type(schema, JsonType::integer) && (value.is_number_integer() || value.is_number_unsigned())) || + (has_type(schema, JsonType::number) && value.is_number()) || + (has_type(schema, JsonType::string) && value.is_string()) || + (has_type(schema, JsonType::array) && value.is_array()) || + (has_type(schema, JsonType::object) && value.is_object()); +} + +bool validate_node(const ValidationPlan &plan, const SchemaNode &input_schema, const nlohmann::json &value, + std::size_t depth) { + if (depth > 128U) + return false; + const auto &schema = resolved(plan, input_schema); + if (!matches_type(schema, value)) + return false; + if (schema.literals_count != 0U) { + const auto literals = plan.literals.subspan(schema.literals_begin, schema.literals_count); + if (std::ranges::none_of(literals, [&](const Literal &literal) { return matches_literal(literal, value); })) + return false; + } + if (schema.variants_count != 0U) { + const auto variants = plan.variants.subspan(schema.variants_begin, schema.variants_count); + const auto matches = std::ranges::count_if(variants, [&](std::uint32_t variant) { + return validate_node(plan, plan.nodes[variant], value, depth + 1U); + }); + if (matches != 1) + return false; + } + if (value.is_string()) { + const auto &text = value.get_ref(); + const auto length = utf8_length(text); + if ((schema.has_minimum_length && length < schema.minimum_length) || + (schema.has_maximum_length && length > schema.maximum_length) || !matches_pattern(schema.pattern, text)) + return false; + } + if (value.is_number_unsigned()) { + const auto number = value.get(); + const auto below_minimum = + schema.has_minimum && schema.minimum >= 0 && number < static_cast(schema.minimum); + const auto above_maximum = + schema.has_maximum && (schema.maximum < 0 || number > static_cast(schema.maximum)); + if (below_minimum || above_maximum) + return false; + } else if (value.is_number_integer()) { + const auto number = value.get(); + if ((schema.has_minimum && number < schema.minimum) || (schema.has_maximum && number > schema.maximum)) + return false; + } else if (value.is_number_float()) { + const auto number = value.get(); + if ((schema.has_minimum && number < static_cast(schema.minimum)) || + (schema.has_maximum && number > static_cast(schema.maximum))) + return false; + } + if (value.is_array()) { + if ((schema.has_minimum_items && value.size() < schema.minimum_items) || + (schema.has_maximum_items && value.size() > schema.maximum_items)) + return false; + if (schema.unique_items) { + for (auto left = value.begin(); left != value.end(); ++left) + if (std::find(std::next(left), value.end(), *left) != value.end()) + return false; + } + if (schema.items != no_schema && std::ranges::any_of(value, [&](const auto &item) { + return !validate_node(plan, plan.nodes[schema.items], item, depth + 1U); + })) + return false; + } + if (value.is_object()) { + if (schema.has_maximum_properties && value.size() > schema.maximum_properties) + return false; + const auto properties = plan.properties.subspan(schema.properties_begin, schema.properties_count); + for (const auto &property : properties) { + const auto found = value.find(property.name); + if (found == value.end()) { + if (property.required) + return false; + } else if (property.schema != no_schema && + !validate_node(plan, plan.nodes[property.schema], *found, depth + 1U)) { + return false; + } + } + for (const auto &[name, child] : value.items()) { + if (std::ranges::any_of(properties, [&](const Property &property) { return property.name == name; })) + continue; + if (!schema.additional_properties_allowed || + (schema.additional_properties != no_schema && + !validate_node(plan, plan.nodes[schema.additional_properties], child, depth + 1U))) + return false; + } + } + return true; +} + +nlohmann::json translate_node(const ValidationPlan &plan, const SchemaNode &input_schema, const nlohmann::json &value, + bool to_application, std::size_t depth) { + if (depth > 128U) + return value; + const auto &schema = resolved(plan, input_schema); + if (value.is_string() && schema.mappings_count != 0U) { + const auto mappings = plan.mappings.subspan(schema.mappings_begin, schema.mappings_count); + const auto text = value.get_ref(); + const auto found = std::ranges::find_if(mappings, [&](const EnumMapping &mapping) { + return (to_application ? mapping.wire : mapping.application) == text; + }); + return found == mappings.end() ? value + : nlohmann::json(std::string{to_application ? found->application : found->wire}); + } + auto result = value; + for (const auto variant : plan.variants.subspan(schema.variants_begin, schema.variants_count)) + result = translate_node(plan, plan.nodes[variant], result, to_application, depth + 1U); + if (result.is_object()) { + for (const auto &property : plan.properties.subspan(schema.properties_begin, schema.properties_count)) + if (property.schema != no_schema && result.contains(property.name)) + result[property.name] = translate_node(plan, plan.nodes[property.schema], result.at(property.name), + to_application, depth + 1U); + } else if (result.is_array() && schema.items != no_schema) { + for (auto &item : result) + item = translate_node(plan, plan.nodes[schema.items], item, to_application, depth + 1U); + } + return result; +} + +} // namespace + +const SchemaNode *find_schema(const ValidationPlan &plan, std::string_view name) { + const auto found = std::ranges::lower_bound(plan.schemas, name, {}, &NamedSchema::name); + return found != plan.schemas.end() && found->name == name ? &plan.nodes[found->schema] : nullptr; +} + +bool validate(const ValidationPlan &plan, const SchemaNode &schema, const nlohmann::json &value) { + return validate_node(plan, schema, value, 0U); +} + +nlohmann::json translate(const ValidationPlan &plan, const SchemaNode &schema, const nlohmann::json &value, + bool to_application) { + return translate_node(plan, schema, value, to_application, 0U); +} + +} // namespace axk::server::detail diff --git a/apps/server/src/validation_plan.hpp b/apps/server/src/validation_plan.hpp new file mode 100644 index 00000000..46517fa8 --- /dev/null +++ b/apps/server/src/validation_plan.hpp @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace axk::server::detail { + +constexpr std::uint32_t no_schema = std::numeric_limits::max(); + +enum class JsonType : std::uint8_t { + null = 1U << 0U, + boolean = 1U << 1U, + integer = 1U << 2U, + number = 1U << 3U, + string = 1U << 4U, + array = 1U << 5U, + object = 1U << 6U, +}; + +enum class Pattern : std::uint8_t { + none, + lowercase_sha256, + sampler_name, + printable_text, + media_extension, + package_extension, + identifier, + midi_manufacturer_id, + receive_channel, + api_path, + archive_content_path, +}; + +enum class LiteralKind : std::uint8_t { null, boolean, integer, string }; + +struct Literal { + LiteralKind kind; + std::string_view text; + std::int64_t integer; + bool boolean; +}; + +struct Property { + std::string_view name; + std::uint32_t schema; + bool required; +}; + +struct EnumMapping { + std::string_view wire; + std::string_view application; +}; + +struct SchemaNode { + std::uint8_t types; + Pattern pattern; + std::uint32_t reference; + std::uint32_t items; + std::uint32_t additional_properties; + std::uint32_t properties_begin; + std::uint32_t properties_count; + std::uint32_t variants_begin; + std::uint32_t variants_count; + std::uint32_t literals_begin; + std::uint32_t literals_count; + std::uint32_t mappings_begin; + std::uint32_t mappings_count; + std::uint64_t minimum_length; + std::uint64_t maximum_length; + std::uint64_t minimum_items; + std::uint64_t maximum_items; + std::uint64_t maximum_properties; + std::int64_t minimum; + std::int64_t maximum; + bool additional_properties_allowed; + bool unique_items; + bool has_minimum_length; + bool has_maximum_length; + bool has_minimum_items; + bool has_maximum_items; + bool has_maximum_properties; + bool has_minimum; + bool has_maximum; +}; + +struct NamedSchema { + std::string_view name; + std::uint32_t schema; +}; + +struct ValidationPlan { + std::span nodes; + std::span properties; + std::span variants; + std::span literals; + std::span mappings; + std::span schemas; +}; + +[[nodiscard]] const ValidationPlan &generated_validation_plan(); +[[nodiscard]] const SchemaNode *find_schema(const ValidationPlan &plan, std::string_view name); +[[nodiscard]] bool validate(const ValidationPlan &plan, const SchemaNode &schema, const nlohmann::json &value); +[[nodiscard]] nlohmann::json translate(const ValidationPlan &plan, const SchemaNode &schema, + const nlohmann::json &value, bool to_application); + +} // namespace axk::server::detail diff --git a/apps/server/src/validation_plan_generator.cpp b/apps/server/src/validation_plan_generator.cpp new file mode 100644 index 00000000..c3b39dec --- /dev/null +++ b/apps/server/src/validation_plan_generator.cpp @@ -0,0 +1,387 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "validation_plan.hpp" + +namespace { + +using Json = nlohmann::json; +using axk::server::detail::EnumMapping; +using axk::server::detail::Literal; +using axk::server::detail::LiteralKind; +using axk::server::detail::NamedSchema; +using axk::server::detail::Pattern; +using axk::server::detail::Property; +using axk::server::detail::SchemaNode; +constexpr auto no_schema = axk::server::detail::no_schema; +constexpr std::string_view reference_prefix{"#/components/schemas/"}; + +std::string quote(std::string_view value) { return Json(std::string{value}).dump(); } + +std::string pattern_name(std::string_view value) { + static const std::map patterns{ + {"", "none"}, + {"^[0-9a-f]{64}$", "lowercase_sha256"}, + {"^(?:[!-~]|[!-~][ -~]{0,6}[!-~])$", "sampler_name"}, + {"^[ -~]+$", "printable_text"}, + {"^\\.(iso|ima|zip)$", "media_extension"}, + {"^\\.axk[a-z]+$", "package_extension"}, + {"^[A-Za-z0-9_.-]+$", "identifier"}, + {"^(?:[0-9A-F]{2}|00-[0-9A-F]{2}-[0-9A-F]{2})$", "midi_manufacturer_id"}, + {"^(A|B)?(0[1-9]|1[0-6])$", "receive_channel"}, + {"^/api/v1/", "api_path"}, + {"^/api/v1/download-archives/[A-Za-z0-9]+/content$", "archive_content_path"}, + }; + const auto found = patterns.find(value); + if (found == patterns.end()) + throw std::runtime_error{"unsupported OpenAPI pattern: " + std::string{value}}; + return "Pattern::" + std::string{found->second}; +} + +std::uint8_t type_bit(std::string_view type) { + static const std::map types{ + {"null", 1U << 0U}, {"boolean", 1U << 1U}, {"integer", 1U << 2U}, {"number", 1U << 3U}, + {"string", 1U << 4U}, {"array", 1U << 5U}, {"object", 1U << 6U}, + }; + const auto found = types.find(type); + if (found == types.end()) + throw std::runtime_error{"unsupported OpenAPI type: " + std::string{type}}; + return found->second; +} + +struct Compiler { + explicit Compiler(const Json &document) : document(document) { + for (const auto &[name, schema] : document.at("components").at("schemas").items()) { + static_cast(schema); + names.emplace(name, static_cast(nodes.size())); + named.push_back({intern(name), static_cast(nodes.size())}); + nodes.emplace_back(); + } + for (const auto &[name, schema] : document.at("components").at("schemas").items()) + compile_at(names.at(name), schema, name); + std::ranges::sort(named, {}, &NamedSchema::name); + } + + std::uint32_t compile(const Json &schema, std::string_view path) { + const auto index = static_cast(nodes.size()); + nodes.emplace_back(); + compile_at(index, schema, path); + return index; + } + + void compile_at(std::uint32_t index, const Json &schema, std::string_view path) { + if (!schema.is_object()) + throw std::runtime_error{"schema is not an object at " + std::string{path}}; + static const std::set supported{ + "$ref", + "type", + "properties", + "required", + "additionalProperties", + "items", + "enum", + "const", + "minimum", + "maximum", + "minLength", + "maxLength", + "minItems", + "maxItems", + "maxProperties", + "uniqueItems", + "oneOf", + "pattern", + "default", + "description", + "example", + "x-axklib-application-enum", + }; + for (const auto &[keyword, value] : schema.items()) { + static_cast(value); + if (!supported.contains(keyword)) + throw std::runtime_error{"unsupported OpenAPI schema keyword at " + std::string{path} + ": " + keyword}; + } + + SchemaNode node{}; + node.reference = no_schema; + node.items = no_schema; + node.additional_properties = no_schema; + node.additional_properties_allowed = true; + if (const auto reference = schema.find("$ref"); reference != schema.end()) { + if (!reference->is_string() || !reference->get_ref().starts_with(reference_prefix)) + throw std::runtime_error{"unsupported OpenAPI reference at " + std::string{path}}; + node.reference = compile_reference(reference->get_ref()); + nodes[index] = node; + return; + } + if (const auto type = schema.find("type"); type != schema.end()) { + if (type->is_string()) { + node.types = type_bit(type->get_ref()); + } else if (type->is_array()) { + for (const auto &candidate : *type) { + if (!candidate.is_string()) + throw std::runtime_error{"non-string OpenAPI type at " + std::string{path}}; + node.types |= type_bit(candidate.get_ref()); + } + } else { + throw std::runtime_error{"invalid OpenAPI type at " + std::string{path}}; + } + } + node.pattern = pattern_from(schema.value("pattern", std::string{})); + if (const auto items = schema.find("items"); items != schema.end()) + node.items = compile(*items, std::string{path} + "[]"); + + std::set required; + if (const auto values = schema.find("required"); values != schema.end()) { + if (!values->is_array()) + throw std::runtime_error{"required is not an array at " + std::string{path}}; + for (const auto &value : *values) + required.insert(value.get()); + } + std::vector local_properties; + if (const auto values = schema.find("properties"); values != schema.end()) { + if (!values->is_object()) + throw std::runtime_error{"properties is not an object at " + std::string{path}}; + for (const auto &[name, child] : values->items()) + local_properties.push_back( + {intern(name), compile(child, std::string{path} + '/' + name), required.contains(name)}); + } + for (const auto &name : required) + if (std::ranges::none_of(local_properties, [&](const Property &property) { return property.name == name; })) + local_properties.push_back({intern(name), no_schema, true}); + node.properties_begin = append(properties, local_properties); + node.properties_count = static_cast(local_properties.size()); + + if (const auto additional = schema.find("additionalProperties"); additional != schema.end()) { + if (additional->is_boolean()) { + node.additional_properties_allowed = additional->get(); + } else if (additional->is_object()) { + node.additional_properties = compile(*additional, std::string{path} + "/additionalProperties"); + } else { + throw std::runtime_error{"invalid additionalProperties at " + std::string{path}}; + } + } + std::vector local_variants; + if (const auto values = schema.find("oneOf"); values != schema.end()) { + if (!values->is_array()) + throw std::runtime_error{"oneOf is not an array at " + std::string{path}}; + for (std::size_t variant = 0U; variant < values->size(); ++variant) + local_variants.push_back(compile(values->at(variant), std::string{path} + "/oneOf")); + } + node.variants_begin = append(variants, local_variants); + node.variants_count = static_cast(local_variants.size()); + + std::vector local_literals; + if (const auto enumeration = schema.find("enum"); enumeration != schema.end()) { + if (!enumeration->is_array()) + throw std::runtime_error{"enum is not an array at " + std::string{path}}; + for (const auto &value : *enumeration) + local_literals.push_back(literal(value, path)); + } + if (const auto constant = schema.find("const"); constant != schema.end()) + local_literals.push_back(literal(*constant, path)); + node.literals_begin = append(literals, local_literals); + node.literals_count = static_cast(local_literals.size()); + + std::vector local_mappings; + if (const auto mapping = schema.find("x-axklib-application-enum"); mapping != schema.end()) { + if (!mapping->is_object()) + throw std::runtime_error{"application enum mapping is not an object at " + std::string{path}}; + for (const auto &[wire, application] : mapping->items()) { + if (!application.is_string()) + throw std::runtime_error{"application enum value is not a string at " + std::string{path}}; + local_mappings.push_back({intern(wire), intern(application.get_ref())}); + } + } + node.mappings_begin = append(mappings, local_mappings); + node.mappings_count = static_cast(local_mappings.size()); + bounded(schema, "minLength", node.minimum_length, node.has_minimum_length); + bounded(schema, "maxLength", node.maximum_length, node.has_maximum_length); + bounded(schema, "minItems", node.minimum_items, node.has_minimum_items); + bounded(schema, "maxItems", node.maximum_items, node.has_maximum_items); + bounded(schema, "maxProperties", node.maximum_properties, node.has_maximum_properties); + signed_bound(schema, "minimum", node.minimum, node.has_minimum); + signed_bound(schema, "maximum", node.maximum, node.has_maximum); + node.unique_items = schema.value("uniqueItems", false); + nodes[index] = node; + } + + static Pattern pattern_from(std::string_view value) { + const auto generated = pattern_name(value); + static const std::map values{ + {"Pattern::none", Pattern::none}, + {"Pattern::lowercase_sha256", Pattern::lowercase_sha256}, + {"Pattern::sampler_name", Pattern::sampler_name}, + {"Pattern::printable_text", Pattern::printable_text}, + {"Pattern::media_extension", Pattern::media_extension}, + {"Pattern::package_extension", Pattern::package_extension}, + {"Pattern::identifier", Pattern::identifier}, + {"Pattern::midi_manufacturer_id", Pattern::midi_manufacturer_id}, + {"Pattern::receive_channel", Pattern::receive_channel}, + {"Pattern::api_path", Pattern::api_path}, + {"Pattern::archive_content_path", Pattern::archive_content_path}, + }; + return values.at(generated); + } + + std::uint32_t compile_reference(const std::string &reference) { + const auto name = reference.substr(reference_prefix.size()); + if (!name.contains('/')) { + const auto found = names.find(name); + if (found == names.end()) + throw std::runtime_error{"unknown OpenAPI schema reference: " + name}; + return found->second; + } + + const auto existing = reference_nodes.find(reference); + if (existing != reference_nodes.end()) + return existing->second; + + const auto index = static_cast(nodes.size()); + reference_nodes.emplace(reference, index); + nodes.emplace_back(); + try { + compile_at(index, document.at(Json::json_pointer{reference.substr(1U)}), reference); + } catch (const Json::exception &error) { + throw std::runtime_error{"unknown OpenAPI schema reference " + reference + ": " + error.what()}; + } + return index; + } + + Literal literal(const Json &value, std::string_view path) { + if (value.is_null()) + return {LiteralKind::null, {}, 0, false}; + if (value.is_boolean()) + return {LiteralKind::boolean, {}, 0, value.get()}; + if (value.is_number_integer() || value.is_number_unsigned()) + return {LiteralKind::integer, {}, value.get(), false}; + if (value.is_string()) + return {LiteralKind::string, intern(value.get_ref()), 0, false}; + throw std::runtime_error{"unsupported enum or const literal at " + std::string{path}}; + } + + template static std::uint32_t append(std::vector &target, const std::vector &values) { + const auto begin = static_cast(target.size()); + target.insert(target.end(), values.begin(), values.end()); + return begin; + } + + static void bounded(const Json &schema, std::string_view keyword, std::uint64_t &value, bool &present) { + if (const auto found = schema.find(keyword); found != schema.end()) { + value = found->get(); + present = true; + } + } + + static void signed_bound(const Json &schema, std::string_view keyword, std::int64_t &value, bool &present) { + if (const auto found = schema.find(keyword); found != schema.end()) { + value = found->get(); + present = true; + } + } + + std::string_view intern(std::string_view value) { + strings.emplace_back(value); + return strings.back(); + } + + const Json &document; + std::map> names; + std::map> reference_nodes; + std::vector nodes; + std::vector properties; + std::vector variants; + std::vector literals; + std::vector mappings; + std::vector named; + std::deque strings; +}; + +std::string bool_text(bool value) { return value ? "true" : "false"; } + +void write_output(std::ostream &out, const Compiler &value) { + out << "#include \"validation_plan.hpp\"\n\n#include \n\nnamespace axk::server::detail {\nnamespace {\n"; + out << "constexpr std::array nodes{\n"; + for (const auto &node : value.nodes) { + out << "SchemaNode{" << unsigned{node.types} << ", Pattern::"; + const auto pattern = static_cast(node.pattern); + static constexpr std::string_view names[]{"none", "lowercase_sha256", "sampler_name", + "printable_text", "media_extension", "package_extension", + "identifier", "midi_manufacturer_id", "receive_channel", + "api_path", "archive_content_path"}; + out << names[pattern] << "," << node.reference << 'U' << ',' << node.items << 'U' << ',' + << node.additional_properties << 'U' << ',' << node.properties_begin << 'U' << ',' << node.properties_count + << 'U' << ',' << node.variants_begin << 'U' << ',' << node.variants_count << 'U' << ',' + << node.literals_begin << 'U' << ',' << node.literals_count << 'U' << ',' << node.mappings_begin << 'U' + << ',' << node.mappings_count << 'U' << ',' << node.minimum_length << "ULL," << node.maximum_length + << "ULL," << node.minimum_items << "ULL," << node.maximum_items << "ULL," << node.maximum_properties + << "ULL," << node.minimum << "LL," << node.maximum << "LL," << bool_text(node.additional_properties_allowed) + << ',' << bool_text(node.unique_items) << ',' << bool_text(node.has_minimum_length) << ',' + << bool_text(node.has_maximum_length) << ',' << bool_text(node.has_minimum_items) << ',' + << bool_text(node.has_maximum_items) << ',' << bool_text(node.has_maximum_properties) << ',' + << bool_text(node.has_minimum) << ',' << bool_text(node.has_maximum) << "},\n"; + } + out << "};\nconstexpr std::array properties{\n"; + for (const auto &property : value.properties) + out << "Property{" << quote(property.name) << ',' << property.schema << "U," << bool_text(property.required) + << "},\n"; + out << "};\nconstexpr std::array variants{"; + for (const auto variant : value.variants) + out << variant << "U,"; + out << "};\nconstexpr std::array literals{\n"; + for (const auto &literal : value.literals) { + static constexpr std::string_view kinds[]{"null", "boolean", "integer", "string"}; + out << "Literal{LiteralKind::" << kinds[static_cast(literal.kind)] << ',' << quote(literal.text) + << ',' << literal.integer << "LL," << bool_text(literal.boolean) << "},\n"; + } + out << "};\nconstexpr std::array mappings{\n"; + for (const auto &mapping : value.mappings) + out << "EnumMapping{" << quote(mapping.wire) << ',' << quote(mapping.application) << "},\n"; + out << "};\nconstexpr std::array schemas{\n"; + for (const auto &schema : value.named) + out << "NamedSchema{" << quote(schema.name) << ',' << schema.schema << "U},\n"; + out << "};\nconstexpr ValidationPlan plan{nodes,properties,variants,literals,mappings,schemas};\n" + "} // namespace\nconst ValidationPlan &generated_validation_plan() { return plan; }\n" + "} // namespace axk::server::detail\n"; +} + +} // namespace + +int main(int argc, char **argv) { + if (argc != 3) { + std::cerr << "usage: axk-server-validation-plan-generator OPENAPI OUTPUT\n"; + return 2; + } + try { + std::ifstream input{std::filesystem::path{argv[1]}, std::ios::binary}; + if (!input) + throw std::runtime_error{"could not open OpenAPI document"}; + const Json document = Json::parse(input); + const Compiler compiler{document}; + std::ostringstream generated; + write_output(generated, compiler); + std::ofstream output{std::filesystem::path{argv[2]}, std::ios::binary | std::ios::trunc}; + output << generated.str(); + if (!output) + throw std::runtime_error{"could not write validation plan"}; + return 0; + } catch (const std::exception &error) { + std::cerr << "OpenAPI validation-plan generation failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/apps/server/tests/contract_test_support.cpp b/apps/server/tests/contract_test_support.cpp new file mode 100644 index 00000000..14826cbe --- /dev/null +++ b/apps/server/tests/contract_test_support.cpp @@ -0,0 +1,77 @@ +#include "contract_test_support.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace { + +nlohmann::json validation_root(const nlohmann::json &document, const nlohmann::json &schema) { + auto root = schema; + root["$schema"] = "http://json-schema.org/draft-07/schema#"; + root["components"] = document.at("components"); + return root; +} + +axk::app::Result validate_value(const nlohmann::json &document, const nlohmann::json &schema, + const nlohmann::json &value) { + try { + nlohmann::json_schema::json_validator validator; + validator.set_root_schema(validation_root(document, schema)); + static_cast(validator.validate(value)); + } catch (const std::exception &) { + return std::unexpected( + axk::app::Error{"invalid_request", "request body does not match the declared OpenAPI schema"}); + } + return {}; +} + +} // namespace + +struct axk::server::OracleOpenApiValidator::Impl { + explicit Impl(nlohmann::json value) : document(std::move(value)) { + for (const auto &[name, schema] : document.at("components").at("schemas").items()) { + auto validator = std::make_unique(); + validator->set_root_schema(validation_root(document, schema)); + validators.emplace(name, std::move(validator)); + } + } + + nlohmann::json document; + std::map, std::less<>> validators; +}; + +axk::server::OracleOpenApiValidator::OracleOpenApiValidator(nlohmann::json document) + : impl_(std::make_unique(std::move(document))) {} + +axk::server::OracleOpenApiValidator::~OracleOpenApiValidator() = default; + +bool axk::server::OracleOpenApiValidator::validate(std::string_view schema_name, const nlohmann::json &value) const { + const auto found = impl_->validators.find(schema_name); + if (found == impl_->validators.end()) + return false; + try { + static_cast(found->second->validate(value)); + return true; + } catch (const std::exception &) { + return false; + } +} + +axk::app::Result axk::server::validate_openapi_value(const nlohmann::json &document, std::string_view schema_name, + const nlohmann::json &value) { + try { + return validate_value(document, {{"$ref", "#/components/schemas/" + std::string{schema_name}}}, value); + } catch (const std::exception &) { + return std::unexpected(app::Error{"contract_error", "OpenAPI schema is not available"}); + } +} + +axk::app::Result axk::server::validate_openapi_schema(const nlohmann::json &document, + const nlohmann::json &schema, const nlohmann::json &value) { + return validate_value(document, schema, value); +} diff --git a/apps/server/tests/contract_test_support.hpp b/apps/server/tests/contract_test_support.hpp new file mode 100644 index 00000000..14b96c29 --- /dev/null +++ b/apps/server/tests/contract_test_support.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include + +#include "axklib/application/contracts.hpp" + +namespace axk::server { + +class OracleOpenApiValidator { + public: + explicit OracleOpenApiValidator(nlohmann::json document); + ~OracleOpenApiValidator(); + + OracleOpenApiValidator(const OracleOpenApiValidator &) = delete; + OracleOpenApiValidator &operator=(const OracleOpenApiValidator &) = delete; + + [[nodiscard]] bool validate(std::string_view schema_name, const nlohmann::json &value) const; + + private: + struct Impl; + std::unique_ptr impl_; +}; + +[[nodiscard]] app::Result validate_openapi_value(const nlohmann::json &document, std::string_view schema_name, + const nlohmann::json &value); +[[nodiscard]] app::Result validate_openapi_schema(const nlohmann::json &document, const nlohmann::json &schema, + const nlohmann::json &value); + +} // namespace axk::server diff --git a/apps/server/tests/server_contract_test.cpp b/apps/server/tests/server_contract_test.cpp index ff2c59fa..127f78c3 100644 --- a/apps/server/tests/server_contract_test.cpp +++ b/apps/server/tests/server_contract_test.cpp @@ -14,6 +14,7 @@ #include "axklib/application/operation_registry.hpp" #include "axklib/server/contract.hpp" #include "axklib/server/server.hpp" +#include "contract_test_support.hpp" namespace { @@ -89,6 +90,58 @@ TEST(ServerContract, EmbedsValidOpenApi31WithSandboxReferences) { EXPECT_TRUE(headers.contains("XRequestId")); } +TEST(ServerContract, GeneratedValidationPlanMatchesTheSchemaCompilerForRepresentativeValues) { + const auto document = + axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); + const axk::server::OpenApiValidator generated; + const axk::server::OracleOpenApiValidator oracle{document}; + const auto representatives = std::to_array({ + nullptr, + false, + true, + -1, + 0, + 1, + 1.5, + "", + "x", + "é", + "A01", + "A16", + "A00", + "A17", + "B01", + "B16", + "Bch", + "01", + "16", + "00", + "17", + ".iso", + ".axkvol", + ".AXKVOL", + "/api/v1/", + "/api/v1/system/version", + "/api/v2/system/version", + "/api/v1/download-archives/archive1/content", + "/api/v1/download-archives/archive-1/content", + "00-43-10", + "0A", + "0a", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + nlohmann::json::array(), + nlohmann::json::object(), + nlohmann::json{{"unexpected", true}}, + }); + for (const auto &[name, schema] : document.at("components").at("schemas").items()) { + static_cast(schema); + for (const auto &value : representatives) { + EXPECT_EQ(static_cast(generated.validate(name, value)), oracle.validate(name, value)) + << name << ": " << value.dump(); + } + } +} + TEST(ServerContract, ImageObjectScopeUsesAnOpaqueContentNodeIdentifier) { const auto document = axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); @@ -147,7 +200,7 @@ TEST(ServerContract, DirectoryListingsSeparateMediaSourceInspection) { TEST(ServerContract, AlterationJobReportsIncludeTx16wDiskSetImports) { const auto document = axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); - axk::server::OpenApiValidator validator{document}; + axk::server::OpenApiValidator validator; const auto &type = document.at("components").at("schemas").at("AlterationOperationReport").at("properties").at("type"); EXPECT_TRUE(std::ranges::contains(type.at("enum"), "IMPORT_TX16W_DISK_SET")); @@ -417,7 +470,7 @@ TEST(ServerContract, EveryHttpResponseCarriesRequestIdAndPaginationIsBounded) { TEST(ServerContract, RelationshipDiagnosticsValidateForInspectionAndTerminalExtractionResults) { const auto document = axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); - axk::server::OpenApiValidator validator{document}; + axk::server::OpenApiValidator validator; const auto diagnostic = nlohmann::json{ {"code", "unconfirmed_relationship_excluded"}, {"message", "Unconfirmed relationship excluded from exact export"}, @@ -479,7 +532,7 @@ TEST(ServerContract, RelationshipDiagnosticsValidateForInspectionAndTerminalExtr TEST(ServerContract, ProgramAssignmentAdjustmentsValidateForPlansAndImportResults) { const auto document = axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); - axk::server::OpenApiValidator validator{document}; + axk::server::OpenApiValidator validator; const auto adjustment = nlohmann::json{ {"adjustmentId", "adjustment-1"}, {"origin", "existing-program"}, @@ -821,9 +874,7 @@ TEST(ServerContract, ProgramGenerationInspectionAndJobUseReviewedSelections) { } TEST(ServerContract, WorkspaceCreateRequestRejectsUnknownFields) { - const auto document = - axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); - axk::server::OpenApiValidator validator{document}; + axk::server::OpenApiValidator validator; const auto request = nlohmann::json{{"displayName", "Samples"}, {"path", "/samples"}, {"writable", false}, {"revision", 0U}}; EXPECT_TRUE(validator.validate("WorkspaceCreateRequest", request)); @@ -835,9 +886,7 @@ TEST(ServerContract, WorkspaceCreateRequestRejectsUnknownFields) { } TEST(ServerContract, MediaConversionRequestsAndTerminalResultsMatchTheirSchemas) { - const auto document = - axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); - axk::server::OpenApiValidator validator{document}; + axk::server::OpenApiValidator validator; const auto inspection_request = nlohmann::json{{"imageId", "image-one"}, {"expectedRevision", 3U}, {"format", "FAT12_FLOPPY"}, @@ -886,7 +935,7 @@ TEST(ServerContract, WireEnumsAreUpperSnakeAndTranslateOnlyAtTheApplicationBound const auto document = axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); expect_upper_snake_enums(document.at("components").at("schemas")); - axk::server::OpenApiValidator validator{document}; + axk::server::OpenApiValidator validator; const auto wire_request = nlohmann::json{{"source", file_ref()}, @@ -933,9 +982,7 @@ TEST(ServerContract, WireEnumsAreUpperSnakeAndTranslateOnlyAtTheApplicationBound } TEST(ServerContract, ImageSessionVolumeSelectorsUseExactContentIdentity) { - const auto document = - axk::server::build_openapi_document(axk::server::embedded_openapi(), axk::app::make_operation_registry()); - axk::server::OpenApiValidator validator{document}; + axk::server::OpenApiValidator validator; const auto exact = nlohmann::json{{"kind", "VOLUME"}, {"contentId", "content-volume-1"}}; EXPECT_TRUE(validator.validate("ImageSessionExportRoot", exact)); EXPECT_FALSE( diff --git a/vcpkg.json b/vcpkg.json index ed745f5b..06a6c7db 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -32,14 +32,14 @@ "server": { "description": "Dependencies used only by the Crow REST and WebSocket server", "dependencies": [ - "crow", - "json-schema-validator" + "crow" ] }, "tests": { "description": "Dependencies used only by native test targets", "dependencies": [ - "gtest" + "gtest", + "json-schema-validator" ] } } From 2f41750b7c947a32a626d1c19e6eb3fd210530dc Mon Sep 17 00:00:00 2001 From: bzeiss Date: Tue, 18 Aug 2026 21:26:25 +0200 Subject: [PATCH 02/12] - hidpi mode read and applied before displaying anything - added 115% scaling --- apps/axkdeck/README.md | 6 ++--- apps/axkdeck/index.html | 6 ++++- .../src/lib/components/LayoutControls.svelte | 1 + .../src/lib/components/LayoutControls.test.ts | 4 +-- apps/axkdeck/src/lib/interfaceScale.test.ts | 13 ++++++++-- apps/axkdeck/src/lib/interfaceScale.ts | 4 +-- .../axkdeck/src/lib/startupVisibility.test.ts | 25 +++++++++++++++++++ apps/axkdeck/src/lib/startupVisibility.ts | 9 +++++++ apps/axkdeck/src/main.ts | 5 +++- 9 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 apps/axkdeck/src/lib/startupVisibility.test.ts create mode 100644 apps/axkdeck/src/lib/startupVisibility.ts diff --git a/apps/axkdeck/README.md b/apps/axkdeck/README.md index 23c10b8a..d765e2d3 100644 --- a/apps/axkdeck/README.md +++ b/apps/axkdeck/README.md @@ -217,7 +217,7 @@ target before launching the desktop shell. ### Interface scale -Desktop builds adjust the webview scale before mounting the interface. Auto +Desktop builds adjust the webview scale before revealing the interface. Auto mode uses the active monitor's physical resolution together with its operating system scale factor, so a 4K display at 100% receives a larger interface while a display that is already scaled by the operating system is not enlarged @@ -225,8 +225,8 @@ twice. The scale is recalculated when the window moves to another monitor or the monitor scale changes. Use the sliders menu beside the panel layout controls to select Auto, 100%, -125%, or 150%. The selected mode is stored locally and restored on the next -launch. Manual modes remain fixed when the window moves between displays. +115%, 125%, or 150%. The selected mode is stored locally and restored on the +next launch. Manual modes remain fixed when the window moves between displays. ### Local workspaces diff --git a/apps/axkdeck/index.html b/apps/axkdeck/index.html index b35a9fe4..05fd32f5 100644 --- a/apps/axkdeck/index.html +++ b/apps/axkdeck/index.html @@ -1,5 +1,5 @@ - + @@ -30,6 +30,10 @@ font: 12px/1.4 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + + html[data-interface-scale-pending] #app { + visibility: hidden; + } axkdeck diff --git a/apps/axkdeck/src/lib/components/LayoutControls.svelte b/apps/axkdeck/src/lib/components/LayoutControls.svelte index af59651f..585bf8fb 100644 --- a/apps/axkdeck/src/lib/components/LayoutControls.svelte +++ b/apps/axkdeck/src/lib/components/LayoutControls.svelte @@ -31,6 +31,7 @@ let scaleTrigger: HTMLButtonElement | undefined = $state(); const scaleOptions: readonly { mode: InterfaceScaleMode; label: string }[] = [ { mode: '1', label: '100%' }, + { mode: '1.15', label: '115%' }, { mode: '1.25', label: '125%' }, { mode: '1.5', label: '150%' }, ]; diff --git a/apps/axkdeck/src/lib/components/LayoutControls.test.ts b/apps/axkdeck/src/lib/components/LayoutControls.test.ts index 3c21fbcd..01fe235e 100644 --- a/apps/axkdeck/src/lib/components/LayoutControls.test.ts +++ b/apps/axkdeck/src/lib/components/LayoutControls.test.ts @@ -85,9 +85,9 @@ describe('LayoutControls', () => { expect(document.activeElement).toBe(trigger); await fireEvent.click(trigger); - await fireEvent.click(screen.getByRole('menuitemradio', { name: '125%' })); + await fireEvent.click(screen.getByRole('menuitemradio', { name: '115%' })); - expect(oninterfacescalechange).toHaveBeenCalledWith('1.25'); + expect(oninterfacescalechange).toHaveBeenCalledWith('1.15'); expect(screen.queryByRole('menu', { name: 'Interface scale' })).toBeNull(); }); diff --git a/apps/axkdeck/src/lib/interfaceScale.test.ts b/apps/axkdeck/src/lib/interfaceScale.test.ts index 2fc7c773..13db8f0b 100644 --- a/apps/axkdeck/src/lib/interfaceScale.test.ts +++ b/apps/axkdeck/src/lib/interfaceScale.test.ts @@ -174,6 +174,15 @@ describe('InterfaceScaleController', () => { await controller.dispose(); }); + it('restores and applies the fixed 115% mode', async () => { + const { adapter } = adapterFor(monitor(1920, 1080)); + const controller = await createInterfaceScaleController(adapter, storageWith('1.15')); + + expect(controller.state()).toEqual({ mode: '1.15', appliedZoom: 1.15 }); + expect(adapter.setZoom).toHaveBeenCalledWith(1.15); + await controller.dispose(); + }); + it.each(['', '125%', '2', 'invalid'])('falls back to Auto for an invalid stored mode %j', async (stored) => { const { adapter } = adapterFor(monitor(2560, 1440)); @@ -241,7 +250,7 @@ describe('InterfaceScaleController', () => { await controller.dispose(); }); - it.each(['auto', '1', '1.25', '1.5'])( + it.each(['auto', '1', '1.15', '1.25', '1.5'])( 'does not mutate the native window when maximizing in %s mode', async (mode) => { const { adapter, emitMoved, emitResized, setMaximized } = adapterFor(monitor(1920, 1080)); @@ -341,7 +350,7 @@ describe('InterfaceScaleController', () => { it('accepts every declared fixed mode', async () => { const { adapter } = adapterFor(); const controller = await createInterfaceScaleController(adapter, storageWith()); - const modes: InterfaceScaleMode[] = ['1', '1.25', '1.5', 'auto']; + const modes: InterfaceScaleMode[] = ['1', '1.15', '1.25', '1.5', 'auto']; for (const mode of modes) await controller.setMode(mode); diff --git a/apps/axkdeck/src/lib/interfaceScale.ts b/apps/axkdeck/src/lib/interfaceScale.ts index a38f3dc7..bb509817 100644 --- a/apps/axkdeck/src/lib/interfaceScale.ts +++ b/apps/axkdeck/src/lib/interfaceScale.ts @@ -1,4 +1,4 @@ -export type InterfaceScaleMode = 'auto' | '1' | '1.25' | '1.5'; +export type InterfaceScaleMode = 'auto' | '1' | '1.15' | '1.25' | '1.5'; export interface InterfaceScaleState { mode: InterfaceScaleMode; @@ -49,7 +49,7 @@ const storageKey = 'axkdeck.interface-scale.v1'; const minimumWindowWidth = 800; const minimumWindowHeight = 600; const windowEventDebounceMs = 150; -const fixedModes = new Set(['1', '1.25', '1.5']); +const fixedModes = new Set(['1', '1.15', '1.25', '1.5']); function finitePositive(value: number): boolean { return Number.isFinite(value) && value > 0; diff --git a/apps/axkdeck/src/lib/startupVisibility.test.ts b/apps/axkdeck/src/lib/startupVisibility.test.ts new file mode 100644 index 00000000..af404443 --- /dev/null +++ b/apps/axkdeck/src/lib/startupVisibility.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { revealAfterInterfaceScale } from './startupVisibility'; + +describe('startup visibility', () => { + it('reveals the mounted shell before measuring its first visible frame', async () => { + document.documentElement.setAttribute('data-interface-scale-pending', ''); + let finishScale: (() => void) | undefined; + const scaleReady = new Promise((resolve) => { + finishScale = resolve; + }); + const waitForFirstFrame = vi.fn(async () => undefined); + + const ready = revealAfterInterfaceScale(scaleReady, waitForFirstFrame); + + expect(document.documentElement.hasAttribute('data-interface-scale-pending')).toBe(true); + expect(waitForFirstFrame).not.toHaveBeenCalled(); + + finishScale?.(); + await ready; + + expect(document.documentElement.hasAttribute('data-interface-scale-pending')).toBe(false); + expect(waitForFirstFrame).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/axkdeck/src/lib/startupVisibility.ts b/apps/axkdeck/src/lib/startupVisibility.ts new file mode 100644 index 00000000..22f83e70 --- /dev/null +++ b/apps/axkdeck/src/lib/startupVisibility.ts @@ -0,0 +1,9 @@ +export async function revealAfterInterfaceScale( + interfaceScaleReady: Promise, + waitForFirstFrame: () => Promise, + root: HTMLElement = document.documentElement, +): Promise { + await interfaceScaleReady; + root.removeAttribute('data-interface-scale-pending'); + await waitForFirstFrame(); +} diff --git a/apps/axkdeck/src/main.ts b/apps/axkdeck/src/main.ts index f293584d..f8498544 100644 --- a/apps/axkdeck/src/main.ts +++ b/apps/axkdeck/src/main.ts @@ -6,6 +6,7 @@ import { installDiagnostics, reportDiagnostic, reportError, reportInfo } from '. import { createInterfaceScaleController, type InterfaceScaleController } from './lib/interfaceScale'; import { prepareServerConnection, prepareStartup } from './lib/startupCoordinator'; import { frontendStartup, type StartupView } from './lib/startupDiagnostics'; +import { revealAfterInterfaceScale } from './lib/startupVisibility'; import { createTauriInterfaceScaleAdapter } from './lib/tauriInterfaceScale'; type ServerConnection = NonNullable; @@ -64,7 +65,6 @@ async function bootstrap(mountTarget: HTMLElement): Promise { }; renderShell(); frontendStartup.markShellMounted(); - const shellFirstFrame = frontendStartup.waitForShellFirstFrame(); const diagnosticsReady = installDiagnostics().finally(() => frontendStartup.markDiagnosticsInstalled()); const interfaceScalingReady = (async () => { if (!isDesktop) return; @@ -78,6 +78,9 @@ async function bootstrap(mountTarget: HTMLElement): Promise { reportDiagnostic('interface_scale_initialization_failed', { message: String(error) }, 'warn'); } })().finally(() => frontendStartup.markInterfaceScaleComplete()); + const shellFirstFrame = revealAfterInterfaceScale(interfaceScalingReady, () => + frontendStartup.waitForShellFirstFrame(), + ); const view: StartupView = new URLSearchParams(window.location.search).get('view') === 'allocation' ? 'allocation' : 'workspace'; From 7ec07e1550aff645bbd64fb914ea2d07908a6b6d Mon Sep 17 00:00:00 2001 From: bzeiss Date: Wed, 19 Aug 2026 08:04:42 +0200 Subject: [PATCH 03/12] visual improvements. --- .../lib/components/ServerStoragePicker.svelte | 20 ++++----- .../components/ServerStoragePicker.test.ts | 7 ++-- .../lib/components/WorkspaceManager.svelte | 35 ++++++++++------ .../lib/components/WorkspaceManager.test.ts | 41 +++++++++++++++++++ 4 files changed, 78 insertions(+), 25 deletions(-) diff --git a/apps/axkdeck/src/lib/components/ServerStoragePicker.svelte b/apps/axkdeck/src/lib/components/ServerStoragePicker.svelte index 277fd3fc..8e4ff761 100644 --- a/apps/axkdeck/src/lib/components/ServerStoragePicker.svelte +++ b/apps/axkdeck/src/lib/components/ServerStoragePicker.svelte @@ -450,6 +450,16 @@ diff --git a/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts b/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts index 3a56b5af..b658374d 100644 --- a/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts +++ b/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts @@ -305,7 +305,7 @@ describe('ServerStoragePicker', () => { }); }); - it('keeps a persistent location bar with the path before the right-aligned home action', async () => { + it('keeps a persistent location bar ordered home, parent, then path', async () => { render(ServerStoragePicker, { props: { transport: transport(), @@ -330,8 +330,9 @@ describe('ServerStoragePicker', () => { const home = within(location).getByRole('button', { name: 'Go to all workspaces' }); expect(parent.hasAttribute('disabled')).toBe(false); expect(home.hasAttribute('disabled')).toBe(false); - expect(path.compareDocumentPosition(home) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(location.lastElementChild?.contains(home)).toBe(true); + expect(location.firstElementChild).toBe(home); + expect(home.compareDocumentPosition(parent) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(parent.compareDocumentPosition(path) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it('uses parent navigation to return from a workspace root to the workspace list', async () => { diff --git a/apps/axkdeck/src/lib/components/WorkspaceManager.svelte b/apps/axkdeck/src/lib/components/WorkspaceManager.svelte index dd3f7fe8..51bf4678 100644 --- a/apps/axkdeck/src/lib/components/WorkspaceManager.svelte +++ b/apps/axkdeck/src/lib/components/WorkspaceManager.svelte @@ -379,37 +379,48 @@ border-color: rgb(190 80 80 / 35%); background: rgb(120 35 35 / 20%); } - .workspace-list, + .workspace-list { + display: grid; + gap: 2px; + max-height: 200px; + overflow: auto; + padding: 6px 12px; + } .host-browser { min-height: 72px; overflow: auto; padding: 6px 12px; } - .workspace-list { - max-height: 200px; - } - .workspace-row, - .host-row { - min-height: 36px; + .workspace-row { + min-height: 32px; display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 8px; - border-bottom: 1px solid var(--color-border); + padding: 3px 0; } - .workspace-row span, - .workspace-row small { - display: block; + .workspace-row span { + min-width: 0; + display: grid; + gap: 0; } .workspace-row strong { font-size: 10px; + line-height: 11px; } .workspace-row small { - margin-top: 1px; + display: block; + margin: 0; color: var(--color-text-muted); font-size: 8px; + line-height: 9px; } .host-row { + min-height: 36px; + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: 8px; width: 100%; color: inherit; text-align: left; diff --git a/apps/axkdeck/src/lib/components/WorkspaceManager.test.ts b/apps/axkdeck/src/lib/components/WorkspaceManager.test.ts index c4653d16..b23026e6 100644 --- a/apps/axkdeck/src/lib/components/WorkspaceManager.test.ts +++ b/apps/axkdeck/src/lib/components/WorkspaceManager.test.ts @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import workspaceManagerSource from './WorkspaceManager.svelte?raw'; const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); @@ -76,6 +77,46 @@ describe('WorkspaceManager', () => { expect(screen.queryByText('Server storage')).toBeNull(); }); + it('renders configured storage locations as a compact undivided list', async () => { + vi.mocked(fetch).mockResolvedValue(activeWorkspaceResponse()); + render(WorkspaceManager, { props: { open: true, onclose: vi.fn() } }); + + const list = await screen.findByRole('list', { name: 'Configured storage locations' }); + const rows = list.querySelectorAll('.workspace-row'); + expect(rows).toHaveLength(2); + + const listGeometry = workspaceManagerSource.match(/\.workspace-list\s*\{[^}]+\}/)?.[0]; + const rowGeometry = workspaceManagerSource.match(/\.workspace-row\s*\{[^}]+\}/)?.[0]; + const nameGeometry = workspaceManagerSource.match(/\.workspace-row strong\s*\{[^}]+\}/)?.[0]; + const statusGeometry = workspaceManagerSource.match(/\.workspace-row small\s*\{[^}]+\}/)?.[0]; + expect(listGeometry).toBeDefined(); + expect(rowGeometry).toBeDefined(); + expect(nameGeometry).toBeDefined(); + expect(statusGeometry).toBeDefined(); + const style = document.createElement('style'); + style.textContent = `${listGeometry}\n${rowGeometry}\n${nameGeometry}\n${statusGeometry}`; + document.head.append(style); + + const listStyle = getComputedStyle(list); + expect(listStyle.minHeight).toBe('auto'); + expect(listStyle.paddingTop).toBe('6px'); + expect(listStyle.paddingBottom).toBe('6px'); + expect(listStyle.gap).toBe('2px'); + + const rowStyle = getComputedStyle(rows[0]); + expect(rowStyle.minHeight).toBe('32px'); + expect(rowStyle.borderBottomStyle).toBe('none'); + + const name = screen.getByText('Open image location'); + const status = rows[0].querySelector('small'); + expect(status).not.toBeNull(); + expect(getComputedStyle(name).lineHeight).toBe('11px'); + expect(getComputedStyle(status!).lineHeight).toBe('9px'); + expect(getComputedStyle(status!).marginTop).toBe('0px'); + + style.remove(); + }); + it('opens the native picker and presents the selected directory for confirmation', async () => { mocks.invoke.mockResolvedValue({ candidateId: 'candidate-1', suggestedName: 'Samples' }); render(WorkspaceManager, { props: { open: false, onclose: vi.fn() } }); From 847f14feeb6e5cf5b83ec73a4b2d93ebda0e6503 Mon Sep 17 00:00:00 2001 From: bzeiss Date: Wed, 19 Aug 2026 20:03:05 +0200 Subject: [PATCH 04/12] webview minimum version, allow cancellation before webview is installed. --- README.md | 6 +++ apps/axkdeck/README.md | 12 ++++- apps/axkdeck/src-tauri/tauri.conf.json | 8 ++- .../src-tauri/windows/webview2-consent.nsh | 53 +++++++++++++++++++ apps/axkdeck/vite.config.ts | 2 +- docs/index.md | 7 +++ tools/python/tests/test_release_tools.py | 32 ++++++++++- 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 apps/axkdeck/src-tauri/windows/webview2-consent.nsh diff --git a/README.md b/README.md index 683dd6d1..62515f09 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ the source tree and its CMake package, keeping the compiler, standard library, and dependency choices under their own control. The local `axklib-server` is an axkdeck sidecar and is not published as a standalone download. +Windows installers require Microsoft Edge WebView2 Evergreen Runtime version +111 or newer. An interactive installation asks before downloading or updating +an insufficient runtime from Microsoft; silent `/S` installations perform that +prerequisite step without a prompt. The installer does not bundle a fixed +WebView2 runtime and does not replace a newer installed version. + ## Command Line The CLI exposes the same image and object operations for scripts and batch diff --git a/apps/axkdeck/README.md b/apps/axkdeck/README.md index d765e2d3..80f1abe5 100644 --- a/apps/axkdeck/README.md +++ b/apps/axkdeck/README.md @@ -166,7 +166,8 @@ WebView2: and select the **Desktop development with C++** workload. 2. Ensure the [WebView2 Evergreen Runtime](https://developer.microsoft.com/microsoft-edge/webview2/#download-section) - is installed. It is already included with Windows 10 version 1803 and newer. + version 111 or newer is installed. WebView2 is already included with current + Windows releases. 3. Install Rust with the MSVC host toolchain and use Node.js LTS. Open a new PowerShell terminal and verify the toolchain: @@ -184,6 +185,15 @@ See the official [Tauri Windows prerequisites](https://v2.tauri.app/start/prerequisites/#windows) for installer details and troubleshooting. +The packaged NSIS installer checks for WebView2 version `111.0.0.0` or newer. +When an interactive installation finds no runtime or an older runtime, axkdeck +discloses the installed and required versions and asks before continuing. If +accepted, the installer downloads Microsoft's current Evergreen bootstrapper; +the runtime is not bundled in the installer. An unattended `/S` installation +performs the same check and installation without a prompt. A newer installed +runtime is retained, and the shared Evergreen Runtime continues to receive its +normal Microsoft updates. + Tauri desktop packages are native to the build host. The native CI matrix builds the C++ targets once per platform and then reuses the resulting server for the matching axkdeck build. Release packaging produces a universal macOS DMG, diff --git a/apps/axkdeck/src-tauri/tauri.conf.json b/apps/axkdeck/src-tauri/tauri.conf.json index 91628bbd..260a7851 100644 --- a/apps/axkdeck/src-tauri/tauri.conf.json +++ b/apps/axkdeck/src-tauri/tauri.conf.json @@ -49,9 +49,15 @@ "resources/axkdeck.spdx.json": "licenses/axkdeck.spdx.json" }, "windows": { + "minimumWebview2Version": "111.0.0.0", + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + }, "nsis": { "installerIcon": "icons/icon.ico", - "uninstallerIcon": "icons/icon.ico" + "uninstallerIcon": "icons/icon.ico", + "installerHooks": "windows/webview2-consent.nsh" } }, "macOS": { diff --git a/apps/axkdeck/src-tauri/windows/webview2-consent.nsh b/apps/axkdeck/src-tauri/windows/webview2-consent.nsh new file mode 100644 index 00000000..1809cf21 --- /dev/null +++ b/apps/axkdeck/src-tauri/windows/webview2-consent.nsh @@ -0,0 +1,53 @@ +!define AXKDECK_WEBVIEW2_APP_GUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" +!define AXKDECK_MINIMUM_WEBVIEW2_VERSION "111.0.0.0" + +; Tauri's WebView2 section runs before NSIS_HOOK_PREINSTALL. Use the GUI +; callback so an interactive user can decline before any runtime is changed. +Function .onGUIInit + IfSilent axkdeck_webview2_consent_done 0 + + StrCpy $0 "" + StrCpy $1 "" + ${If} ${RunningX64} + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${AXKDECK_WEBVIEW2_APP_GUID}" "pv" + ${Else} + ReadRegStr $0 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${AXKDECK_WEBVIEW2_APP_GUID}" "pv" + ${EndIf} + ReadRegStr $1 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${AXKDECK_WEBVIEW2_APP_GUID}" "pv" + + ; Use the newest visible machine- or user-scoped Evergreen Runtime. + StrCpy $2 $0 + ${If} $2 == "" + ${OrIf} $2 == "0.0.0.0" + StrCpy $2 $1 + ${Else} + ${If} $1 != "" + ${If} $1 != "0.0.0.0" + ${VersionCompare} "$1" "$2" $3 + ${If} $3 == 1 + StrCpy $2 $1 + ${EndIf} + ${EndIf} + ${EndIf} + ${EndIf} + + ${If} $2 == "" + ${OrIf} $2 == "0.0.0.0" + StrCpy $4 "Not installed" + Goto axkdeck_webview2_consent_prompt + ${EndIf} + + ${VersionCompare} "${AXKDECK_MINIMUM_WEBVIEW2_VERSION}" "$2" $3 + ${If} $3 != 1 + Goto axkdeck_webview2_consent_done + ${EndIf} + StrCpy $4 $2 + + axkdeck_webview2_consent_prompt: + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \ + "axkdeck requires the Microsoft Edge WebView2 Evergreen Runtime.$\r$\n$\r$\nInstalled version: $4$\r$\nMinimum required version: ${AXKDECK_MINIMUM_WEBVIEW2_VERSION}$\r$\n$\r$\nContinue to let setup download and install or update the shared runtime from Microsoft? WebView2 updates automatically after installation." \ + IDOK axkdeck_webview2_consent_done + Quit + + axkdeck_webview2_consent_done: +FunctionEnd diff --git a/apps/axkdeck/vite.config.ts b/apps/axkdeck/vite.config.ts index 5e5b1619..3fb56086 100644 --- a/apps/axkdeck/vite.config.ts +++ b/apps/axkdeck/vite.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ }, envPrefix: ['VITE_', 'TAURI_ENV_*'], build: { - target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13', + target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome111' : 'safari16.4', minify: process.env.TAURI_ENV_DEBUG ? false : 'oxc', sourcemap: Boolean(process.env.TAURI_ENV_DEBUG), }, diff --git a/docs/index.md b/docs/index.md index 572ca559..887ac54f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,13 @@ a separately validated repaired copy without changing the source image. [Download axkdeck](https://github.com/tphzz/axklib/releases) +On Windows, axkdeck requires Microsoft Edge WebView2 Evergreen Runtime version +111 or newer. The interactive NSIS installer reports a missing or outdated +runtime and asks before downloading or updating it from Microsoft. Silent `/S` +installations perform the prerequisite step without a prompt. The installer +uses the online Evergreen bootstrapper rather than bundling a fixed runtime and +leaves newer installed versions in place. + ## Other Interfaces - The [CLI reference](axklib/cli.md) covers scripted and batch workflows. diff --git a/tools/python/tests/test_release_tools.py b/tools/python/tests/test_release_tools.py index abf670d8..1bb78b9a 100644 --- a/tools/python/tests/test_release_tools.py +++ b/tools/python/tests/test_release_tools.py @@ -862,12 +862,42 @@ def test_windows_desktop_bundle_uses_branded_gui_startup() -> None: configuration = json.loads( (desktop / "src-tauri/tauri.conf.json").read_text(encoding="utf-8") ) - nsis = configuration["bundle"]["windows"]["nsis"] + windows = configuration["bundle"]["windows"] + nsis = windows["nsis"] assert nsis["installerIcon"] == "icons/icon.ico" assert nsis["uninstallerIcon"] == "icons/icon.ico" assert (desktop / "src-tauri" / nsis["installerIcon"]).is_file() + minimum_webview2_version = "111.0.0.0" + assert windows["minimumWebview2Version"] == minimum_webview2_version + assert windows["webviewInstallMode"] == { + "type": "downloadBootstrapper", + "silent": True, + } + assert nsis["installerHooks"] == "windows/webview2-consent.nsh" + + installer_hook = (desktop / "src-tauri" / nsis["installerHooks"]).read_text( + encoding="utf-8" + ) + assert "Function .onGUIInit" in installer_hook + assert "IfSilent" in installer_hook + assert minimum_webview2_version in installer_hook + assert "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" in installer_hook + assert "Microsoft" in installer_hook + assert "EdgeUpdate" in installer_hook + assert "Clients" in installer_hook + assert '"pv"' in installer_hook + assert "Installed version:" in installer_hook + assert "Minimum required version:" in installer_hook + assert "download and install or update" in installer_hook + assert "MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION" in installer_hook + assert "Quit" in installer_hook + + vite = (desktop / "vite.config.ts").read_text(encoding="utf-8") + assert "'chrome111'" in vite + assert "'safari16.4'" in vite + main = (desktop / "src-tauri/src/main.rs").read_text(encoding="utf-8") assert '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]' in main sidecar = (desktop / "src-tauri/src/server_sidecar.rs").read_text(encoding="utf-8") From 3851ddec779d9c8c3b81fdf72acda5be9a4ab7b4 Mon Sep 17 00:00:00 2001 From: bzeiss Date: Thu, 20 Aug 2026 08:13:32 +0200 Subject: [PATCH 05/12] overall drag & drop improvements. --- .../src/package_operations_support.cpp | 28 +- apps/application/src/package_plan_store.cpp | 13 +- .../src/session_package_import_plan.cpp | 17 + apps/application/src/uploads.cpp | 4 +- .../tests/package_operations_test.cpp | 38 +++ apps/application/tests/uploads_test.cpp | 10 +- apps/axkdeck/src-tauri/src/lib.rs | 2 +- apps/axkdeck/src-tauri/src/local_packages.rs | 9 +- apps/axkdeck/src/App.svelte | 11 +- apps/axkdeck/src/App.test.ts | 6 +- apps/axkdeck/src/StartupShell.svelte | 2 +- apps/axkdeck/src/app.css | 104 +++++- apps/axkdeck/src/features/catalog/hooks.ts | 13 + .../src/features/dialogs/AppDialogs.svelte | 18 +- .../import/mediaDropWorkflow.svelte.ts | 97 ++++-- .../features/import/mediaDropWorkflow.test.ts | 5 + .../import/packageDestinations.test.ts | 27 ++ .../features/import/packageDestinations.ts | 97 ++++++ .../features/import/packageWorkflow.svelte.ts | 273 ++++++++++++++- .../features/import/packageWorkflow.test.ts | 189 +++++++++++ .../components/AssignSampleBankDialog.svelte | 46 +-- .../components/AssignSampleBankDialog.test.ts | 6 +- .../lib/components/AudioImportDialog.test.ts | 1 + .../src/lib/components/AudioImportRows.svelte | 18 +- .../components/AudioSamplerSettings.svelte | 23 +- .../lib/components/CollectionToolbar.svelte | 1 + .../lib/components/CollectionToolbar.test.ts | 46 +++ .../src/lib/components/ImageNavigator.svelte | 2 +- .../PackageImportDestination.svelte | 321 ++++++++++++++++++ .../PackageImportDestination.test.ts | 116 +++++++ .../lib/components/PackageImportDialog.svelte | 69 +++- .../components/PackageImportDialog.test.ts | 39 +++ .../src/lib/components/TreeNode.svelte | 6 +- .../lib/components/Tx16wImportDialog.svelte | 7 +- .../lib/components/VolumeActionDialog.svelte | 1 + apps/axkdeck/src/lib/generated/axklibApiV1.ts | 8 +- apps/axkdeck/src/lib/nativeMediaDrop.test.ts | 21 ++ apps/axkdeck/src/lib/nativeMediaDrop.ts | 8 +- apps/axkdeck/src/lib/packageImportMedia.ts | 22 ++ apps/server/contracts/openapi-v1.base.json | 12 +- apps/server/contracts/openapi-v1.json | 28 +- 41 files changed, 1629 insertions(+), 135 deletions(-) create mode 100644 apps/axkdeck/src/features/catalog/hooks.ts create mode 100644 apps/axkdeck/src/features/import/packageDestinations.test.ts create mode 100644 apps/axkdeck/src/features/import/packageDestinations.ts create mode 100644 apps/axkdeck/src/lib/components/PackageImportDestination.svelte create mode 100644 apps/axkdeck/src/lib/components/PackageImportDestination.test.ts create mode 100644 apps/axkdeck/src/lib/packageImportMedia.ts diff --git a/apps/application/src/package_operations_support.cpp b/apps/application/src/package_operations_support.cpp index b1fb448f..75ccc4f3 100644 --- a/apps/application/src/package_operations_support.cpp +++ b/apps/application/src/package_operations_support.cpp @@ -1,6 +1,8 @@ #include "package_operations_internal.hpp" #include +#include +#include #include #include #include @@ -108,8 +110,13 @@ axk::app::Result resolve_package(const PackageInput &input, std auto snapshot = uploads.inspect(upload, owner_id); if (!snapshot) return std::unexpected(snapshot.error()); - if (snapshot->kind != axk::app::UploadKind::package) { - return std::unexpected(operation_error("upload_kind_mismatch", "upload is not a portable package")); + auto extension = std::filesystem::path{snapshot->filename}.extension().string(); + std::ranges::transform(extension, extension.begin(), + [](unsigned char character) { return static_cast(std::tolower(character)); }); + if (snapshot->kind != axk::app::UploadKind::package && + !(snapshot->kind == axk::app::UploadKind::disk_image && extension == ".a3k")) { + return std::unexpected( + operation_error("upload_kind_mismatch", "upload is not a portable package or A3K archive")); } auto lease = uploads.lease(upload, owner_id); if (!lease) @@ -122,6 +129,23 @@ axk::app::Result resolve_package(const PackageInput &input, std axk::app::Result read_package(const ResolvedPackage &resolved, bool verify, const axk::app::OperationContext &context) { + auto extension = std::filesystem::path{resolved.filename}.extension().string(); + std::ranges::transform(extension, extension.begin(), + [](unsigned char character) { return static_cast(std::tolower(character)); }); + if (extension == ".a3k") { + auto media = axk::open_media(resolved.reader, std::filesystem::path{resolved.filename}, context.cancellation); + if (!media) + return std::unexpected(core_error(media.error())); + if (media->kind() != axk::MediaKind::a3k_archive) + return std::unexpected(operation_error("package_read_failed", "source is not an A3K volume archive")); + axk::PackageRootSelector root; + root.kind = axk::PackageRootKind::volume; + const std::array roots{std::move(root)}; + auto package = axk::build_portable_package(*media, roots, context.cancellation); + if (!package) + return std::unexpected(core_error(package.error())); + return std::move(package->package); + } auto package = verify ? axk::open_portable_package(*resolved.reader, resolved.filename, context.cancellation) : axk::inspect_portable_package(*resolved.reader, resolved.filename, context.cancellation); if (!package) diff --git a/apps/application/src/package_plan_store.cpp b/apps/application/src/package_plan_store.cpp index 4c8b074b..417182a6 100644 --- a/apps/application/src/package_plan_store.cpp +++ b/apps/application/src/package_plan_store.cpp @@ -1,5 +1,7 @@ #include "package_plan_store.hpp" +#include +#include #include #include #include @@ -160,8 +162,15 @@ axk::app::package_plan_internal::retain_sources(std::span in auto snapshot = uploads.inspect(upload, owner_id); if (!snapshot) return std::unexpected(snapshot.error()); - if (snapshot->state != UploadState::ready || snapshot->kind != UploadKind::package) - return std::unexpected(plan_error("upload_kind_mismatch", "upload is not a ready portable package")); + auto extension = std::filesystem::path{snapshot->filename}.extension().string(); + std::ranges::transform(extension, extension.begin(), + [](unsigned char character) { return static_cast(std::tolower(character)); }); + const auto supported_kind = snapshot->kind == UploadKind::package || + (snapshot->kind == UploadKind::disk_image && extension == ".a3k"); + if (snapshot->state != UploadState::ready || !supported_kind) { + return std::unexpected( + plan_error("upload_kind_mismatch", "upload is not a ready portable package or A3K archive")); + } auto lease = uploads.lease(upload, owner_id); if (!lease) return std::unexpected(lease.error()); diff --git a/apps/application/src/session_package_import_plan.cpp b/apps/application/src/session_package_import_plan.cpp index 9cbbecb8..4395591a 100644 --- a/apps/application/src/session_package_import_plan.cpp +++ b/apps/application/src/session_package_import_plan.cpp @@ -107,6 +107,23 @@ prepare_session_import(const Json &input, std::span {package_index, root_index, *partition, {}, volume_name, {}, {}, false}); } } + } else if (kind == "CREATE_VOLUME") { + if (packages.size() != 1U) + return std::unexpected( + operation_error("invalid_request", "CREATE_VOLUME requires exactly one import source")); + const auto volume_name = destination.at("volumeName").get(); + if (!valid_volume_name(volume_name)) + return std::unexpected(operation_error("invalid_request", "destination volume name is invalid")); + if (std::ranges::any_of(volume_scopes_by_id, [&](const auto &entry) { + return entry.second.partition_index == *partition && entry.second.display_name == volume_name; + })) { + return std::unexpected( + operation_error("package_destination_conflict", "destination volume name already exists")); + } + result.destination_volume_names.assign(1U, volume_name); + for (std::size_t root_index = 0U; root_index < packages.front().roots.size(); ++root_index) { + result.request.root_destinations.push_back({0U, root_index, *partition, {}, volume_name, {}, {}, true}); + } } else if (kind == "CREATE_VOLUMES_FROM_HINTS") { std::map overrides; for (const auto &override_value : destination.value("volumeNameOverrides", Json::array())) { diff --git a/apps/application/src/uploads.cpp b/apps/application/src/uploads.cpp index 63b702a0..76e11046 100644 --- a/apps/application/src/uploads.cpp +++ b/apps/application/src/uploads.cpp @@ -62,7 +62,7 @@ bool admitted_extension(axk::app::UploadKind kind, const std::filesystem::path & case axk::app::UploadKind::manifest: return extension == ".json"; case axk::app::UploadKind::disk_image: - return extension == ".img" || extension == ".ima"; + return extension == ".img" || extension == ".ima" || extension == ".a3k"; } return false; } @@ -96,7 +96,7 @@ std::string_view disallowed_upload_message(axk::app::UploadKind kind) { case axk::app::UploadKind::manifest: return "manifest uploads require a JSON file"; case axk::app::UploadKind::disk_image: - return "disk image uploads require an IMG or IMA file"; + return "media uploads require an IMG, IMA, or A3K file"; } return "upload type is not allowed"; } diff --git a/apps/application/tests/package_operations_test.cpp b/apps/application/tests/package_operations_test.cpp index 37f50027..7c9a81ef 100644 --- a/apps/application/tests/package_operations_test.cpp +++ b/apps/application/tests/package_operations_test.cpp @@ -709,6 +709,44 @@ TEST_F(PackageOperationsTest, SessionExportsA3kArchiveVolumeAsDirectPackage) { EXPECT_EQ(package->source_media_kind, "a3k-archive"); } +TEST_F(PackageOperationsTest, InspectsA3kArchiveAsAnImportableVolumePackage) { + const auto inspected = registry_.invoke( + "package.inspect", {{"package", {{"fileRef", {{"rootId", "workspace"}, {"relativePath", "archive.a3k"}}}}}}, + context()); + ASSERT_TRUE(inspected) << inspected.error().message; + EXPECT_EQ(inspected->at("packageKind"), "volume"); + EXPECT_EQ(inspected->at("requiredExtension"), ".axkvol"); + EXPECT_EQ(inspected->at("sourceMediaKind"), "a3k-archive"); + ASSERT_EQ(inspected->at("roots").size(), 1U); + EXPECT_EQ(inspected->at("roots").front().at("displayName"), "Archive Volume"); +} + +TEST_F(PackageOperationsTest, SessionImportCreatesOneExplicitlyNamedVolumeForA3kArchive) { + const auto opened = images_->open({"workspace", "target.hds"}, "owner"); + ASSERT_TRUE(opened) << opened.error().message; + const auto request = nlohmann::json{ + {"imageId", opened->image_id}, + {"expectedRevision", opened->revision}, + {"packages", {{{"fileRef", {{"rootId", "workspace"}, {"relativePath", "archive.a3k"}}}}}}, + {"destination", {{"kind", "CREATE_VOLUME"}, {"partitionIndex", 0U}, {"volumeName", "Imported A3K"}}}, + {"renames", nlohmann::json::array()}, + {"programSlotAssignments", nlohmann::json::array()}, + {"opaqueSequenceDecisions", nlohmann::json::array()}, + }; + const auto planned = registry_.invoke("images.package_import.plan", request, context()); + ASSERT_TRUE(planned) << planned.error().message; + ASSERT_TRUE(planned->at("valid").get()); + ASSERT_EQ(planned->at("packages").size(), 1U); + EXPECT_EQ(planned->at("packages").front().at("destinationVolumeName"), "Imported A3K"); + + const auto applied = registry_.invoke("images.package_import", + {{"planToken", planned->at("planToken").get()}}, context()); + ASSERT_TRUE(applied) << applied.error().message; + const auto refreshed = images_->inspect(opened->image_id, "owner"); + ASSERT_TRUE(refreshed) << refreshed.error().message; + EXPECT_FALSE(volume_content_id(*refreshed, "Imported A3K").empty()); +} + TEST_F(PackageOperationsTest, SessionInspectsAndExportsImmediateVolumePackagesWithReport) { const auto opened = images_->open({"workspace", "batch-volumes.hds"}, "owner"); ASSERT_TRUE(opened) << opened.error().message; diff --git a/apps/application/tests/uploads_test.cpp b/apps/application/tests/uploads_test.cpp index 7b0b270c..c4809d68 100644 --- a/apps/application/tests/uploads_test.cpp +++ b/apps/application/tests/uploads_test.cpp @@ -113,6 +113,14 @@ TEST_F(UploadStoreTest, RejectsDiskImagesWrongOwnersOffsetsAndOversizedChunks) { .sha256 = std::nullopt}); ASSERT_TRUE(tx16w_disk) << tx16w_disk.error().message; ASSERT_TRUE(value.remove(tx16w_disk->reference, "owner")); + const auto a3k_archive = value.create({.owner_id = "owner", + .filename = "JupiterPad.a3k", + .kind = axk::app::UploadKind::disk_image, + .media_type = "application/octet-stream", + .declared_size = 1U, + .sha256 = std::nullopt}); + ASSERT_TRUE(a3k_archive) << a3k_archive.error().message; + ASSERT_TRUE(value.remove(a3k_archive->reference, "owner")); const auto wrong_disk_extension = value.create({.owner_id = "owner", .filename = "tx16w.iso", .kind = axk::app::UploadKind::disk_image, @@ -120,7 +128,7 @@ TEST_F(UploadStoreTest, RejectsDiskImagesWrongOwnersOffsetsAndOversizedChunks) { .declared_size = 1U, .sha256 = std::nullopt}); ASSERT_FALSE(wrong_disk_extension); - EXPECT_EQ(wrong_disk_extension.error().message, "disk image uploads require an IMG or IMA file"); + EXPECT_EQ(wrong_disk_extension.error().message, "media uploads require an IMG, IMA, or A3K file"); const auto mislabeled_audio = value.create({.owner_id = "owner", .filename = "sample.wav", diff --git a/apps/axkdeck/src-tauri/src/lib.rs b/apps/axkdeck/src-tauri/src/lib.rs index f41dd553..5f80007e 100644 --- a/apps/axkdeck/src-tauri/src/lib.rs +++ b/apps/axkdeck/src-tauri/src/lib.rs @@ -158,7 +158,7 @@ mod tests { #[test] fn package_picker_accepts_every_current_package_extension() { for extension in [ - "axkvol", "axkprg", "axksbac", "axksbnk", "axksmpl", "axkseq", "axkpkg", + "a3k", "axkvol", "axkprg", "axksbac", "axksbnk", "axksmpl", "axkseq", "axkpkg", ] { assert_eq!( supported_package_extension(&format!("Package.{extension}")), diff --git a/apps/axkdeck/src-tauri/src/local_packages.rs b/apps/axkdeck/src-tauri/src/local_packages.rs index 78a0d3f1..39e005d1 100644 --- a/apps/axkdeck/src-tauri/src/local_packages.rs +++ b/apps/axkdeck/src-tauri/src/local_packages.rs @@ -14,8 +14,8 @@ use crate::desktop_preferences::DesktopPreferencesStore; use crate::{file_publication, remote_settings, retained_download, server_sidecar}; const MAX_RETAINED_PACKAGE_BYTES: u64 = 4 * 1024 * 1024 * 1024; -const SUPPORTED_PACKAGE_EXTENSIONS: [&str; 7] = [ - "axkvol", "axkprg", "axksbac", "axksbnk", "axksmpl", "axkseq", "axkpkg", +const SUPPORTED_PACKAGE_EXTENSIONS: [&str; 8] = [ + "a3k", "axkvol", "axkprg", "axksbac", "axksbnk", "axksmpl", "axkseq", "axkpkg", ]; const SUPPORTED_MEDIA_EXTENSIONS: [&str; 3] = ["iso", "ima", "zip"]; @@ -118,7 +118,10 @@ pub(crate) async fn select_local_package( .dialog() .file() .set_title("Choose axklib package") - .add_filter("axklib packages", &SUPPORTED_PACKAGE_EXTENSIONS) + .add_filter( + "axklib packages and A3K archives", + &SUPPORTED_PACKAGE_EXTENSIONS, + ) .set_parent(&window); if let Some(directory) = starting_directory { dialog = dialog.set_directory(directory); diff --git a/apps/axkdeck/src/App.svelte b/apps/axkdeck/src/App.svelte index 9d7b4bfc..8f587f70 100644 --- a/apps/axkdeck/src/App.svelte +++ b/apps/axkdeck/src/App.svelte @@ -2,6 +2,7 @@ import { onDestroy, onMount } from 'svelte'; import type { AppProps } from './appProps'; import { AuditionWorkflow } from './features/audition/workflow.svelte'; + import { createCatalogHooks } from './features/catalog/hooks'; import { CatalogWorkflow } from './features/catalog/workflow.svelte'; import { createConnectionActions } from './features/connection/actions'; import { DeletionWorkflow } from './features/deletion/workflow.svelte'; @@ -115,11 +116,7 @@ sessionId: () => imageSessionWorkflow.sessionId, setStatus: (status) => imageSessionWorkflow.setStatus(status), }); - const catalogHooks = { - stopPlayback: () => Promise.resolve(), - resetPreviews: () => {}, - resetCleanup: () => {}, - }; + const catalogHooks = createCatalogHooks(); const catalog = new CatalogWorkflow({ transport, sessionId: () => imageSessionWorkflow.sessionId, @@ -164,6 +161,9 @@ refreshSession: (preferred) => imageSessionWorkflow.refresh(preferred), setStatus: (status) => imageSessionWorkflow.setStatus(status), pickerHistory: packagePickerHistory, + mutationsAvailable: () => imageSessionWorkflow.packageImportAvailable, + selectedSource: () => imageSessionWorkflow.selectedSource, + sourceItems: () => imageSessionWorkflow.sourceItems, }); const packageBatchImportWorkflow = new PackageBatchImportWorkflow({ transport, @@ -273,6 +273,7 @@ audioImport: audioImportWorkflow, sequenceImport: sequenceImportWorkflow, tx16wImport: tx16wImportWorkflow, + packageImport: packageImportWorkflow, setStatus: (status) => imageSessionWorkflow.setStatus(status), }); imageSessionWorkflow.connect({ diff --git a/apps/axkdeck/src/App.test.ts b/apps/axkdeck/src/App.test.ts index 4dc052a8..1652a70c 100644 --- a/apps/axkdeck/src/App.test.ts +++ b/apps/axkdeck/src/App.test.ts @@ -623,7 +623,7 @@ describe('App panel layout', () => { expect(screen.getByRole('button', { name: 'Create image' }).closest('aside')).toBe(navigator); expect(screen.queryByRole('textbox', { name: 'Disk image path' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Eject image' })).toBeNull(); - expect(screen.getByText('Partitions, volumes and objects')).toBeTruthy(); + expect(screen.getByText('Partitions and volumes')).toBeTruthy(); }); it('closes the active image and returns to the initial empty state', async () => { @@ -1727,7 +1727,9 @@ describe('App panel layout', () => { Object.defineProperty(mixedDrop, 'dataTransfer', { value: mixedTransfer }); window.dispatchEvent(mixedDrop); unavailable = await screen.findByRole('dialog', { name: 'Import unavailable' }); - expect(within(unavailable).getByText('Drop audio, MIDI, and TX16W disks separately.')).toBeTruthy(); + expect( + within(unavailable).getByText('Drop packages, A3K archives, audio, MIDI, and TX16W disks separately.'), + ).toBeTruthy(); expect(screen.queryByRole('dialog', { name: 'Import MIDI' })).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Import audio' })).toBeNull(); }); diff --git a/apps/axkdeck/src/StartupShell.svelte b/apps/axkdeck/src/StartupShell.svelte index 68f0a76b..5141f7d2 100644 --- a/apps/axkdeck/src/StartupShell.svelte +++ b/apps/axkdeck/src/StartupShell.svelte @@ -35,7 +35,7 @@

IMAGE

Open Image

CONTENTS

- Partitions, volumes and objects + Partitions and volumes
Programs0 items
diff --git a/apps/axkdeck/src/app.css b/apps/axkdeck/src/app.css index 9b31935a..75046b12 100644 --- a/apps/axkdeck/src/app.css +++ b/apps/axkdeck/src/app.css @@ -29,6 +29,9 @@ --density-table-header: 20px; --density-metadata-row: 22px; --density-gap: 4px; + --dialog-control-font-size: 11px; + --dialog-label-font-size: 10px; + --dialog-option-metadata-font-size: 10px; --overlay-scrollbar-clearance: 16px; --contained-playback-clearance: 10px; font-family: var(--font-sans); @@ -70,7 +73,9 @@ textarea, } button, -input { +input, +select, +textarea { font: inherit; } @@ -2123,13 +2128,71 @@ input:focus-visible { font-size: 10px; } -.volume-action-content input { +.dialog-field-control { + min-width: 0; height: var(--density-control); padding: 0 8px; color: var(--color-text-strong); border: 1px solid var(--color-border); border-radius: 5px; + outline: none; background: var(--color-bg-deep); + color-scheme: dark; + font-family: inherit; + font-size: var(--dialog-control-font-size); + line-height: 1.2; +} + +.dialog-field-control:focus { + border-color: var(--color-accent); +} + +.dialog-autocomplete-list { + display: grid; + max-height: 220px; + overflow-y: auto; + color: var(--color-text-strong); + border: 1px solid var(--color-border); + background: var(--color-bg-deep); + box-shadow: 0 10px 24px rgb(0 0 0 / 36%); +} + +.dialog-autocomplete-option { + min-height: 28px; + padding: 0 8px; + color: var(--color-text-strong); + border: 0; + border-bottom: 1px solid var(--color-border); + border-radius: 0; + background: transparent; + font-family: inherit; + font-size: var(--dialog-control-font-size); + line-height: 1.25; + text-align: left; +} + +.dialog-autocomplete-option:last-of-type { + border-bottom: 0; +} + +.dialog-autocomplete-option:hover:not(:disabled), +.dialog-autocomplete-option.active:not(:disabled) { + background: rgb(104 151 187 / 14%); +} + +.dialog-autocomplete-option:disabled { + color: var(--color-text-muted); + cursor: not-allowed; +} + +.dialog-autocomplete-option small, +.dialog-autocomplete-empty { + color: var(--color-text-muted); + font-size: var(--dialog-option-metadata-font-size); +} + +.volume-action-content input { + width: 100%; } .volume-action-content strong { @@ -2781,11 +2844,44 @@ input:focus-visible { white-space: nowrap; } -.collection-filter input { +.compact-checkbox { + display: grid; width: 12px; height: 12px; + flex: none; margin: 0; - accent-color: var(--color-accent); + padding: 0; + overflow: hidden; + appearance: none; + place-content: center; + color: #fff; + border: 1px solid var(--color-border); + border-radius: 2px; + background: var(--color-bg-deep); + cursor: pointer; +} + +.compact-checkbox::before { + width: 3px; + height: 6px; + content: ''; + border: solid currentcolor; + border-width: 0 1.5px 1.5px 0; + transform: translateY(-0.5px) rotate(45deg) scale(0); +} + +.compact-checkbox:checked { + border-color: var(--color-accent); + background: var(--color-accent); +} + +.compact-checkbox:checked::before { + transform: translateY(-0.5px) rotate(45deg) scale(1); +} + +.compact-checkbox:disabled { + cursor: default; + opacity: 0.5; } .collection-actions .collection-search { diff --git a/apps/axkdeck/src/features/catalog/hooks.ts b/apps/axkdeck/src/features/catalog/hooks.ts new file mode 100644 index 00000000..7180d5ac --- /dev/null +++ b/apps/axkdeck/src/features/catalog/hooks.ts @@ -0,0 +1,13 @@ +export interface CatalogHooks { + stopPlayback: () => Promise; + resetPreviews: () => void; + resetCleanup: () => void; +} + +export function createCatalogHooks(): CatalogHooks { + return { + stopPlayback: () => Promise.resolve(), + resetPreviews: () => {}, + resetCleanup: () => {}, + }; +} diff --git a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte index 7c09b21f..e92877f9 100644 --- a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte +++ b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte @@ -255,8 +255,14 @@ {/if} {#if packageImport.request && pickerRequest?.parentDialog !== 'package-import'} void packageImport.chooseWorkspace()} onchooselocal={() => void packageImport.chooseLocal()} onchange={() => void packageImport.resetSource()} + ondestinationmode={(mode) => packageImport.setDestinationMode(mode)} + ondestinationvolume={(partitionIndex, volumeName) => + packageImport.setExistingVolume(partitionIndex, volumeName)} + ondestinationpartition={(partitionIndex) => packageImport.setDestinationPartition(partitionIndex)} + ondestinationname={(name) => packageImport.setDestinationVolumeName(name)} onrename={(nodeId, name) => packageImport.rename(nodeId, name)} onprogramslot={(nodeId, slot) => packageImport.programSlot(nodeId, slot)} onprogramstart={(placementId, start) => packageImport.programStart(placementId, start)} @@ -472,7 +483,7 @@ onclose={() => mediaDrop.closeNotice()} /> {/if} -{#if mediaDrop.dragActive && !audioImport.request && !sequenceImport.request && !tx16wImport.request} +{#if mediaDrop.dragActive && !audioImport.request && !sequenceImport.request && !tx16wImport.request && !packageImport.request} diff --git a/apps/axkdeck/src/lib/components/MidiExportDialog.test.ts b/apps/axkdeck/src/lib/components/MidiExportDialog.test.ts index f8fa0a41..904bb5cc 100644 --- a/apps/axkdeck/src/lib/components/MidiExportDialog.test.ts +++ b/apps/axkdeck/src/lib/components/MidiExportDialog.test.ts @@ -20,8 +20,6 @@ describe('MidiExportDialog', () => { }, ], desktop: true, - busy: false, - progressLabel: '', error: '', onworkspace, onlocal, @@ -37,7 +35,7 @@ describe('MidiExportDialog', () => { expect(onlocal).toHaveBeenCalledOnce(); }); - it('summarizes a multi-Sequence export and permits cancellation while busy', async () => { + it('summarizes a multi-Sequence export and permits closing the destination chooser', async () => { const oncancel = vi.fn(); render(MidiExportDialog, { props: { @@ -62,8 +60,6 @@ describe('MidiExportDialog', () => { }, ], desktop: false, - busy: true, - progressLabel: 'Writing MIDI files', error: '', onworkspace: vi.fn(), onlocal: vi.fn(), @@ -72,9 +68,8 @@ describe('MidiExportDialog', () => { }); expect(screen.getByText('Export 2 Sequences')).toBeTruthy(); - expect(screen.getByRole('status').textContent).toContain('Writing MIDI files'); expect(screen.queryByRole('button', { name: /This computer/ })).toBeNull(); - await fireEvent.click(screen.getByRole('button', { name: 'Cancel export' })); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(oncancel).toHaveBeenCalledOnce(); }); }); diff --git a/apps/axkdeck/src/lib/components/PackageExportDialog.svelte b/apps/axkdeck/src/lib/components/PackageExportDialog.svelte index a1962105..aa2740c7 100644 --- a/apps/axkdeck/src/lib/components/PackageExportDialog.svelte +++ b/apps/axkdeck/src/lib/components/PackageExportDialog.svelte @@ -7,15 +7,13 @@ interface Props { items: PackageExportSelection[]; desktop: boolean; - busy: boolean; - progressLabel: string; error: string; onworkspace: () => void; onlocal: () => void; oncancel: () => void; } - let { items, desktop, busy, progressLabel, error, onworkspace, onlocal, oncancel }: Props = $props(); + let { items, desktop, error, onworkspace, onlocal, oncancel }: Props = $props(); const singleItem = $derived(items.length === 1 ? items[0] : undefined); const typeSummary = $derived.by(() => { const labels = ['Program', 'Sequence', 'Sample Bank', 'Sample', 'Wave Data'] as const; @@ -52,15 +50,14 @@ role="dialog" aria-modal="true" aria-label="Export axklib package" - aria-busy={busy} - use:modal={{ onescape: busy ? undefined : oncancel }} + use:modal={{ onescape: oncancel }} >

Export package

- +
@@ -84,14 +81,13 @@ {/each}
{/if} - - {#if busy}

{progressLabel || 'Exporting package…'}

{/if} + {#if error}{/if}
- +
diff --git a/apps/axkdeck/src/lib/components/PackageExportDialog.test.ts b/apps/axkdeck/src/lib/components/PackageExportDialog.test.ts index c40202de..0d2ec9c1 100644 --- a/apps/axkdeck/src/lib/components/PackageExportDialog.test.ts +++ b/apps/axkdeck/src/lib/components/PackageExportDialog.test.ts @@ -19,8 +19,6 @@ describe('PackageExportDialog', () => { }, ], desktop: true, - busy: false, - progressLabel: '', error: '', onworkspace, onlocal, @@ -35,7 +33,7 @@ describe('PackageExportDialog', () => { expect(onlocal).toHaveBeenCalledOnce(); }); - it('hides local save in browser mode and disables actions while exporting', () => { + it('hides local save in browser mode', () => { render(PackageExportDialog, { props: { items: [ @@ -50,8 +48,6 @@ describe('PackageExportDialog', () => { }, ], desktop: false, - busy: true, - progressLabel: 'Writing package', error: '', onworkspace: vi.fn(), onlocal: vi.fn(), @@ -60,9 +56,8 @@ describe('PackageExportDialog', () => { }); expect(screen.queryByRole('button', { name: /This computer/ })).toBeNull(); - expect((screen.getByRole('button', { name: /Storage location/ }) as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByRole('status').textContent).toContain('Writing package'); + expect((screen.getByRole('button', { name: /Storage location/ }) as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(false); }); it('summarizes every root in a multi-object package', () => { @@ -89,8 +84,6 @@ describe('PackageExportDialog', () => { }, ], desktop: false, - busy: false, - progressLabel: '', error: '', onworkspace: vi.fn(), onlocal: vi.fn(), @@ -130,8 +123,6 @@ describe('PackageExportDialog', () => { }, ], desktop: false, - busy: false, - progressLabel: '', error: '', onworkspace: vi.fn(), onlocal: vi.fn(), @@ -168,8 +159,6 @@ describe('PackageExportDialog', () => { }, ], desktop: false, - busy: false, - progressLabel: '', error: '', onworkspace: vi.fn(), onlocal: vi.fn(), @@ -198,8 +187,6 @@ describe('PackageExportDialog', () => { }, ], desktop: false, - busy: false, - progressLabel: '', error, onworkspace: vi.fn(), onlocal: vi.fn(), diff --git a/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts b/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts index b658374d..94212948 100644 --- a/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts +++ b/apps/axkdeck/src/lib/components/ServerStoragePicker.test.ts @@ -14,6 +14,8 @@ const appStyles = readFileSync(resolve(process.cwd(), 'src/app.css'), 'utf8'); function transport(withRoots = true): ImageTransport { return { storageMode: 'server', + connectionMode: 'remote', + supportsClientUploads: false, sandboxRoots: vi.fn().mockResolvedValue( withRoots ? [ diff --git a/apps/axkdeck/src/lib/components/SfzExportDialog.svelte b/apps/axkdeck/src/lib/components/SfzExportDialog.svelte index f76cb84b..000a33c9 100644 --- a/apps/axkdeck/src/lib/components/SfzExportDialog.svelte +++ b/apps/axkdeck/src/lib/components/SfzExportDialog.svelte @@ -10,8 +10,6 @@ inspection: ImageSessionAudioExportInspection | null; desktop: boolean; loading: boolean; - busy: boolean; - progressLabel: string; error: string; format: 'SFZ' | 'WAV'; onformatchange: (format: 'SFZ' | 'WAV') => void; @@ -26,20 +24,8 @@ fatal: boolean; } - let { - items, - inspection, - desktop, - loading, - busy, - progressLabel, - error, - format, - onformatchange, - onworkspace, - onlocal, - oncancel, - }: Props = $props(); + let { items, inspection, desktop, loading, error, format, onformatchange, onworkspace, onlocal, oncancel }: Props = + $props(); const singleItem = $derived(items.length === 1 ? items[0] : undefined); const counts = $derived.by(() => { @@ -93,15 +79,15 @@ role="dialog" aria-modal="true" aria-label="Export SFZ" - aria-busy={loading || busy} - use:modal={{ onescape: busy ? undefined : oncancel }} + aria-busy={loading} + use:modal={{ onescape: oncancel }} >

Export SFZ

- +
@@ -117,14 +103,13 @@ @@ -144,17 +129,14 @@ {#each displayIssues as issue (issue.key)}

{issue.message}

{/each} - + {/if} - {#if busy}

{progressLabel || 'Exporting audio…'}

{/if} {#if error}{/if}
- +
diff --git a/apps/axkdeck/src/lib/components/SfzExportDialog.test.ts b/apps/axkdeck/src/lib/components/SfzExportDialog.test.ts index 3e3246d9..2c8a6eef 100644 --- a/apps/axkdeck/src/lib/components/SfzExportDialog.test.ts +++ b/apps/axkdeck/src/lib/components/SfzExportDialog.test.ts @@ -34,8 +34,6 @@ describe('SfzExportDialog', () => { }, desktop: true, loading: false, - busy: false, - progressLabel: '', error: '', format: 'SFZ', onformatchange: vi.fn(), @@ -79,8 +77,6 @@ describe('SfzExportDialog', () => { }, desktop: false, loading: false, - busy: false, - progressLabel: '', error: '', format: 'WAV', onformatchange, @@ -136,8 +132,6 @@ describe('SfzExportDialog', () => { }, desktop: true, loading: false, - busy: false, - progressLabel: '', error: '', format: 'SFZ', onformatchange: vi.fn(), diff --git a/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.svelte b/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.svelte index 4db660c4..b84e9444 100644 --- a/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.svelte +++ b/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.svelte @@ -9,17 +9,14 @@ inspection: ImageSessionVolumeFloppyExportInspection | null; desktop: boolean; loading: boolean; - busy: boolean; - progressLabel: string; error: string; onworkspace: () => void; onlocal: () => void; oncancel: () => void; } - let { scopeName, inspection, desktop, loading, busy, progressLabel, error, onworkspace, onlocal, oncancel }: Props = - $props(); - const unavailable = $derived(loading || busy || !inspection || inspection.exportableCount === 0); + let { scopeName, inspection, desktop, loading, error, onworkspace, onlocal, oncancel }: Props = $props(); + const unavailable = $derived(loading || !inspection || inspection.exportableCount === 0); function outcome(volume: NonNullable['volumes'][number]): string { if (volume.state === 'EMPTY') return 'Empty'; @@ -34,15 +31,15 @@ role="dialog" aria-modal="true" aria-label="Export volumes to floppies" - aria-busy={loading || busy} - use:modal={{ onescape: busy ? undefined : oncancel }} + aria-busy={loading} + use:modal={{ onescape: oncancel }} >

Export volumes to floppies

- +
@@ -74,10 +71,9 @@
{/if} {#if !unavailable} - + {/if} {#if loading}

Inspecting volumes…

{/if} - {#if busy}

{progressLabel || 'Exporting floppy sets…'}

{/if} {#if inspection?.exportableCount === 0} {/if} @@ -86,9 +82,7 @@
- +
diff --git a/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.test.ts b/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.test.ts index 54e1c787..fc97ab53 100644 --- a/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.test.ts +++ b/apps/axkdeck/src/lib/components/VolumeFloppyExportDialog.test.ts @@ -78,8 +78,6 @@ describe('VolumeFloppyExportDialog', () => { inspection, desktop: true, loading: false, - busy: false, - progressLabel: '', error: '', onworkspace, onlocal, @@ -106,8 +104,6 @@ describe('VolumeFloppyExportDialog', () => { inspection: { ...inspection, exportableCount: 0, totalFloppyImageCount: 0 }, desktop: true, loading: false, - busy: false, - progressLabel: '', error: '', onworkspace: vi.fn(), onlocal: vi.fn(), diff --git a/apps/axkdeck/src/lib/components/VolumePackageExportDialog.svelte b/apps/axkdeck/src/lib/components/VolumePackageExportDialog.svelte index ecf1abba..0df47b08 100644 --- a/apps/axkdeck/src/lib/components/VolumePackageExportDialog.svelte +++ b/apps/axkdeck/src/lib/components/VolumePackageExportDialog.svelte @@ -9,17 +9,14 @@ inspection: ImageSessionVolumePackageExportInspection | null; desktop: boolean; loading: boolean; - busy: boolean; - progressLabel: string; error: string; onworkspace: () => void; onlocal: () => void; oncancel: () => void; } - let { scopeName, inspection, desktop, loading, busy, progressLabel, error, onworkspace, onlocal, oncancel }: Props = - $props(); - const unavailable = $derived(loading || busy || !inspection || inspection.exportableCount === 0); + let { scopeName, inspection, desktop, loading, error, onworkspace, onlocal, oncancel }: Props = $props(); + const unavailable = $derived(loading || !inspection || inspection.exportableCount === 0);
- +
diff --git a/apps/axkdeck/src/lib/components/VolumePackageExportDialog.test.ts b/apps/axkdeck/src/lib/components/VolumePackageExportDialog.test.ts index f6059abf..37b71b6b 100644 --- a/apps/axkdeck/src/lib/components/VolumePackageExportDialog.test.ts +++ b/apps/axkdeck/src/lib/components/VolumePackageExportDialog.test.ts @@ -46,8 +46,6 @@ describe('VolumePackageExportDialog', () => { inspection, desktop: true, loading: false, - busy: false, - progressLabel: '', error: '', onworkspace, onlocal, @@ -71,8 +69,6 @@ describe('VolumePackageExportDialog', () => { inspection: { ...inspection, exportableCount: 0, emptyCount: 2 }, desktop: true, loading: false, - busy: false, - progressLabel: '', error: '', onworkspace: vi.fn(), onlocal: vi.fn(), diff --git a/apps/axkdeck/src/lib/createTransport.test.ts b/apps/axkdeck/src/lib/createTransport.test.ts index a5da43ec..afaef73a 100644 --- a/apps/axkdeck/src/lib/createTransport.test.ts +++ b/apps/axkdeck/src/lib/createTransport.test.ts @@ -16,6 +16,19 @@ describe('createTransport', () => { const transport = createTransport(); expect(transport.storageMode).toBe('unavailable'); + expect(transport.connectionMode).toBe('unavailable'); await expect(transport.sandboxRoots()).rejects.toThrow('axklib-server'); }); + + it.each(['local', 'remote'] as const)('preserves the explicit %s connection mode', (mode) => { + window.__AXKLIB_SERVER__ = { + baseUrl: 'http://127.0.0.1:42101', + bearerToken: 'token', + mode, + }; + + const transport = createTransport(); + + expect(transport.connectionMode).toBe(mode); + }); }); diff --git a/apps/axkdeck/src/lib/createTransport.ts b/apps/axkdeck/src/lib/createTransport.ts index dec17159..5db0105d 100644 --- a/apps/axkdeck/src/lib/createTransport.ts +++ b/apps/axkdeck/src/lib/createTransport.ts @@ -35,6 +35,7 @@ import type { DirectoryListing, DirectoryRef, FileRef, SandboxRoot } from './sto class UnavailableTransport implements ImageTransport { readonly storageMode = 'unavailable' as const; + readonly connectionMode = 'unavailable' as const; readonly supportsClientUploads = false; private unavailable(): Promise { return Promise.reject(new Error('axklib-server is unavailable; configure or restart the server connection')); diff --git a/apps/axkdeck/src/lib/fileOperationRouting.test.ts b/apps/axkdeck/src/lib/fileOperationRouting.test.ts new file mode 100644 index 00000000..0a21bfc4 --- /dev/null +++ b/apps/axkdeck/src/lib/fileOperationRouting.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldUseDirectComputerFileOperations } from './fileOperationRouting'; + +describe('shouldUseDirectComputerFileOperations', () => { + it('uses native file dialogs for the managed local desktop sidecar', () => { + expect(shouldUseDirectComputerFileOperations(true, 'local')).toBe(true); + }); + + it('retains the storage chooser for configured remote connections', () => { + expect(shouldUseDirectComputerFileOperations(true, 'remote')).toBe(false); + }); + + it('does not infer locality from a configured loopback URL', () => { + expect(shouldUseDirectComputerFileOperations(true, 'remote')).toBe(false); + }); + + it('does not use native file dialogs outside the desktop shell', () => { + expect(shouldUseDirectComputerFileOperations(false, 'local')).toBe(false); + }); +}); diff --git a/apps/axkdeck/src/lib/fileOperationRouting.ts b/apps/axkdeck/src/lib/fileOperationRouting.ts new file mode 100644 index 00000000..1a084cc7 --- /dev/null +++ b/apps/axkdeck/src/lib/fileOperationRouting.ts @@ -0,0 +1,5 @@ +import type { ConnectionMode } from './transport'; + +export function shouldUseDirectComputerFileOperations(isDesktop: boolean, connectionMode: ConnectionMode): boolean { + return isDesktop && connectionMode === 'local'; +} diff --git a/apps/axkdeck/src/lib/httpTransport.test.ts b/apps/axkdeck/src/lib/httpTransport.test.ts index 40c39a14..20717600 100644 --- a/apps/axkdeck/src/lib/httpTransport.test.ts +++ b/apps/axkdeck/src/lib/httpTransport.test.ts @@ -114,7 +114,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); await expect(transport.audioImportCapabilities()).resolves.toEqual({ supportedSampleRates: [44_100], @@ -252,6 +256,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://127.0.0.1:4000/api/v1', bearerToken: 'secret', + mode: 'remote', }); const opened = await transport.openImage(serverFile('images/test.hds')); expect(opened).toMatchObject({ sessionId: 1, objectTotalCount: 0, initialVolume: { id: 'volume-1' } }); @@ -331,7 +336,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const opened = await transport.openImage( axkObjectDirectoryLocation({ rootId: 'workspace', relativePath: 'unpacked/volume' }), ); @@ -402,7 +411,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const opened = await transport.openImage( axkObjectDirectoryLocation({ rootId: 'workspace', relativePath: 'set/DISK2' }), ); @@ -476,6 +489,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://127.0.0.1:4000/api/v1', bearerToken: 'secret', + mode: 'remote', }); const audio = await transport.readAuditionContent('audition-1', 10); @@ -526,6 +540,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://127.0.0.1:4000/api/v1', bearerToken: 'secret', + mode: 'remote', }); const result = await transport.downloadFile(serverFile('exports/tone.wav')); @@ -562,6 +577,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://127.0.0.1:4000/api/v1', bearerToken: 'secret', + mode: 'remote', }); await expect(transport.readAuditionContent('audition-1', 4)).rejects.toThrow('returned 3 bytes; expected 4'); @@ -600,6 +616,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://127.0.0.1:4000/api/v1', bearerToken: 'secret', + mode: 'remote', }); const request = transport.readAuditionContent('audition-1', 4, controller.signal); await requestStarted; @@ -722,6 +739,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret', + mode: 'remote', }); const manifest = serverFile('authoring/build.json'); const output = serverFile('images/new.hds'); @@ -870,7 +888,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const profiles = await transport.hardDiskCreationProfiles(); expect(profiles[0]).toMatchObject({ profileId: 'CD_R_700', defaultPartitionCount: 1 }); const plan = await transport.planHardDiskCreation('CD_R_700', 1, serverFile('images/new.hds')); @@ -978,6 +1000,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret', + mode: 'remote', }); const source = serverFile('images/base.hds'); const opened = await transport.openImage(source); @@ -1324,7 +1347,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const opened = await transport.openImage(serverFile('images/base.hds')); const deletion = await transport.inspectVolumeDeletion(opened.sessionId, 0, 'Samples'); expect(deletion).toMatchObject({ canDelete: false, crossingRelationshipCount: 1 }); @@ -1475,6 +1502,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret', + mode: 'remote', }); const opened = await transport.openImage(serverFile('images/base.hds')); expect(opened.objectDeletionAvailable).toBe(true); @@ -1574,6 +1602,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret', + mode: 'remote', }); const opened = await transport.openImage(serverFile('images/base.hds')); expect(opened.waveDataCleanupAvailable).toBe(true); @@ -1692,6 +1721,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret', + mode: 'remote', }); const opened = await transport.openImage(serverFile('images/base.hds')); expect(opened.programGenerationAvailable).toBe(true); @@ -1785,6 +1815,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret', + mode: 'remote', }); const mono = clientUploadLocation({ uploadId: 'upload-mono' }, 'AUDIO', 'mono.wav'); const stereo = serverFile('audio/stereo.flac'); @@ -2000,6 +2031,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret', + mode: 'remote', }); const uploaded = clientUploadLocation({ uploadId: 'upload-midi' }, 'MIDI', 'Intro.mid'); const workspace = serverFile('midi/Ending.midi'); @@ -2057,7 +2089,11 @@ describe('HttpImageTransport', () => { }); it('rejects desktop paths instead of relabeling them as sandbox paths', async () => { - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const desktopPath = { kind: 'native-file', path: 'C:\\Users\\example\\disk.hds' } as unknown as FileLocation; await expect(transport.openImage(desktopPath)).rejects.toThrow('server sandbox file selection'); }); @@ -2107,7 +2143,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const manifest = clientUploadLocation({ uploadId: 'upload-manifest' }, 'MANIFEST', 'build.json'); const audio = clientUploadLocation({ uploadId: 'upload-audio' }, 'AUDIO', 'tone.wav'); await expect( @@ -2212,7 +2252,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const file = await transport.downloadFile(serverFile('exports/tone.wav')); expect(file.filename).toBe('tone.wav'); expect(await file.blob.text()).toBe('wave-bytes'); @@ -2344,7 +2388,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const uploaded = clientUploadLocation({ uploadId: 'upload-package' }, 'PACKAGE', 'volume.axkvol'); await expect(transport.inspectPackage(uploaded, true)).resolves.toMatchObject({ packageId: 'package-one', @@ -2657,7 +2705,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const opened = await transport.openImage(serverFile('images/base.hds')); expect(opened).toMatchObject({ packageImportAvailable: true, @@ -2965,6 +3017,7 @@ describe('HttpImageTransport', () => { const transport = new HttpImageTransport({ baseUrl: 'https://sampler.example.test/api/v1', bearerToken: 'remote-secret', + mode: 'remote', }); const opened = await transport.openImage(serverFile('images/base.hds')); const audio = clientUploadLocation({ uploadId: 'audio-upload' }, 'AUDIO', 'tone.wav'); @@ -3090,7 +3143,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const manifest = serverFile('authoring/build.json'); const output = serverFile('images/new.hds'); await transport.planCreate(manifest, output, false); @@ -3190,7 +3247,11 @@ describe('HttpImageTransport', () => { }), ); - const transport = new HttpImageTransport({ baseUrl: 'http://localhost/api/v1', bearerToken: 'secret' }); + const transport = new HttpImageTransport({ + baseUrl: 'http://localhost/api/v1', + bearerToken: 'secret', + mode: 'remote', + }); const manifest = serverFile('authoring/build.json'); const output = serverFile('images/new.hds'); await transport.planCreate(manifest, output, false); diff --git a/apps/axkdeck/src/lib/httpTransport.ts b/apps/axkdeck/src/lib/httpTransport.ts index ad698e56..cf3c44d9 100644 --- a/apps/axkdeck/src/lib/httpTransport.ts +++ b/apps/axkdeck/src/lib/httpTransport.ts @@ -60,6 +60,7 @@ import type { Tx16wImportMode, VolumeMutation, VolumeDeletionInspection, + ConnectionMode, } from './transport'; import { clientUploadLocation, @@ -92,9 +93,10 @@ import { serverInputBindings, volumeMutationOperation, } from './httpTransportWire'; -type HttpImageTransportConnection = AxklibApiConnection; +type HttpImageTransportConnection = AxklibApiConnection & { mode: Exclude }; export class HttpImageTransport extends HttpImageSessionReads implements ImageTransport { readonly storageMode = 'server' as const; + readonly connectionMode: Exclude; readonly supportsClientUploads = true; private readonly client: AxklibHttpApiClient; private readonly jobs: HttpJobController; @@ -111,6 +113,7 @@ export class HttpImageTransport extends HttpImageSessionReads implements ImageTr this.jobs = jobs; this.imports = new HttpImportOperations(this.client, this.jobs, this.imageSessions); this.packages = new HttpPackageOperations(this.client, this.jobs, this.imageSessions); + this.connectionMode = connection.mode; } sandboxRoots(): Promise { return this.client.roots(); diff --git a/apps/axkdeck/src/lib/testing/inMemoryTransport.ts b/apps/axkdeck/src/lib/testing/inMemoryTransport.ts index 3af127bb..092f1ef7 100644 --- a/apps/axkdeck/src/lib/testing/inMemoryTransport.ts +++ b/apps/axkdeck/src/lib/testing/inMemoryTransport.ts @@ -80,6 +80,7 @@ import type { ClientUploadSource } from '../clientUploadSource'; export interface InMemoryImageTransportOptions { storageMode?: ImageTransport['storageMode']; + connectionMode?: ImageTransport['connectionMode']; supportsClientUploads?: boolean; opened: Omit< OpenedImage, @@ -129,12 +130,14 @@ export interface InMemoryImageTransportOptions { export class InMemoryImageTransport implements ImageTransport { readonly storageMode: ImageTransport['storageMode']; + readonly connectionMode: ImageTransport['connectionMode']; readonly supportsClientUploads: boolean; readonly calls: string[] = []; private nextSessionId = 1; constructor(private readonly options: InMemoryImageTransportOptions) { this.storageMode = options.storageMode ?? 'server'; + this.connectionMode = options.connectionMode ?? 'remote'; this.supportsClientUploads = options.supportsClientUploads ?? false; } diff --git a/apps/axkdeck/src/lib/transport.ts b/apps/axkdeck/src/lib/transport.ts index 9f743cd9..bd2db196 100644 --- a/apps/axkdeck/src/lib/transport.ts +++ b/apps/axkdeck/src/lib/transport.ts @@ -462,8 +462,11 @@ export interface SequenceImportTarget { volumeName: string; } +export type ConnectionMode = 'local' | 'remote' | 'unavailable'; + export interface ImageTransport { readonly storageMode: 'server' | 'unavailable'; + readonly connectionMode: ConnectionMode; readonly supportsClientUploads: boolean; sandboxRoots(): Promise; sandboxDirectory(directory: DirectoryRef, cursor?: string): Promise; diff --git a/apps/axkdeck/src/lib/transportConformance.test.ts b/apps/axkdeck/src/lib/transportConformance.test.ts index ab50a39b..a427a4c5 100644 --- a/apps/axkdeck/src/lib/transportConformance.test.ts +++ b/apps/axkdeck/src/lib/transportConformance.test.ts @@ -303,7 +303,7 @@ describe('ImageTransport shared read contract', () => { }), ); await exerciseReadContract( - new HttpImageTransport({ baseUrl: 'http://127.0.0.1:7300/api/v1', bearerToken: 'secret' }), + new HttpImageTransport({ baseUrl: 'http://127.0.0.1:7300/api/v1', bearerToken: 'secret', mode: 'remote' }), serverFileLocation({ rootId: 'workspace', relativePath: 'images/fixture.hds' }), ); }); From f4f71273f7be04e101a958cef1357a67fbe4f2b5 Mon Sep 17 00:00:00 2001 From: bzeiss Date: Thu, 20 Aug 2026 23:50:23 +0200 Subject: [PATCH 11/12] add self-report about webview version --- apps/axkdeck/src-tauri/Cargo.lock | 1 + apps/axkdeck/src-tauri/Cargo.toml | 1 + apps/axkdeck/src-tauri/src/lib.rs | 13 ++++- apps/axkdeck/src-tauri/src/webview_runtime.rs | 57 +++++++++++++++++++ apps/axkdeck/src/App.test.ts | 2 + .../src/lib/components/AboutDialog.svelte | 7 +++ .../src/lib/components/AboutDialog.test.ts | 28 +++++++++ apps/axkdeck/src/lib/desktopBuildInfo.ts | 2 + 8 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 apps/axkdeck/src-tauri/src/webview_runtime.rs diff --git a/apps/axkdeck/src-tauri/Cargo.lock b/apps/axkdeck/src-tauri/Cargo.lock index 53965a38..8d640afe 100644 --- a/apps/axkdeck/src-tauri/Cargo.lock +++ b/apps/axkdeck/src-tauri/Cargo.lock @@ -245,6 +245,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-log", "url", + "webview2-com", "windows-sys 0.61.2", ] diff --git a/apps/axkdeck/src-tauri/Cargo.toml b/apps/axkdeck/src-tauri/Cargo.toml index 98773648..a31ac138 100644 --- a/apps/axkdeck/src-tauri/Cargo.toml +++ b/apps/axkdeck/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ url = "2.5.7" rustix = { version = "1", features = ["fs", "process"] } [target.'cfg(windows)'.dependencies] +webview2-com = "0.38.2" windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", diff --git a/apps/axkdeck/src-tauri/src/lib.rs b/apps/axkdeck/src-tauri/src/lib.rs index 5f80007e..a509ba5a 100644 --- a/apps/axkdeck/src-tauri/src/lib.rs +++ b/apps/axkdeck/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod remote_settings; mod retained_download; mod server_sidecar; mod startup_diagnostics; +mod webview_runtime; use std::sync::Mutex; @@ -42,6 +43,8 @@ struct DesktopBuildInfo { source_identity: &'static str, release_tag: &'static str, is_release: bool, + webview_engine: &'static str, + webview_version: Option, } fn current_build_info() -> DesktopBuildInfo { @@ -52,6 +55,8 @@ fn current_build_info() -> DesktopBuildInfo { source_identity: env!("AXKDECK_SOURCE_IDENTITY"), release_tag: env!("AXKDECK_RELEASE_TAG"), is_release: env!("AXKDECK_IS_RELEASE") == "true", + webview_engine: webview_runtime::ENGINE, + webview_version: None, } } @@ -124,6 +129,8 @@ mod tests { assert!(!build.project_version.is_empty()); assert!(!build.source_identity.is_empty()); assert_eq!(build.is_release, !build.release_tag.is_empty()); + assert!(!build.webview_engine.is_empty()); + assert!(build.webview_version.is_none()); } #[test] @@ -318,8 +325,10 @@ fn diagnostic_log_level() -> &'static str { } #[tauri::command] -fn desktop_build_info() -> DesktopBuildInfo { - current_build_info() +async fn desktop_build_info(window: WebviewWindow) -> DesktopBuildInfo { + let mut build = current_build_info(); + build.webview_version = webview_runtime::version(&window).await; + build } #[tauri::command] diff --git a/apps/axkdeck/src-tauri/src/webview_runtime.rs b/apps/axkdeck/src-tauri/src/webview_runtime.rs new file mode 100644 index 00000000..55093e96 --- /dev/null +++ b/apps/axkdeck/src-tauri/src/webview_runtime.rs @@ -0,0 +1,57 @@ +use tauri::WebviewWindow; + +#[cfg(windows)] +pub const ENGINE: &str = "Microsoft Edge WebView2"; + +#[cfg(target_os = "macos")] +pub const ENGINE: &str = "Apple WebKit"; + +#[cfg(not(any(windows, target_os = "macos")))] +pub const ENGINE: &str = "WebKitGTK"; + +#[cfg(windows)] +pub async fn version(window: &WebviewWindow) -> Option { + let (sender, mut receiver) = tauri::async_runtime::channel(1); + if let Err(error) = window.with_webview(move |webview| { + let environment = webview.environment(); + let mut raw_version = Default::default(); + let result = unsafe { environment.BrowserVersionString(&mut raw_version) } + .map(|()| webview2_com::take_pwstr(raw_version)) + .map_err(|error| error.to_string()); + let _ = sender.try_send(result); + }) { + log::warn!("query active WebView2 environment: {error}"); + return None; + } + + match receiver.recv().await { + Some(Ok(version)) if !version.trim().is_empty() => Some(version), + Some(Ok(_)) => { + log::warn!("active WebView2 environment returned an empty version"); + None + } + Some(Err(error)) => { + log::warn!("query active WebView2 version: {error}"); + None + } + None => { + log::warn!("active WebView2 version query did not return a result"); + None + } + } +} + +#[cfg(not(windows))] +pub async fn version(_window: &WebviewWindow) -> Option { + match tauri::webview_version() { + Ok(version) if !version.trim().is_empty() => Some(version), + Ok(_) => { + log::warn!("active web view engine returned an empty version"); + None + } + Err(error) => { + log::warn!("query active web view version: {error}"); + None + } + } +} diff --git a/apps/axkdeck/src/App.test.ts b/apps/axkdeck/src/App.test.ts index e58a380a..0db30d70 100644 --- a/apps/axkdeck/src/App.test.ts +++ b/apps/axkdeck/src/App.test.ts @@ -210,6 +210,8 @@ describe('App panel layout', () => { sourceIdentity: 'v0.4.0-1234567', releaseTag: 'v0.4.0', isRelease: true, + webviewEngine: 'WebKitGTK', + webviewVersion: '2.50.4', }); }); diff --git a/apps/axkdeck/src/lib/components/AboutDialog.svelte b/apps/axkdeck/src/lib/components/AboutDialog.svelte index 82478b70..0361d159 100644 --- a/apps/axkdeck/src/lib/components/AboutDialog.svelte +++ b/apps/axkdeck/src/lib/components/AboutDialog.svelte @@ -46,6 +46,13 @@
Build
{state.buildInfo.sourceIdentity}
+
+
Web view
+
+ {state.buildInfo.webviewEngine} + {state.buildInfo.webviewVersion ?? 'unavailable'} +
+
{/if} diff --git a/apps/axkdeck/src/lib/components/AboutDialog.test.ts b/apps/axkdeck/src/lib/components/AboutDialog.test.ts index ce71c125..d2ce29a0 100644 --- a/apps/axkdeck/src/lib/components/AboutDialog.test.ts +++ b/apps/axkdeck/src/lib/components/AboutDialog.test.ts @@ -30,6 +30,8 @@ describe('AboutDialog', () => { sourceIdentity: 'v0.4.0-1234567-mod', releaseTag: '', isRelease: false, + webviewEngine: 'Microsoft Edge WebView2', + webviewVersion: '120.0.2210.144', }, }, onclose, @@ -39,11 +41,37 @@ describe('AboutDialog', () => { const dialog = screen.getByRole('dialog', { name: 'About axkdeck' }); expect(within(dialog).getByText('0.4.0')).toBeTruthy(); expect(within(dialog).getByText('v0.4.0-1234567-mod')).toBeTruthy(); + expect(within(dialog).getByText('Microsoft Edge WebView2 120.0.2210.144')).toBeTruthy(); await fireEvent.keyDown(dialog, { key: 'Escape' }); expect(onclose).toHaveBeenCalledOnce(); }); + it('keeps build information available when webview introspection fails', () => { + render(AboutDialog, { + props: { + state: { + status: 'ready', + buildInfo: { + schemaVersion: 1, + semanticVersion: '0.4.0', + projectVersion: '0.4.0', + sourceIdentity: 'v0.4.0-1234567-mod', + releaseTag: '', + isRelease: false, + webviewEngine: 'WebKitGTK', + webviewVersion: null, + }, + }, + onclose: vi.fn(), + }, + }); + + const dialog = screen.getByRole('dialog', { name: 'About axkdeck' }); + expect(within(dialog).getByText('WebKitGTK unavailable')).toBeTruthy(); + expect(within(dialog).getByText('v0.4.0-1234567-mod')).toBeTruthy(); + }); + it('shows a stable fallback when build information cannot be loaded', () => { render(AboutDialog, { props: { diff --git a/apps/axkdeck/src/lib/desktopBuildInfo.ts b/apps/axkdeck/src/lib/desktopBuildInfo.ts index 4ac91332..690aca16 100644 --- a/apps/axkdeck/src/lib/desktopBuildInfo.ts +++ b/apps/axkdeck/src/lib/desktopBuildInfo.ts @@ -7,6 +7,8 @@ export interface DesktopBuildInfo { sourceIdentity: string; releaseTag: string; isRelease: boolean; + webviewEngine: string; + webviewVersion: string | null; } export type DesktopBuildInfoState = From 78acdf89787fac7a11fbec98060a2ca3ae755fb0 Mon Sep 17 00:00:00 2001 From: bzeiss Date: Fri, 21 Aug 2026 18:18:25 +0200 Subject: [PATCH 12/12] batch import of packages + a3k --- .../src/session_package_import_plan.cpp | 12 +- .../tests/package_operations_test.cpp | 39 ++ apps/axkdeck/src-tauri/src/lib.rs | 4 +- apps/axkdeck/src-tauri/src/local_packages.rs | 23 +- apps/axkdeck/src/App.svelte | 4 +- apps/axkdeck/src/App.test.ts | 6 +- .../src/features/dialogs/AppDialogs.svelte | 22 +- .../import/mediaDropWorkflow.svelte.ts | 22 +- .../features/import/packageBatchPlanning.ts | 192 +++++++ .../features/import/packageBatchSources.ts | 78 +++ .../src/features/import/packageBatchTypes.ts | 59 ++ .../import/packageBatchWorkflow.svelte.ts | 543 ++++++++++++------ .../import/packageBatchWorkflow.test.ts | 133 ++++- .../PackageBatchConflictControls.svelte | 247 ++++++++ .../PackageBatchDestinationChooser.svelte | 163 ++++++ .../PackageBatchImportDialog.svelte | 445 +++++--------- .../PackageBatchImportDialog.test.ts | 74 ++- .../components/PackageBatchItemsTable.svelte | 306 ++++++++++ apps/axkdeck/src/lib/generated/axklibApiV1.ts | 2 +- apps/axkdeck/src/lib/nativePackages.test.ts | 12 +- apps/axkdeck/src/lib/nativePackages.ts | 4 +- apps/server/contracts/openapi-v1.base.json | 2 +- apps/server/contracts/openapi-v1.json | 2 +- 23 files changed, 1849 insertions(+), 545 deletions(-) create mode 100644 apps/axkdeck/src/features/import/packageBatchPlanning.ts create mode 100644 apps/axkdeck/src/features/import/packageBatchSources.ts create mode 100644 apps/axkdeck/src/features/import/packageBatchTypes.ts create mode 100644 apps/axkdeck/src/lib/components/PackageBatchConflictControls.svelte create mode 100644 apps/axkdeck/src/lib/components/PackageBatchDestinationChooser.svelte create mode 100644 apps/axkdeck/src/lib/components/PackageBatchItemsTable.svelte diff --git a/apps/application/src/session_package_import_plan.cpp b/apps/application/src/session_package_import_plan.cpp index 4395591a..74e513e4 100644 --- a/apps/application/src/session_package_import_plan.cpp +++ b/apps/application/src/session_package_import_plan.cpp @@ -108,9 +108,6 @@ prepare_session_import(const Json &input, std::span } } } else if (kind == "CREATE_VOLUME") { - if (packages.size() != 1U) - return std::unexpected( - operation_error("invalid_request", "CREATE_VOLUME requires exactly one import source")); const auto volume_name = destination.at("volumeName").get(); if (!valid_volume_name(volume_name)) return std::unexpected(operation_error("invalid_request", "destination volume name is invalid")); @@ -120,9 +117,12 @@ prepare_session_import(const Json &input, std::span return std::unexpected( operation_error("package_destination_conflict", "destination volume name already exists")); } - result.destination_volume_names.assign(1U, volume_name); - for (std::size_t root_index = 0U; root_index < packages.front().roots.size(); ++root_index) { - result.request.root_destinations.push_back({0U, root_index, *partition, {}, volume_name, {}, {}, true}); + result.destination_volume_names.assign(packages.size(), volume_name); + for (std::size_t package_index = 0U; package_index < packages.size(); ++package_index) { + for (std::size_t root_index = 0U; root_index < packages[package_index].roots.size(); ++root_index) { + result.request.root_destinations.push_back( + {package_index, root_index, *partition, {}, volume_name, {}, {}, true}); + } } } else if (kind == "CREATE_VOLUMES_FROM_HINTS") { std::map overrides; diff --git a/apps/application/tests/package_operations_test.cpp b/apps/application/tests/package_operations_test.cpp index 7c9a81ef..1c992017 100644 --- a/apps/application/tests/package_operations_test.cpp +++ b/apps/application/tests/package_operations_test.cpp @@ -597,6 +597,45 @@ TEST_F(PackageOperationsTest, SessionBatchImportCreatesUniquelyNamedVolumesAtomi EXPECT_FALSE(volume_content_id(*refreshed, "Percussion").empty()); } +TEST_F(PackageOperationsTest, SessionBatchImportCreatesOneSharedVolumeFromMultiplePackages) { + for (const auto filename : {"shared-one.axkvol", "shared-two.axkvol"}) { + const auto exported = + registry_.invoke("package.export", + {{"source", {{"rootId", "workspace"}, {"relativePath", "mixed-roots.hds"}}}, + {"output", {{"rootId", "workspace"}, {"relativePath", filename}}}, + {"roots", {{{"kind", "volume"}, {"partitionIndex", 0U}, {"volumeName", "Mixed"}}}}}, + context()); + ASSERT_TRUE(exported) << exported.error().message; + } + + const auto target = images_->open({"workspace", "target.hds"}, "owner"); + ASSERT_TRUE(target) << target.error().message; + const auto request = nlohmann::json{ + {"imageId", target->image_id}, + {"expectedRevision", target->revision}, + {"packages", + {{{"fileRef", {{"rootId", "workspace"}, {"relativePath", "shared-one.axkvol"}}}}, + {{"fileRef", {{"rootId", "workspace"}, {"relativePath", "shared-two.axkvol"}}}}}}, + {"destination", {{"kind", "CREATE_VOLUME"}, {"partitionIndex", 0U}, {"volumeName", "Shared"}}}, + {"renames", nlohmann::json::array()}, + {"programSlotAssignments", nlohmann::json::array()}, + {"opaqueSequenceDecisions", nlohmann::json::array()}, + }; + const auto planned = registry_.invoke("images.package_import.plan", request, context()); + ASSERT_TRUE(planned) << planned.error().message; + ASSERT_TRUE(planned->at("valid").get()); + ASSERT_EQ(planned->at("packages").size(), 2U); + EXPECT_EQ(planned->at("packages").at(0).at("destinationVolumeName"), "Shared"); + EXPECT_EQ(planned->at("packages").at(1).at("destinationVolumeName"), "Shared"); + + const auto applied = registry_.invoke("images.package_import", + {{"planToken", planned->at("planToken").get()}}, context()); + ASSERT_TRUE(applied) << applied.error().message; + const auto refreshed = images_->inspect(target->image_id, "owner"); + ASSERT_TRUE(refreshed) << refreshed.error().message; + EXPECT_FALSE(volume_content_id(*refreshed, "Shared").empty()); +} + TEST_F(PackageOperationsTest, SessionExportsExactSingleAndMultiRootPackagesToWorkspaceOrRetainedDownload) { const auto opened = images_->open({"workspace", "fixture.hds"}, "owner"); ASSERT_TRUE(opened) << opened.error().message; diff --git a/apps/axkdeck/src-tauri/src/lib.rs b/apps/axkdeck/src-tauri/src/lib.rs index a509ba5a..f4c330ef 100644 --- a/apps/axkdeck/src-tauri/src/lib.rs +++ b/apps/axkdeck/src-tauri/src/lib.rs @@ -26,7 +26,7 @@ use local_directory_exports::{ use local_packages::{ PackageSaveCandidateStore, save_retained_media, save_retained_package, select_local_media_destination, select_local_package, select_local_package_destination, - select_local_volume_packages, + select_local_packages, }; use local_workspaces::{WorkspaceCandidateStore, commit_local_workspace, select_local_workspace}; use startup_diagnostics::{StartupDiagnostics, StartupMilestone, complete_startup}; @@ -490,7 +490,7 @@ pub fn run() { select_local_workspace, commit_local_workspace, select_local_package, - select_local_volume_packages, + select_local_packages, select_local_package_destination, save_retained_package, select_local_media_destination, diff --git a/apps/axkdeck/src-tauri/src/local_packages.rs b/apps/axkdeck/src-tauri/src/local_packages.rs index 39e005d1..c0517943 100644 --- a/apps/axkdeck/src-tauri/src/local_packages.rs +++ b/apps/axkdeck/src-tauri/src/local_packages.rs @@ -157,7 +157,7 @@ pub(crate) async fn select_local_package( } #[tauri::command] -pub(crate) async fn select_local_volume_packages( +pub(crate) async fn select_local_packages( app: AppHandle, window: WebviewWindow, preferred_path: Option, @@ -168,8 +168,11 @@ pub(crate) async fn select_local_volume_packages( let mut dialog = app .dialog() .file() - .set_title("Choose volume packages") - .add_filter("axklib volume packages", &["axkvol"]) + .set_title("Choose axklib packages") + .add_filter( + "axklib packages and A3K archives", + &SUPPORTED_PACKAGE_EXTENSIONS, + ) .set_parent(&window); if let Some(directory) = starting_directory { dialog = dialog.set_directory(directory); @@ -177,7 +180,7 @@ pub(crate) async fn select_local_volume_packages( dialog.blocking_pick_files() }) .await - .map_err(|error| format!("open volume package file picker: {error}"))?; + .map_err(|error| format!("open package file picker: {error}"))?; selected .unwrap_or_default() .into_iter() @@ -188,12 +191,16 @@ pub(crate) async fn select_local_volume_packages( .canonicalize() .map_err(|error| format!("resolve selected package: {error}"))?; if !path.is_file() - || !path - .extension() + || path + .file_name() .and_then(|value| value.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("axkvol")) + .and_then(supported_package_extension) + .is_none() { - return Err("every selected package must be a regular .axkvol file".to_owned()); + return Err( + "every selected file must be a supported axklib package or A3K archive" + .to_owned(), + ); } scope_window .fs_scope() diff --git a/apps/axkdeck/src/App.svelte b/apps/axkdeck/src/App.svelte index cd7c00bb..8bd9db0e 100644 --- a/apps/axkdeck/src/App.svelte +++ b/apps/axkdeck/src/App.svelte @@ -176,6 +176,8 @@ invalidateSession: (sessionId) => auditionWorkflow.invalidateSession(sessionId), refreshSession: (preferred) => imageSessionWorkflow.refresh(preferred), setStatus: (status) => imageSessionWorkflow.setStatus(status), + mutationsAvailable: () => imageSessionWorkflow.packageImportAvailable, + sourceItems: () => imageSessionWorkflow.sourceItems, }); const deletionWorkflow = new DeletionWorkflow({ transport, @@ -278,6 +280,7 @@ sequenceImport: sequenceImportWorkflow, tx16wImport: tx16wImportWorkflow, packageImport: packageImportWorkflow, + packageBatchImport: packageBatchImportWorkflow, sessionId: () => imageSessionWorkflow.sessionId, imageFormat: () => imageSessionWorkflow.imageFormat, mutationsAvailable: () => mutationWorkflow.volumeAvailable, @@ -491,7 +494,6 @@ } if (mutationWorkflow.requestVolumeAction(item, action)) imageSessionWorkflow.selectedSource = item; } - function requestObjectPackageExport(items: PackageExportObject[]): void { if ( !imageSessionWorkflow.packageExportAvailable || diff --git a/apps/axkdeck/src/App.test.ts b/apps/axkdeck/src/App.test.ts index 0db30d70..59308057 100644 --- a/apps/axkdeck/src/App.test.ts +++ b/apps/axkdeck/src/App.test.ts @@ -1366,8 +1366,10 @@ describe('App panel layout', () => { await fireEvent.click(screen.getByRole('menuitem', { name: 'Import' })); await fireEvent.click(screen.getByRole('menuitem', { name: 'Import packages…' })); - const dialog = await screen.findByRole('dialog', { name: 'Import volume packages' }); - expect(within(dialog).getByText('Create volumes in My Partition from selected .axkvol packages.')).toBeTruthy(); + const dialog = await screen.findByRole('dialog', { name: 'Import packages' }); + expect( + within(dialog).getByText('Import portable axklib packages or A3K archives into the open SFS image.'), + ).toBeTruthy(); expect(within(dialog).getByRole('button', { name: /Storage location/ })).toBeTruthy(); }); diff --git a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte index 44fb9b56..78488f8d 100644 --- a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte +++ b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte @@ -320,11 +320,20 @@ {/if} {#if packageBatchImport.request && pickerRequest?.parentDialog !== 'package-import' && directChoiceVisible('package-batch-import', packageBatchImport.request.status !== 'choosing' || packageBatchImport.request.items.length > 0 || Boolean(packageBatchImport.request.error))} void packageBatchImport.chooseWorkspace()} onchooselocal={() => void packageBatchImport.chooseLocal()} - onrename={(itemId, name) => packageBatchImport.renameVolume(itemId, name)} + ondestinationstrategy={(strategy) => packageBatchImport.setDestinationStrategy(strategy)} + ondestinationmode={(mode) => packageBatchImport.setDestinationMode(mode)} + ondestinationvolume={(partitionIndex, volumeName) => + packageBatchImport.setExistingVolume(partitionIndex, volumeName)} + ondestinationpartition={(partitionIndex) => packageBatchImport.setDestinationPartition(partitionIndex)} + ondestinationname={(name) => packageBatchImport.setDestinationVolumeName(name)} + onrenamevolume={(itemId, name) => packageBatchImport.renameVolume(itemId, name)} + onrename={(itemId, nodeId, name) => packageBatchImport.rename(itemId, nodeId, name)} + onprogramslot={(itemId, nodeId, slot) => packageBatchImport.programSlot(itemId, nodeId, slot)} + onprogramstart={(placementId, start) => packageBatchImport.programStart(placementId, start)} ontoggleselected={(itemId, selected) => packageBatchImport.setSelected(itemId, selected)} ontoggleall={(selected) => packageBatchImport.setAllSelected(selected)} onopaquesequenceaction={(itemId, nodeId, action) => diff --git a/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts b/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts index 0191c798..c7588ae1 100644 --- a/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/mediaDropWorkflow.svelte.ts @@ -8,6 +8,7 @@ import type { AudioImportTarget } from '../../lib/transport'; import { tx16wDiskMediaType } from '../../lib/tx16wImport'; import type { DiskTreeItem, WorkspaceView } from '../../lib/types'; import type { AudioImportWorkflow } from './audioWorkflow.svelte'; +import type { PackageBatchImportWorkflow } from './packageBatchWorkflow.svelte'; import type { PackageImportWorkflow } from './packageWorkflow.svelte'; import type { SequenceImportWorkflow } from './sequenceWorkflow.svelte'; import type { Tx16wImportWorkflow } from './tx16wWorkflow.svelte'; @@ -27,6 +28,7 @@ interface MediaDropDependencies { sequenceImport: SequenceImportWorkflow; tx16wImport: Tx16wImportWorkflow; packageImport: PackageImportWorkflow; + packageBatchImport: PackageBatchImportWorkflow; sessionId: () => number | null; imageFormat: () => string | null; mutationsAvailable: () => boolean; @@ -147,14 +149,6 @@ export class MediaDropWorkflow { return; } if (selectedKind === 'package') { - if (files.length !== 1) { - this.dependencies.setStatus('Drop one package or A3K archive at a time'); - this.notice = { - title: 'Import unavailable', - message: 'Choose one portable package or A3K archive for each import.', - }; - return; - } if (!this.dependencies.packageImport.dropAvailable()) { this.dependencies.setStatus('Package import requires a writable SFS hard-disk image'); this.notice = { @@ -163,7 +157,14 @@ export class MediaDropWorkflow { }; return; } - await this.dependencies.packageImport.requestDroppedFile(files[0], this.dependencies.selectedSource()); + if (files.length === 1) { + await this.dependencies.packageImport.requestDroppedFile(files[0], this.dependencies.selectedSource()); + } else { + await this.dependencies.packageBatchImport.requestDroppedFiles( + files, + this.dependencies.selectedSource(), + ); + } return; } const volumeTarget = this.selectedVolumeTarget(); @@ -242,7 +243,8 @@ export class MediaDropWorkflow { this.dependencies.audioImport.request !== null || this.dependencies.sequenceImport.request !== null || this.dependencies.tx16wImport.request !== null || - this.dependencies.packageImport.request !== null + this.dependencies.packageImport.request !== null || + this.dependencies.packageBatchImport.request !== null ); } diff --git a/apps/axkdeck/src/features/import/packageBatchPlanning.ts b/apps/axkdeck/src/features/import/packageBatchPlanning.ts new file mode 100644 index 00000000..e99ae752 --- /dev/null +++ b/apps/axkdeck/src/features/import/packageBatchPlanning.ts @@ -0,0 +1,192 @@ +import type { + ImageSessionPackageImportDestination, + ImageSessionPackageImportPlan, + PackageOpaqueSequenceDecision, + PackageProgramSlotAssignment, + PackageRename, +} from '../../lib/transport'; +import type { BatchPackageItem, PackageBatchImportRequest } from './packageBatchTypes'; +import { importDestination, suggestedPackageVolumeName, type ImportDestinations } from './packageDestinations'; + +export interface BatchPlanArguments { + destination: ImageSessionPackageImportDestination; + renames: PackageRename[]; + programSlotAssignments: PackageProgramSlotAssignment[]; + opaqueSequenceDecisions: PackageOpaqueSequenceDecision[]; +} + +export function batchDecisionKey(itemId: string, nodeId: string): string { + return `${itemId}:${nodeId}`; +} + +export function separateVolumesAvailable(items: readonly BatchPackageItem[]): boolean { + return ( + items.length > 0 && + items.every( + (item) => + item.inspection.packageKind === 'VOLUME' && + item.inspection.roots.length === 1 && + item.inspection.roots[0]?.kind === 'VOLUME', + ) + ); +} + +export function suggestedSharedVolumeName(items: readonly BatchPackageItem[]): string { + const selected = items.filter((item) => item.selected); + return selected.length === 1 ? suggestedPackageVolumeName(selected[0].sourceName, selected[0].inspection) : ''; +} + +export function batchDestinationName(request: PackageBatchImportRequest, itemId: string): string { + const item = request.items.find((candidate) => candidate.id === itemId); + if (!item) return ''; + const packageIndex = request.items + .filter((candidate) => candidate.selected) + .findIndex((entry) => entry.id === itemId); + return ( + request.volumeNames[itemId] ?? + (!request.hasUnvalidatedChanges && packageIndex >= 0 + ? request.plan?.packages.find((candidate) => candidate.packageIndex === packageIndex)?.destinationVolumeName + : undefined) ?? + item.inspection.roots[0]?.displayName ?? + '' + ); +} + +export function normalizedBatchDestination( + request: PackageBatchImportRequest, + items: BatchPackageItem[], + destinations: ImportDestinations, +): Partial { + if (request.destinationStrategy === 'separate' && separateVolumesAvailable(items)) return {}; + const selectedVolume = destinations.volumes.find( + (volume) => + volume.partitionIndex === request.destinationPartitionIndex && + volume.volumeName === request.destinationVolumeName, + ); + const mode = selectedVolume ? 'existing' : request.item?.kind === 'partition' ? 'create' : 'existing'; + const firstVolume = selectedVolume ?? destinations.volumes[0]; + return { + destinationStrategy: 'shared', + destinationMode: mode, + destinationPartitionIndex: + mode === 'existing' + ? (firstVolume?.partitionIndex ?? null) + : (request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null), + destinationVolumeName: mode === 'existing' ? (firstVolume?.volumeName ?? '') : suggestedSharedVolumeName(items), + }; +} + +export function batchPlanArguments( + request: PackageBatchImportRequest, + selectedItems: BatchPackageItem[], +): BatchPlanArguments | null { + const sharedDestination = importDestination( + request.destinationMode, + request.destinationPartitionIndex, + request.destinationVolumeName, + ); + if ( + request.destinationPartitionIndex === null || + (request.destinationStrategy === 'shared' && !sharedDestination) || + (request.destinationStrategy === 'separate' && !separateVolumesAvailable(selectedItems)) + ) { + return null; + } + const packageEntries = new Map(selectedItems.map((item, packageIndex) => [item.id, packageIndex])); + const decisions = ( + values: Record, + convert: (packageIndex: number, nodeId: string, value: T) => R, + ): R[] => + Object.entries(values).flatMap(([key, value]) => { + const separator = key.indexOf(':'); + const packageIndex = packageEntries.get(key.slice(0, separator)); + return separator < 0 || packageIndex === undefined + ? [] + : [convert(packageIndex, key.slice(separator + 1), value)]; + }); + const volumeNameOverrides = selectedItems + .map((item, packageIndex) => ({ + packageIndex, + volumeName: request.volumeNames[item.id]?.trim() ?? '', + })) + .filter((item) => item.volumeName.length > 0); + return { + destination: + request.destinationStrategy === 'separate' + ? { + kind: 'CREATE_VOLUMES_FROM_HINTS', + partitionIndex: request.destinationPartitionIndex, + volumeNameOverrides, + } + : sharedDestination!, + renames: decisions(request.renames, (packageIndex, nodeId, destinationName) => ({ + packageIndex, + nodeId, + destinationName: destinationName.trim(), + })).filter((rename) => rename.destinationName.length > 0), + programSlotAssignments: decisions(request.programSlots, (packageIndex, nodeId, destinationSlot) => ({ + packageIndex, + nodeId, + destinationSlot, + })).sort( + (left, right) => + left.destinationSlot - right.destinationSlot || + left.packageIndex - right.packageIndex || + left.nodeId.localeCompare(right.nodeId), + ), + opaqueSequenceDecisions: decisions(request.opaqueSequenceActions, (packageIndex, nodeId, action) => ({ + packageIndex, + nodeId, + action, + })), + }; +} + +export function mergeBatchPlanSuggestions( + request: PackageBatchImportRequest, + selectedItems: BatchPackageItem[], + plan: ImageSessionPackageImportPlan, +): Pick & { suggestedSlotsAdded: boolean } { + const volumeNames = { ...request.volumeNames }; + for (const summary of plan.packages) { + const item = selectedItems[summary.packageIndex]; + if (item) volumeNames[item.id] ??= summary.destinationVolumeName; + } + const placementKeys = new Set( + plan.programSlotPlacements.flatMap((placement) => + placement.mappings.flatMap((mapping) => { + const item = selectedItems[mapping.packageIndex]; + return item ? [batchDecisionKey(item.id, mapping.nodeId)] : []; + }), + ), + ); + const renames = Object.fromEntries(Object.entries(request.renames).filter(([key]) => !placementKeys.has(key))); + for (const action of plan.actions) { + const item = selectedItems[action.packageIndex]; + if (!item) continue; + const key = batchDecisionKey(item.id, action.nodeId); + if ( + !placementKeys.has(key) && + plan.conflicts.some( + (conflict) => conflict.packageIndex === action.packageIndex && conflict.nodeId === action.nodeId, + ) && + !renames[key] + ) { + renames[key] = action.destinationName; + } + } + const programSlots = { ...request.programSlots }; + let suggestedSlotsAdded = false; + for (const placement of plan.programSlotPlacements) { + for (const mapping of placement.mappings) { + const item = selectedItems[mapping.packageIndex]; + if (!item) continue; + const key = batchDecisionKey(item.id, mapping.nodeId); + if (programSlots[key] === undefined) { + programSlots[key] = mapping.destinationSlot; + suggestedSlotsAdded = true; + } + } + } + return { volumeNames, renames, programSlots, suggestedSlotsAdded }; +} diff --git a/apps/axkdeck/src/features/import/packageBatchSources.ts b/apps/axkdeck/src/features/import/packageBatchSources.ts new file mode 100644 index 00000000..8744ae2c --- /dev/null +++ b/apps/axkdeck/src/features/import/packageBatchSources.ts @@ -0,0 +1,78 @@ +import type { ClientUploadSource } from '../../lib/clientUploadSource'; +import { nativeFileSource } from '../../lib/nativeFileSource'; +import { packageImportExtensionSetCopy, packageImportUploadKind } from '../../lib/packageImportMedia'; +import type { ClientUploadLocation, InputFileLocation } from '../../lib/storageLocations'; +import type { ImageTransport } from '../../lib/transport'; + +export interface BatchPackageSource { + source: InputFileLocation; + sourceName: string; + upload: ClientUploadLocation | null; + localPath: string | null; +} + +interface UploadCallbacks { + progress: (completedFiles: number, currentProgress: number) => void; +} + +export async function uploadLocalPackageSources( + transport: ImageTransport, + paths: readonly string[], + signal: AbortSignal, + callbacks: UploadCallbacks, +): Promise { + const extensions = packageImportExtensionSetCopy(); + const files = await Promise.all( + paths.map(async (path) => ({ + file: await nativeFileSource(path, extensions, 'application/octet-stream'), + localPath: path, + })), + ); + return uploadPackageSources(transport, files, signal, callbacks); +} + +export async function uploadDroppedPackageSources( + transport: ImageTransport, + files: readonly ClientUploadSource[], + signal: AbortSignal, + callbacks: UploadCallbacks, +): Promise { + return uploadPackageSources( + transport, + files.map((file) => ({ file, localPath: null })), + signal, + callbacks, + ); +} + +async function uploadPackageSources( + transport: ImageTransport, + files: readonly { file: ClientUploadSource; localPath: string | null }[], + signal: AbortSignal, + callbacks: UploadCallbacks, +): Promise { + const staged: BatchPackageSource[] = []; + try { + for (const [index, item] of files.entries()) { + const uploadKind = packageImportUploadKind(item.file.name); + if (!uploadKind) throw new Error(`${item.file.name} is not a supported package or A3K archive`); + const upload = await transport.uploadClientFile( + item.file, + uploadKind, + (sent, total) => callbacks.progress(index, total === 0 ? 0 : sent / total), + signal, + ); + staged.push({ + source: upload, + sourceName: item.file.name, + upload, + localPath: item.localPath, + }); + callbacks.progress(index + 1, 0); + } + return staged; + } catch (error) { + await Promise.all(staged.map((item) => transport.releaseClientUpload(item.upload!).catch(() => undefined))); + throw error; + } +} diff --git a/apps/axkdeck/src/features/import/packageBatchTypes.ts b/apps/axkdeck/src/features/import/packageBatchTypes.ts new file mode 100644 index 00000000..6c3277bf --- /dev/null +++ b/apps/axkdeck/src/features/import/packageBatchTypes.ts @@ -0,0 +1,59 @@ +import type { ClientUploadLocation, InputFileLocation } from '../../lib/storageLocations'; +import type { + ImageSessionPackageImportPlan, + ImageTransport, + PackageInspection, + PackageOpaqueSequenceDecision, +} from '../../lib/transport'; +import type { DiskTreeItem } from '../../lib/types'; +import type { PickerController } from '../dialogs/picker'; +import type { JobController } from '../jobs/actions'; +import type { ImportDestinationMode } from './packageDestinations'; +import type { PackagePickerHistory } from './packagePickerHistory'; + +export type PackageBatchDestinationStrategy = 'shared' | 'separate'; + +export interface BatchPackageItem { + id: string; + selected: boolean; + source: InputFileLocation; + sourceName: string; + inspection: PackageInspection; + upload: ClientUploadLocation | null; + localPath: string | null; +} + +export interface PackageBatchImportRequest { + item: DiskTreeItem | null; + canChangeSources: boolean; + items: BatchPackageItem[]; + plan: ImageSessionPackageImportPlan | null; + destinationStrategy: PackageBatchDestinationStrategy; + destinationMode: ImportDestinationMode; + destinationPartitionIndex: number | null; + destinationVolumeName: string; + volumeNames: Record; + renames: Record; + programSlots: Record; + opaqueSequenceActions: Record; + hasUnvalidatedChanges: boolean; + status: 'choosing' | 'loading' | 'planning' | 'ready' | 'applying'; + completedFiles: number; + totalFiles: number; + progress: number; + error: string; +} + +export interface PackageBatchImportDependencies { + transport: ImageTransport; + jobs: JobController; + picker: PickerController; + pickerHistory: PackagePickerHistory; + isDesktop: boolean; + sessionId: () => number | null; + invalidateSession: (sessionId: number) => Promise; + refreshSession: (preferred: { partitionIndex: number; volumeName?: string }) => Promise; + setStatus: (status: string) => void; + mutationsAvailable?: () => boolean; + sourceItems?: () => DiskTreeItem[]; +} diff --git a/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts b/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts index 84b5bd53..6e34f07c 100644 --- a/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts +++ b/apps/axkdeck/src/features/import/packageBatchWorkflow.svelte.ts @@ -1,58 +1,38 @@ -import { nativeFileSource } from '../../lib/nativeFileSource'; -import { selectLocalVolumePackages } from '../../lib/nativePackages'; -import type { ClientUploadLocation, InputFileLocation } from '../../lib/storageLocations'; -import type { - ImageSessionPackageImportPlan, - ImageTransport, - PackageInspection, - PackageOpaqueSequenceDecision, -} from '../../lib/transport'; +import { selectLocalPackages } from '../../lib/nativePackages'; +import type { ClientUploadSource } from '../../lib/clientUploadSource'; +import { packageImportExtensions } from '../../lib/packageImportMedia'; +import type { ClientUploadLocation } from '../../lib/storageLocations'; +import type { ImageSessionPackageImportPlan, PackageOpaqueSequenceDecision } from '../../lib/transport'; import type { DiskTreeItem } from '../../lib/types'; import { reportError } from '../../lib/diagnostics'; import { userFacingMessage } from '../../lib/userFacingMessage'; -import type { PickerController } from '../dialogs/picker'; -import type { JobController } from '../jobs/actions'; -import { PackagePickerHistory } from './packagePickerHistory'; +import { + collectImportDestinations, + importDestination, + initialImportDestination, + type ImportDestinationMode, + type ImportPartitionOption, + type ImportVolumeOption, +} from './packageDestinations'; +import { + batchDecisionKey, + batchDestinationName, + batchPlanArguments, + mergeBatchPlanSuggestions, + normalizedBatchDestination, + separateVolumesAvailable, + suggestedSharedVolumeName, +} from './packageBatchPlanning'; +import { uploadDroppedPackageSources, uploadLocalPackageSources, type BatchPackageSource } from './packageBatchSources'; +import type { + BatchPackageItem, + PackageBatchImportDependencies, + PackageBatchDestinationStrategy, + PackageBatchImportRequest, +} from './packageBatchTypes'; -const volumePackageExtensions = new Set(['axkvol']); const maximumBatchPackages = 256; -export interface BatchPackageItem { - id: string; - selected: boolean; - source: InputFileLocation; - sourceName: string; - inspection: PackageInspection; - upload: ClientUploadLocation | null; - localPath: string | null; -} - -export interface PackageBatchImportRequest { - partition: DiskTreeItem; - items: BatchPackageItem[]; - plan: ImageSessionPackageImportPlan | null; - volumeNames: Record; - opaqueSequenceActions: Record; - hasUnvalidatedChanges: boolean; - status: 'choosing' | 'loading' | 'planning' | 'ready' | 'applying'; - completedFiles: number; - totalFiles: number; - progress: number; - error: string; -} - -interface PackageBatchImportDependencies { - transport: ImageTransport; - jobs: JobController; - picker: PickerController; - pickerHistory: PackagePickerHistory; - isDesktop: boolean; - sessionId: () => number | null; - invalidateSession: (sessionId: number) => Promise; - refreshSession: (preferred: { partitionIndex: number; volumeName?: string }) => Promise; - setStatus: (status: string) => void; -} - export class PackageBatchImportWorkflow { request = $state(null); private generation = 0; @@ -61,16 +41,44 @@ export class PackageBatchImportWorkflow { constructor(private readonly dependencies: PackageBatchImportDependencies) {} - open(partition: DiskTreeItem): void { + dropAvailable(): boolean { + return this.dependencies.mutationsAvailable?.() ?? false; + } + + partitionOptions(): ImportPartitionOption[] { + return this.destinations().partitions; + } + + volumeOptions(): ImportVolumeOption[] { + return this.destinations().volumes; + } + + canUseSeparateVolumes(): boolean { + return this.request ? separateVolumesAvailable(this.request.items.filter((item) => item.selected)) : false; + } + + open(item: DiskTreeItem | null): void { ++this.generation; this.abortController?.abort(); this.abortController = null; this.plannedItemIds = []; + const destination = initialImportDestination(item) ?? { + mode: 'existing' as const, + partitionIndex: null, + volumeName: '', + }; this.request = { - partition, + item, + canChangeSources: true, items: [], plan: null, + destinationStrategy: item?.kind === 'partition' ? 'separate' : 'shared', + destinationMode: destination.mode, + destinationPartitionIndex: destination.partitionIndex, + destinationVolumeName: destination.volumeName, volumeNames: {}, + renames: {}, + programSlots: {}, opaqueSequenceActions: {}, hasUnvalidatedChanges: false, status: 'choosing', @@ -81,6 +89,64 @@ export class PackageBatchImportWorkflow { }; } + setDestinationStrategy(strategy: PackageBatchDestinationStrategy): void { + const request = this.request; + if (!request || request.status === 'applying' || request.destinationStrategy === strategy) return; + if (strategy === 'separate' && !separateVolumesAvailable(request.items.filter((item) => item.selected))) return; + this.updateDestination({ destinationStrategy: strategy }, true); + } + + setDestinationMode(mode: ImportDestinationMode): void { + const request = this.request; + if (!request || request.status === 'applying' || request.destinationMode === mode) return; + const destinations = this.destinations(); + const currentVolume = destinations.volumes.find( + (option) => + option.partitionIndex === request.destinationPartitionIndex && + option.volumeName === request.destinationVolumeName, + ); + const existingVolume = currentVolume ?? destinations.volumes[0]; + this.updateDestination( + { + destinationStrategy: 'shared', + destinationMode: mode, + destinationPartitionIndex: + mode === 'existing' + ? (existingVolume?.partitionIndex ?? null) + : (request.destinationPartitionIndex ?? destinations.partitions[0]?.partitionIndex ?? null), + destinationVolumeName: + mode === 'existing' ? (existingVolume?.volumeName ?? '') : suggestedSharedVolumeName(request.items), + }, + true, + ); + } + + setExistingVolume(partitionIndex: number | null, volumeName: string): void { + const request = this.request; + if (!request || request.status === 'applying') return; + this.updateDestination( + { + destinationStrategy: 'shared', + destinationMode: 'existing', + destinationPartitionIndex: partitionIndex, + destinationVolumeName: volumeName, + }, + partitionIndex !== null, + ); + } + + setDestinationPartition(partitionIndex: number): void { + const request = this.request; + if (!request || request.status === 'applying') return; + this.updateDestination({ destinationPartitionIndex: partitionIndex }, true); + } + + setDestinationVolumeName(volumeName: string): void { + const request = this.request; + if (!request || request.status === 'applying') return; + this.updateDestination({ destinationVolumeName: volumeName.slice(0, 16) }, false); + } + renameVolume(itemId: string, name: string): void { if (!this.request || this.request.status !== 'ready') return; const current = this.destinationName(itemId); @@ -92,15 +158,68 @@ export class PackageBatchImportWorkflow { }; } + rename(itemId: string, nodeId: string, name: string): void { + if (!this.request || this.request.status !== 'ready') return; + const key = batchDecisionKey(itemId, nodeId); + if (this.request.renames[key] === name) return; + this.request = { + ...this.request, + renames: { ...this.request.renames, [key]: name }, + hasUnvalidatedChanges: true, + }; + } + + programSlot(itemId: string, nodeId: string, slot: number): void { + if (!this.request || this.request.status !== 'ready' || !Number.isInteger(slot) || slot < 1 || slot > 128) { + return; + } + const key = batchDecisionKey(itemId, nodeId); + if (this.request.programSlots[key] === slot) return; + this.request = { + ...this.request, + programSlots: { ...this.request.programSlots, [key]: slot }, + hasUnvalidatedChanges: true, + }; + } + + programStart(placementId: string, start: number): void { + const request = this.request; + if (!request || request.status !== 'ready' || !Number.isInteger(start)) return; + const placement = request.plan?.programSlotPlacements.find( + (candidate) => candidate.placementId === placementId, + ); + if ( + !placement || + placement.mode !== 'CONTIGUOUS' || + start < 1 || + start + placement.requiredSlotCount - 1 > 128 + ) { + return; + } + const selectedItems = request.items.filter((item) => item.selected); + const programSlots = { ...request.programSlots }; + for (const [offset, mapping] of placement.mappings.entries()) { + const item = selectedItems[mapping.packageIndex]; + if (item) programSlots[batchDecisionKey(item.id, mapping.nodeId)] = start + offset; + } + this.request = { ...request, programSlots, hasUnvalidatedChanges: true }; + } + setSelected(itemId: string, selected: boolean): void { if (!this.request || this.request.status !== 'ready') return; const item = this.request.items.find((candidate) => candidate.id === itemId); if (!item || item.selected === selected) return; + const items = this.request.items.map((candidate) => + candidate.id === itemId ? { ...candidate, selected } : candidate, + ); this.request = { ...this.request, - items: this.request.items.map((candidate) => - candidate.id === itemId ? { ...candidate, selected } : candidate, - ), + items, + destinationStrategy: + this.request.destinationStrategy === 'separate' && + !separateVolumesAvailable(items.filter((entry) => entry.selected)) + ? 'shared' + : this.request.destinationStrategy, hasUnvalidatedChanges: true, error: '', }; @@ -109,9 +228,15 @@ export class PackageBatchImportWorkflow { setAllSelected(selected: boolean): void { if (!this.request || this.request.status !== 'ready') return; if (this.request.items.every((item) => item.selected === selected)) return; + const items = this.request.items.map((item) => ({ ...item, selected })); this.request = { ...this.request, - items: this.request.items.map((item) => ({ ...item, selected })), + items, + destinationStrategy: + this.request.destinationStrategy === 'separate' && + !separateVolumesAvailable(items.filter((entry) => entry.selected)) + ? 'shared' + : this.request.destinationStrategy, hasUnvalidatedChanges: true, error: '', }; @@ -119,7 +244,7 @@ export class PackageBatchImportWorkflow { opaqueSequenceAction(itemId: string, nodeId: string, action: PackageOpaqueSequenceDecision['action']): void { if (!this.request || this.request.status !== 'ready') return; - const key = this.opaqueSequenceKey(itemId, nodeId); + const key = batchDecisionKey(itemId, nodeId); if (this.request.opaqueSequenceActions[key] === action) return; this.request = { ...this.request, @@ -130,36 +255,26 @@ export class PackageBatchImportWorkflow { } destinationName(itemId: string): string { - const request = this.request; - if (!request) return ''; - const item = request.items.find((candidate) => candidate.id === itemId); - if (!item) return ''; - const selectedItems = request.items.filter((candidate) => candidate.selected); - const packageIndex = selectedItems.findIndex((candidate) => candidate.id === itemId); - return ( - request.volumeNames[itemId] ?? - (!request.hasUnvalidatedChanges && packageIndex >= 0 - ? request.plan?.packages.find((candidate) => candidate.packageIndex === packageIndex) - ?.destinationVolumeName - : undefined) ?? - item.inspection.roots[0]?.displayName ?? - '' - ); + return this.request ? batchDestinationName(this.request, itemId) : ''; } async chooseWorkspace(): Promise { if (!this.request) return; - const selections = await this.dependencies.picker.chooseFiles('Choose volume packages', ['axkvol'], { - parentDialog: 'package-import', - initialDirectory: this.dependencies.pickerHistory.lastDirectory, - initialFile: this.dependencies.pickerHistory.lastImportedWorkspaceFile, - ondirectorychange: (directory) => (this.dependencies.pickerHistory.lastDirectory = directory), - }); + const selections = await this.dependencies.picker.chooseFiles( + 'Choose axklib packages', + [...packageImportExtensions], + { + parentDialog: 'package-import', + initialDirectory: this.dependencies.pickerHistory.lastDirectory, + initialFile: this.dependencies.pickerHistory.lastImportedWorkspaceFile, + ondirectorychange: (directory) => (this.dependencies.pickerHistory.lastDirectory = directory), + }, + ); if (!selections?.length || !this.request) return; if (selections.length > maximumBatchPackages) { this.request = { ...this.request, - error: `Select at most ${maximumBatchPackages} volume packages at once`, + error: `Select at most ${maximumBatchPackages} packages at once`, }; return; } @@ -170,14 +285,8 @@ export class PackageBatchImportWorkflow { async chooseLocal(closeOnCancel = false): Promise { if (!this.request || !this.dependencies.isDesktop) return; - const staged: Array<{ - source: InputFileLocation; - sourceName: string; - upload: ClientUploadLocation; - localPath: string; - }> = []; try { - const paths = await selectLocalVolumePackages(this.dependencies.pickerHistory.lastImportedLocalPath); + const paths = await selectLocalPackages(this.dependencies.pickerHistory.lastImportedLocalPath); if (!paths.length) { if (closeOnCancel) await this.close(); return; @@ -186,54 +295,82 @@ export class PackageBatchImportWorkflow { if (paths.length > maximumBatchPackages) { this.request = { ...this.request, - error: `Select at most ${maximumBatchPackages} volume packages at once`, + error: `Select at most ${maximumBatchPackages} packages at once`, }; return; } - const controller = new AbortController(); - this.abortController?.abort(); - this.abortController = controller; - const generation = ++this.generation; + await this.stageSources(paths.length, (signal, progress) => + uploadLocalPackageSources(this.dependencies.transport, paths, signal, { progress }), + ); + } catch (error) { + if (!this.request) return; + reportError('Import local packages failed', error); + this.request = { ...this.request, status: 'choosing', error: userFacingMessage(error) }; + } + } + + async requestDroppedFiles(files: ClientUploadSource[], item: DiskTreeItem | null): Promise { + if (!this.dropAvailable() || files.length === 0) return; + this.open(item); + if (files.length > maximumBatchPackages) { this.request = { - ...this.request, - status: 'loading', - totalFiles: paths.length, - completedFiles: 0, - progress: 0, - error: '', + ...this.request!, + canChangeSources: false, + error: `Drop at most ${maximumBatchPackages} packages at once`, }; - for (const [index, path] of paths.entries()) { - const file = await nativeFileSource(path, volumePackageExtensions, 'application/octet-stream'); - const upload = await this.dependencies.transport.uploadClientFile( - file, - 'PACKAGE', - (sent, total) => { - if (generation === this.generation && this.request) { - this.request = { - ...this.request, - progress: index + (total === 0 ? 0 : sent / total), - }; - } - }, - controller.signal, - ); - staged.push({ source: upload, sourceName: file.name, upload, localPath: path }); - if (generation === this.generation && this.request) { - this.request = { ...this.request, completedFiles: index + 1 }; - } + return; + } + this.request = { + ...this.request!, + canChangeSources: false, + }; + try { + await this.stageSources(files.length, (signal, progress) => + uploadDroppedPackageSources(this.dependencies.transport, files, signal, { progress }), + ); + } catch (error) { + if (this.request) { + this.request = { ...this.request, status: 'choosing', error: userFacingMessage(error) }; } + } + } + + private async stageSources( + totalFiles: number, + load: ( + signal: AbortSignal, + progress: (completedFiles: number, currentProgress: number) => void, + ) => Promise, + ): Promise { + if (!this.request) return; + const controller = new AbortController(); + this.abortController?.abort(); + this.abortController = controller; + const generation = ++this.generation; + let staged: BatchPackageSource[] = []; + this.request = { + ...this.request, + status: 'loading', + totalFiles, + completedFiles: 0, + progress: 0, + error: '', + }; + try { + staged = await load(controller.signal, (completedFiles, currentProgress) => { + if (generation !== this.generation || !this.request) return; + this.request = { ...this.request, completedFiles, progress: completedFiles + currentProgress }; + }); if (this.abortController === controller) this.abortController = null; if (generation !== this.generation || !this.request) { - await Promise.all(staged.map((item) => this.releaseUpload(item.upload))); + await Promise.all(staged.map((source) => this.releaseUpload(source.upload))); return; } await this.loadSources(staged, generation); } catch (error) { - await Promise.all(staged.map((item) => this.releaseUpload(item.upload))); - this.abortController = null; - if (!this.request) return; - reportError('Import local volume packages failed', error); - this.request = { ...this.request, status: 'choosing', error: userFacingMessage(error) }; + await Promise.all(staged.map((source) => this.releaseUpload(source.upload))); + if (this.abortController === controller) this.abortController = null; + throw error; } } @@ -265,14 +402,14 @@ export class PackageBatchImportWorkflow { request.hasUnvalidatedChanges || selectedItems.length === 0 || sessionId === null || - request.partition.partitionIndex === undefined + request.destinationPartitionIndex === null ) { return; } const generation = ++this.generation; let jobStarted = false; this.request = { ...request, status: 'applying', error: '' }; - this.dependencies.setStatus(`Importing ${selectedItems.length} volume packages`); + this.dependencies.setStatus(`Importing ${selectedItems.length} packages`); try { await this.dependencies.invalidateSession(sessionId); const completed = await this.dependencies.jobs.run( @@ -303,10 +440,10 @@ export class PackageBatchImportWorkflow { await Promise.all(request.items.map((item) => this.releaseUpload(item.upload))); this.request = null; await this.dependencies.refreshSession({ - partitionIndex: request.partition.partitionIndex, + partitionIndex: request.destinationPartitionIndex, volumeName: request.plan.packages[0]?.destinationVolumeName, }); - this.dependencies.setStatus(`Imported ${selectedItems.length} volume packages`); + this.dependencies.setStatus(`Imported ${selectedItems.length} packages`); } catch (error) { if (jobStarted) { await this.recoverUncertainApply(request, generation); @@ -336,7 +473,7 @@ export class PackageBatchImportWorkflow { this.plannedItemIds = []; await this.releasePlan(request.plan); try { - await this.dependencies.refreshSession({ partitionIndex: request.partition.partitionIndex! }); + await this.dependencies.refreshSession({ partitionIndex: request.destinationPartitionIndex! }); if (generation !== this.generation || !this.request) return; await this.plan(generation); if (generation === this.generation && this.request?.status === 'ready' && this.request.plan) { @@ -358,7 +495,7 @@ export class PackageBatchImportWorkflow { const message = 'Import completion could not be confirmed; review the refreshed image before retrying'; try { await this.dependencies.refreshSession({ - partitionIndex: request.partition.partitionIndex!, + partitionIndex: request.destinationPartitionIndex!, volumeName: request.plan?.packages[0]?.destinationVolumeName, }); this.dependencies.setStatus(message); @@ -381,15 +518,7 @@ export class PackageBatchImportWorkflow { await Promise.all(request.items.map((item) => this.releaseUpload(item.upload))); } - private async loadSources( - sources: Array<{ - source: InputFileLocation; - sourceName: string; - upload: ClientUploadLocation | null; - localPath: string | null; - }>, - existingGeneration?: number, - ): Promise { + private async loadSources(sources: BatchPackageSource[], existingGeneration?: number): Promise { if (!this.request) return; const previous = this.request; await this.releasePlan(previous.plan); @@ -401,6 +530,8 @@ export class PackageBatchImportWorkflow { items: [], plan: null, volumeNames: {}, + renames: {}, + programSlots: {}, opaqueSequenceActions: {}, hasUnvalidatedChanges: false, status: 'loading', @@ -413,7 +544,9 @@ export class PackageBatchImportWorkflow { try { for (const [index, source] of sources.entries()) { const inspection = await this.dependencies.transport.inspectPackage(source.source, false); - this.validateInspection(source.sourceName, inspection); + if (!inspection.valid) { + throw new Error(`${source.sourceName} is not a valid portable package or A3K archive`); + } items.push({ ...source, id: `batch-${generation}-${index}`, selected: true, inspection }); if (generation !== this.generation || !this.request) { await Promise.all(sources.map((item) => this.releaseUpload(item.upload))); @@ -421,7 +554,8 @@ export class PackageBatchImportWorkflow { } this.request = { ...this.request, items: [...items], completedFiles: index + 1 }; } - this.request = { ...this.request, status: 'planning' }; + const destination = normalizedBatchDestination(this.request, items, this.destinations()); + this.request = { ...this.request, ...destination, status: 'planning' }; await this.plan(generation); } catch (error) { await Promise.all(sources.map((item) => this.releaseUpload(item.upload))); @@ -430,84 +564,119 @@ export class PackageBatchImportWorkflow { } } - private validateInspection(sourceName: string, inspection: PackageInspection): void { - if ( - !inspection.valid || - inspection.packageKind !== 'VOLUME' || - inspection.requiredExtension.toLocaleLowerCase() !== '.axkvol' || - inspection.roots.length !== 1 || - inspection.roots[0]?.kind !== 'VOLUME' - ) { - throw new Error(`${sourceName} is not a valid single-volume .axkvol package`); + private async plan(generation: number, replacePlanToken?: string): Promise { + const automaticPlanToken = await this.planOnce(generation, replacePlanToken, true); + if (!automaticPlanToken) return; + try { + await this.planOnce(generation, automaticPlanToken, false); + } catch (error) { + if (generation === this.generation && this.request) { + this.request = { ...this.request, status: 'ready', error: userFacingMessage(error) }; + } } } - private async plan(generation: number, replacePlanToken?: string): Promise { + private async planOnce( + generation: number, + replacePlanToken: string | undefined, + allowAutomaticProgramSlotCheck: boolean, + ): Promise { const request = this.request; const sessionId = this.dependencies.sessionId(); - const partitionIndex = request?.partition.partitionIndex; const selectedItems = request?.items.filter((item) => item.selected) ?? []; - if (!request || selectedItems.length === 0 || sessionId === null || partitionIndex === undefined) return; + const arguments_ = request ? batchPlanArguments(request, selectedItems) : null; + if (!request || selectedItems.length === 0 || sessionId === null || !arguments_) { + if (request && generation === this.generation) { + this.request = { ...request, status: 'ready', hasUnvalidatedChanges: true }; + } + return null; + } this.request = { ...request, status: 'planning', error: '' }; try { - const volumeNameOverrides = selectedItems - .map((item, packageIndex) => ({ - packageIndex, - volumeName: request.volumeNames[item.id]?.trim() ?? '', - })) - .filter((item) => item.volumeName.length > 0); - const opaqueSequenceDecisions = selectedItems.flatMap((item, packageIndex) => { - const prefix = `${item.id}:`; - return Object.entries(request.opaqueSequenceActions) - .filter(([key]) => key.startsWith(prefix)) - .map(([key, action]) => ({ packageIndex, nodeId: key.slice(prefix.length), action })); - }); const plan = await this.dependencies.transport.planImagePackageImport( sessionId, selectedItems.map((item) => item.source), - { kind: 'CREATE_VOLUMES_FROM_HINTS', partitionIndex, volumeNameOverrides }, - [], - [], + arguments_.destination, + arguments_.renames, + arguments_.programSlotAssignments, replacePlanToken, - opaqueSequenceDecisions, + arguments_.opaqueSequenceDecisions, ); if (generation !== this.generation || !this.request) { await this.releasePlan(plan); - return; + return null; } + const merged = mergeBatchPlanSuggestions(request, selectedItems, plan); + const checkSuggestedProgramSlots = + allowAutomaticProgramSlotCheck && + merged.suggestedSlotsAdded && + plan.programSlotPlacements.some( + (placement) => + !placement.applied && placement.mode !== 'UNAVAILABLE' && placement.mappings.length > 0, + ); this.request = { ...this.request, plan, - volumeNames: { - ...request.volumeNames, - ...Object.fromEntries( - plan.packages.flatMap((item) => { - const selectedItem = selectedItems[item.packageIndex]; - return selectedItem - ? [ - [ - selectedItem.id, - request.volumeNames[selectedItem.id] ?? item.destinationVolumeName, - ], - ] - : []; - }), - ), - }, - hasUnvalidatedChanges: false, - status: 'ready', + volumeNames: merged.volumeNames, + renames: merged.renames, + programSlots: merged.programSlots, + hasUnvalidatedChanges: merged.suggestedSlotsAdded, + status: checkSuggestedProgramSlots ? 'planning' : 'ready', error: '', }; this.plannedItemIds = selectedItems.map((item) => item.id); + return checkSuggestedProgramSlots ? plan.planToken : null; } catch (error) { if (generation === this.generation && this.request) { this.request = { ...this.request, status: 'ready', error: userFacingMessage(error) }; } + return null; } } - private opaqueSequenceKey(itemId: string, nodeId: string): string { - return `${itemId}:${nodeId}`; + private updateDestination( + update: Partial< + Pick< + PackageBatchImportRequest, + 'destinationStrategy' | 'destinationMode' | 'destinationPartitionIndex' | 'destinationVolumeName' + > + >, + automaticallyPlan: boolean, + ): void { + const request = this.request; + if (!request || request.status === 'applying') return; + const previousPlan = request.plan; + const next = { + ...request, + ...update, + plan: null, + renames: {}, + programSlots: {}, + hasUnvalidatedChanges: true, + status: request.items.length > 0 ? ('ready' as const) : request.status, + error: '', + }; + const destinationReady = + next.destinationStrategy === 'separate' + ? next.destinationPartitionIndex !== null && + separateVolumesAvailable(next.items.filter((item) => item.selected)) + : importDestination( + next.destinationMode, + next.destinationPartitionIndex, + next.destinationVolumeName, + ) !== null; + const shouldPlan = automaticallyPlan && next.items.some((item) => item.selected) && destinationReady; + const generation = ++this.generation; + this.request = { ...next, status: shouldPlan ? 'planning' : next.status }; + void (async () => { + await this.releasePlan(previousPlan); + if (!shouldPlan || generation !== this.generation || !this.request) return; + await this.plan(generation); + })(); + } + + private destinations() { + return collectImportDestinations(this.dependencies.sourceItems?.() ?? []); } private async releasePlan(plan: ImageSessionPackageImportPlan | null): Promise { diff --git a/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts b/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts index 4eaff9f8..af977c0e 100644 --- a/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts +++ b/apps/axkdeck/src/features/import/packageBatchWorkflow.test.ts @@ -7,17 +7,23 @@ import type { JobController } from '../jobs/actions'; import { PackageBatchImportWorkflow } from './packageBatchWorkflow.svelte'; import { PackagePickerHistory } from './packagePickerHistory'; -function inspection(packageId: string, name: string, programs: number): PackageInspection { +function inspection( + packageId: string, + name: string, + programs: number, + packageKind: Exclude = 'VOLUME', +): PackageInspection { + const rootKind = packageKind === 'SEQUENCE' ? 'SEQU' : packageKind; return { schemaVersion: '1.0', packageId, - packageKind: 'VOLUME', - requiredExtension: '.axkvol', + packageKind, + requiredExtension: packageKind === 'VOLUME' ? '.axkvol' : '.axkprg', sourceMediaKind: 'SFS', valid: true, payloadsVerified: true, totalPayloadBytes: programs * 100, - roots: [{ kind: 'VOLUME', displayName: name, nodeIds: [] }], + roots: [{ kind: rootKind, displayName: name, nodeIds: [] }], objects: Array.from({ length: programs }, (_, index) => ({ nodeId: `${packageId}-${index}`, objectType: 'PROG' as const, @@ -278,7 +284,124 @@ describe('PackageBatchImportWorkflow', () => { rootId: 'workspace', relativePath: 'three.axkvol', }); - expect(setStatus).toHaveBeenCalledWith('Imported 2 volume packages'); + expect(setStatus).toHaveBeenCalledWith('Imported 2 packages'); + }); + + it('plans mixed package kinds into one selected existing volume', async () => { + const sources = [ + serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkprg' }, 'one.axkprg'), + serverFileLocation({ rootId: 'workspace', relativePath: 'two.axksbac' }, 'two.axksbac'), + ]; + const planImagePackageImport = vi.fn().mockResolvedValue(plan(['Target', 'Target'], 'mixed-plan')); + const picker = new PickerController(() => undefined); + const volume: DiskTreeItem = { + id: 'volume-0', + name: 'Target', + kind: 'volume', + childCount: 0, + partitionIndex: 0, + }; + const partition: DiskTreeItem = { + id: 'partition-0', + name: 'Partition 1', + kind: 'partition', + childCount: 1, + partitionIndex: 0, + children: [volume], + }; + const workflow = new PackageBatchImportWorkflow({ + transport: { + inspectPackage: vi + .fn() + .mockResolvedValueOnce(inspection('package-0', 'Program', 1, 'PROGRAM')) + .mockResolvedValueOnce(inspection('package-1', 'Bank', 0, 'SBAC')), + planImagePackageImport, + releaseImagePackageImportPlan: vi.fn().mockResolvedValue(undefined), + } as unknown as ImageTransport, + jobs: {} as JobController, + picker, + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + sourceItems: () => [partition], + }); + + workflow.open(volume); + const choosing = workflow.chooseWorkspace(); + picker.finish(sources); + await choosing; + + expect(workflow.request?.destinationStrategy).toBe('shared'); + expect(workflow.canUseSeparateVolumes()).toBe(false); + expect(planImagePackageImport).toHaveBeenCalledWith( + 17, + sources, + { kind: 'EXISTING_VOLUME', partitionIndex: 0, volumeName: 'Target' }, + [], + [], + undefined, + [], + ); + }); + + it('plans multiple portable packages into one explicitly named new volume', async () => { + const sources = [ + serverFileLocation({ rootId: 'workspace', relativePath: 'one.axkprg' }, 'one.axkprg'), + serverFileLocation({ rootId: 'workspace', relativePath: 'two.axksbnk' }, 'two.axksbnk'), + ]; + const planImagePackageImport = vi.fn().mockResolvedValue(plan(['Combined', 'Combined'], 'shared-plan')); + const picker = new PickerController(() => undefined); + const partition: DiskTreeItem = { + id: 'partition-0', + name: 'Partition 1', + kind: 'partition', + childCount: 0, + partitionIndex: 0, + }; + const workflow = new PackageBatchImportWorkflow({ + transport: { + inspectPackage: vi + .fn() + .mockResolvedValueOnce(inspection('package-0', 'Program', 1, 'PROGRAM')) + .mockResolvedValueOnce(inspection('package-1', 'Sample', 0, 'SBNK')), + planImagePackageImport, + releaseImagePackageImportPlan: vi.fn().mockResolvedValue(undefined), + } as unknown as ImageTransport, + jobs: {} as JobController, + picker, + pickerHistory: new PackagePickerHistory(), + isDesktop: false, + sessionId: () => 17, + invalidateSession: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + sourceItems: () => [partition], + }); + + workflow.open(partition); + const choosing = workflow.chooseWorkspace(); + picker.finish(sources); + await choosing; + + expect(workflow.request?.destinationStrategy).toBe('shared'); + expect(workflow.request?.destinationMode).toBe('create'); + expect(workflow.canUseSeparateVolumes()).toBe(false); + workflow.setDestinationVolumeName('Combined'); + await workflow.replan(); + + expect(planImagePackageImport).toHaveBeenCalledTimes(1); + expect(planImagePackageImport).toHaveBeenCalledWith( + 17, + sources, + { kind: 'CREATE_VOLUME', partitionIndex: 0, volumeName: 'Combined' }, + [], + [], + undefined, + [], + ); }); it('refreshes and replans automatically when the image changed before apply', async () => { diff --git a/apps/axkdeck/src/lib/components/PackageBatchConflictControls.svelte b/apps/axkdeck/src/lib/components/PackageBatchConflictControls.svelte new file mode 100644 index 00000000..a52efc84 --- /dev/null +++ b/apps/axkdeck/src/lib/components/PackageBatchConflictControls.svelte @@ -0,0 +1,247 @@ + + +{#if plan.programSlotPlacements.length > 0} +
+ {#each plan.programSlotPlacements as placement (placement.placementId)} + {@const firstMapping = placement.mappings[0]} + {@const firstKey = firstMapping ? assignmentKey(firstMapping.packageIndex, firstMapping.nodeId) : ''} +
+
+ Program slots + + {placement.mode === 'UNAVAILABLE' + ? 'Not enough free slots' + : placementHasChanges(placement) + ? 'Needs check' + : placement.applied + ? 'Checked' + : 'Suggested'} + +
+
+
+
Occupied
+
{formatRanges(placement.occupiedRanges)}
+
+
+
Package
+
{formatRanges(placement.sourceRanges)}
+
+
+
Destination
+
{formatRanges(placement.destinationRanges)}
+
+
+ {#if placement.mode === 'UNAVAILABLE'} +

+ {placement.requiredSlotCount} slots are required, but only {placement.availableSlotCount} are available. +

+ {:else if placement.mode === 'CONTIGUOUS'} + + {:else} +
+ {#each placement.mappings as mapping (`${mapping.packageIndex}:${mapping.nodeId}`)} + {@const item = items[mapping.packageIndex]} + {@const key = assignmentKey(mapping.packageIndex, mapping.nodeId)} + {#if item} + + {/if} + {/each} +
+ {/if} +
+ {/each} +
+{/if} + +{#if renameActions.length > 0} +
+ Choose unused destination names + {#each renameActions as action (`${action.packageIndex}:${action.actionId}`)} + {@const item = items[action.packageIndex]} + {@const key = item ? batchDecisionKey(item.id, action.nodeId) : ''} + {#if item} + + {/if} + {/each} +
+{/if} + + diff --git a/apps/axkdeck/src/lib/components/PackageBatchDestinationChooser.svelte b/apps/axkdeck/src/lib/components/PackageBatchDestinationChooser.svelte new file mode 100644 index 00000000..fd1fb9dd --- /dev/null +++ b/apps/axkdeck/src/lib/components/PackageBatchDestinationChooser.svelte @@ -0,0 +1,163 @@ + + +
+
+ Placement +
+ + +
+
+ + {#if strategy === 'shared'} + + {:else} + + Each selected package creates a volume using its placement hint and editable name below. + {/if} +
+ + diff --git a/apps/axkdeck/src/lib/components/PackageBatchImportDialog.svelte b/apps/axkdeck/src/lib/components/PackageBatchImportDialog.svelte index 648cd565..384e1fed 100644 --- a/apps/axkdeck/src/lib/components/PackageBatchImportDialog.svelte +++ b/apps/axkdeck/src/lib/components/PackageBatchImportDialog.svelte @@ -1,18 +1,44 @@ + +{#each capacityReports as capacity (capacity.partitionIndex)} +
0} + class="capacity-summary" + aria-label={`SFS record capacity for partition ${capacity.partitionIndex + 1}`} + > +
+ SFS record capacity + + Partition {capacity.partitionIndex + 1} · {capacity.indexBlockCount} blocks × + {capacity.recordsPerIndexBlock} records · {capacity.reservedRecordSlots} reserved + +
+
+ {capacity.freeRecordSlots} free + {capacity.requiredRecordSlots} required + {#if capacity.shortfallRecordSlots > 0} + {capacity.shortfallRecordSlots} short + {:else} + {capacity.remainingRecordSlots} remaining + {/if} +
+
+{/each} + +
+
+ + ontoggleall(event.currentTarget.checked)} + /> + + Package + Destination + Contents + SFS records +
+ {#each items as item (item.id)} + {@const packageIndex = packageIndexById.get(item.id)} + {@const summary = summaries.find((entry) => entry.packageIndex === packageIndex)} + {@const recordUsage = packageIndex === undefined ? undefined : capacityByPackage.get(packageIndex)} + {@const allocatableRecords = + packageIndex === undefined ? 0 : (allocatableRecordsByPackage.get(packageIndex) ?? 0)} + {@const cannotFitEmptyPartition = + recordUsage !== undefined && recordUsage.standaloneRequiredRecordSlots > allocatableRecords} +
+
+ ontoggleselected(item.id, event.currentTarget.checked)} + /> +
+
+ {item.sourceName} + {formatStoredSize(item.inspection.totalPayloadBytes)} +
+
+ {#if destinationStrategy === 'separate'} + + {:else if item.selected} + {destinationVolumeName || 'Choose a volume'} + {:else} + Not included + {/if} +
+
+ {#if summary} + {summary.objectCounts.programs} Programs + {summary.objectCounts.sampleBanks} Sample Banks + {summary.objectCounts.samples} Samples + {summary.objectCounts.waveData} Wave Data + {summary.objectCounts.sequences} Sequences + {:else} + {item.inspection.objects.length} objects + {/if} +
+
+ {#if !item.selected} + Not included + {:else if hasUnvalidatedChanges} + Pending check + {:else if recordUsage} + {recordUsage.plannedRecordSlots} + + {recordUsage.plannedObjectRecordSlots} objects + {#if recordUsage.volumeScaffoldingRecordSlots > 0} + + {recordUsage.volumeScaffoldingRecordSlots} volume + {/if} + {#if recordUsage.reusedObjectCount > 0}· {recordUsage.reusedObjectCount} reused{/if} + {#if recordUsage.shortfallRecordSlots > 0}· {recordUsage.shortfallRecordSlots} short{/if} + + {:else} + + {/if} +
+
+ {/each} +
+ + diff --git a/apps/axkdeck/src/lib/generated/axklibApiV1.ts b/apps/axkdeck/src/lib/generated/axklibApiV1.ts index d240f031..9d02a17b 100644 --- a/apps/axkdeck/src/lib/generated/axklibApiV1.ts +++ b/apps/axkdeck/src/lib/generated/axklibApiV1.ts @@ -2754,7 +2754,7 @@ export interface components { totalPayloadBytes: number; valid: boolean; }; - /** @description One existing SFS volume, one explicitly named new SFS volume, or one new SFS volume per volume package placement hint. */ + /** @description One existing SFS volume, one explicitly named new SFS volume receiving all selected packages, or one new SFS volume per volume package placement hint. */ ImageSessionPackageImportDestination: | { /** @constant */ diff --git a/apps/axkdeck/src/lib/nativePackages.test.ts b/apps/axkdeck/src/lib/nativePackages.test.ts index 3780ce36..6a1f2ff5 100644 --- a/apps/axkdeck/src/lib/nativePackages.test.ts +++ b/apps/axkdeck/src/lib/nativePackages.test.ts @@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); -import { selectLocalPackage } from './nativePackages'; +import { selectLocalPackage, selectLocalPackages } from './nativePackages'; describe('nativePackages', () => { beforeEach(() => mocks.invoke.mockReset()); @@ -26,4 +26,14 @@ describe('nativePackages', () => { expect(mocks.invoke).toHaveBeenCalledWith('select_local_package', { preferredPath: null }); }); + + it('passes the preferred package path to the multi-file native chooser', async () => { + mocks.invoke.mockResolvedValue([]); + + await selectLocalPackages('previous-package.axkprg'); + + expect(mocks.invoke).toHaveBeenCalledWith('select_local_packages', { + preferredPath: 'previous-package.axkprg', + }); + }); }); diff --git a/apps/axkdeck/src/lib/nativePackages.ts b/apps/axkdeck/src/lib/nativePackages.ts index e1cc61ad..3b5515be 100644 --- a/apps/axkdeck/src/lib/nativePackages.ts +++ b/apps/axkdeck/src/lib/nativePackages.ts @@ -9,8 +9,8 @@ export function selectLocalPackage(preferredPath: string | null): Promise { - return invoke('select_local_volume_packages', { preferredPath }); +export function selectLocalPackages(preferredPath: string | null): Promise { + return invoke('select_local_packages', { preferredPath }); } export function selectLocalPackageDestination(suggestedName: string): Promise { diff --git a/apps/server/contracts/openapi-v1.base.json b/apps/server/contracts/openapi-v1.base.json index 804482fa..c97ec3ac 100644 --- a/apps/server/contracts/openapi-v1.base.json +++ b/apps/server/contracts/openapi-v1.base.json @@ -2586,7 +2586,7 @@ } }, "ImageSessionPackageImportDestination": { - "description": "One existing SFS volume, one explicitly named new SFS volume, or one new SFS volume per volume package placement hint.", + "description": "One existing SFS volume, one explicitly named new SFS volume receiving all selected packages, or one new SFS volume per volume package placement hint.", "oneOf": [ { "type": "object", diff --git a/apps/server/contracts/openapi-v1.json b/apps/server/contracts/openapi-v1.json index b34f31a4..fbe608ac 100644 --- a/apps/server/contracts/openapi-v1.json +++ b/apps/server/contracts/openapi-v1.json @@ -5478,7 +5478,7 @@ "type": "object" }, "ImageSessionPackageImportDestination": { - "description": "One existing SFS volume, one explicitly named new SFS volume, or one new SFS volume per volume package placement hint.", + "description": "One existing SFS volume, one explicitly named new SFS volume receiving all selected packages, or one new SFS volume per volume package placement hint.", "oneOf": [ { "additionalProperties": false,