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/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..74e513e4 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") { + 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(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; 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..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; @@ -709,6 +748,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/README.md b/apps/axkdeck/README.md index aa751f6b..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, @@ -217,7 +227,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 +235,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 @@ -346,6 +356,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/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/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/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 2b6f9ee0..f4c330ef 100644 --- a/apps/axkdeck/src-tauri/src/lib.rs +++ b/apps/axkdeck/src-tauri/src/lib.rs @@ -7,10 +7,13 @@ mod local_workspaces; mod remote_settings; mod retained_download; mod server_sidecar; +mod startup_diagnostics; +mod webview_runtime; use std::sync::Mutex; use serde::Serialize; +use tauri::webview::PageLoadEvent; use tauri::{Manager, State, WebviewWindow}; use tauri_plugin_log::{RotationStrategy, Target, TargetKind}; @@ -23,9 +26,10 @@ 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}; const LOG_FILE_SIZE: u128 = 5 * 1024 * 1024; const RETAINED_LOG_FILES: usize = 3; @@ -39,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 { @@ -49,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, } } @@ -121,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] @@ -155,7 +165,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}")), @@ -315,28 +325,30 @@ 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] -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 +359,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 +398,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 +411,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 +441,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, @@ -456,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, @@ -468,7 +502,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..c0517943 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); @@ -154,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, @@ -165,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); @@ -174,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() @@ -185,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() @@ -459,7 +469,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 +479,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 +496,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 +506,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-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-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..e93789e2 --- /dev/null +++ b/apps/axkdeck/src-tauri/windows/webview2-consent.nsh @@ -0,0 +1,55 @@ +!define AXKDECK_WEBVIEW2_APP_GUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" +!define AXKDECK_MINIMUM_WEBVIEW2_VERSION "111.0.0.0" +!define MUI_CUSTOMFUNCTION_GUIINIT axkdeck_webview2_consent + +; Tauri's WebView2 section runs before NSIS_HOOK_PREINSTALL. Use the GUI +; callback so an interactive user can decline before any runtime is changed. +; Modern UI owns .onGUIInit and calls this registered callback from it. +Function axkdeck_webview2_consent + 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/src/App.svelte b/apps/axkdeck/src/App.svelte index f1697303..8bd9db0e 100644 --- a/apps/axkdeck/src/App.svelte +++ b/apps/axkdeck/src/App.svelte @@ -1,16 +1,20 @@ + +
+
+
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/app.css b/apps/axkdeck/src/app.css index 9b31935a..c71db098 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; } @@ -128,6 +133,18 @@ input:focus-visible { align-items: center; gap: 10px; padding: 0 10px; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; +} + +.brand:hover { + background: rgb(255 255 255 / 4%); +} + +.brand:focus-visible { + outline-offset: -3px; } .brand-mark { @@ -2123,13 +2140,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 +2856,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/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/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..78488f8d 100644 --- a/apps/axkdeck/src/features/dialogs/AppDialogs.svelte +++ b/apps/axkdeck/src/features/dialogs/AppDialogs.svelte @@ -1,4 +1,5 @@ {#if pickerRequest} @@ -253,10 +281,16 @@ onsubmit={(bankObjectId) => void mutation.submitSampleBankAssignment(bankObjectId)} /> {/if} -{#if packageImport.request && pickerRequest?.parentDialog !== 'package-import'} +{#if packageImport.request && pickerRequest?.parentDialog !== 'package-import' && directChoiceVisible('package-import', packageImport.request.status !== 'choosing' || Boolean(packageImport.request.sourceName || packageImport.request.error))} 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)} @@ -279,13 +318,22 @@ onconfirm={() => void packageImport.apply()} /> {/if} -{#if packageBatchImport.request && pickerRequest?.parentDialog !== 'package-import'} +{#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) => @@ -305,54 +362,46 @@ onconfirm={() => void packageBatchImport.apply()} /> {/if} -{#if exports.packageRequest && pickerRequest?.parentDialog !== 'package-export' && !companionRequest} +{#if exports.packageRequest && !exports.packageRequest.busy && pickerRequest?.parentDialog !== 'package-export' && !companionRequest && directChoiceVisible('package-export', Boolean(exports.packageRequest.error))} void exports.packageToWorkspace()} onlocal={() => void exports.packageToComputer()} oncancel={() => exports.closePackage()} /> {/if} -{#if volumePackages.request && pickerRequest?.parentDialog !== 'volume-package-export'} +{#if volumePackages.request && !volumePackages.request.busy && pickerRequest?.parentDialog !== 'volume-package-export' && directChoiceVisible('volume-package-export', Boolean(volumePackages.request.error))} void volumePackages.toWorkspace()} onlocal={() => void volumePackages.toComputer()} oncancel={() => volumePackages.cancel()} /> {/if} -{#if volumeFloppies.request && pickerRequest?.parentDialog !== 'volume-floppy-export'} +{#if volumeFloppies.request && !volumeFloppies.request.busy && pickerRequest?.parentDialog !== 'volume-floppy-export' && directChoiceVisible('volume-floppy-export', Boolean(volumeFloppies.request.error))} void volumeFloppies.toWorkspace()} onlocal={() => void volumeFloppies.toComputer()} oncancel={() => volumeFloppies.cancel()} /> {/if} -{#if exports.audioRequest && pickerRequest?.parentDialog !== 'audio-export' && !companionRequest} +{#if exports.audioRequest && !exports.audioRequest.busy && pickerRequest?.parentDialog !== 'audio-export' && !companionRequest && directChoiceVisible('audio-export', Boolean(exports.audioRequest.error))} exports.setAudioFormat(format)} @@ -361,19 +410,17 @@ oncancel={() => exports.cancelAudio()} /> {/if} -{#if exports.sequenceRequest && pickerRequest?.parentDialog !== 'sequence-export'} +{#if exports.sequenceRequest && !exports.sequenceRequest.busy && pickerRequest?.parentDialog !== 'sequence-export' && directChoiceVisible('sequence-export', Boolean(exports.sequenceRequest.error))} void exports.sequenceToWorkspace()} onlocal={() => void exports.sequenceToComputer()} oncancel={() => exports.cancelSequence()} /> {/if} -{#if mediaExports.request && pickerRequest?.parentDialog !== 'media-export'} +{#if mediaExports.request && !mediaExports.request.busy && pickerRequest?.parentDialog !== 'media-export' && directChoiceVisible('media-export', Boolean(mediaExports.request.error))} mediaExports.cancel()} /> {/if} +{#if exportProgressVisibility.operation === 'package-export' && exports.packageRequest?.busy} + undefined} + /> +{:else if exportProgressVisibility.operation === 'volume-package-export' && volumePackages.request?.busy} + volumePackages.cancel()} + /> +{:else if exportProgressVisibility.operation === 'volume-floppy-export' && volumeFloppies.request?.busy} + volumeFloppies.cancel()} + /> +{:else if exportProgressVisibility.operation === 'audio-export' && exports.audioRequest?.busy} + exports.cancelAudio()} + /> +{:else if exportProgressVisibility.operation === 'sequence-export' && exports.sequenceRequest?.busy} + exports.cancelSequence()} + /> +{:else if exportProgressVisibility.operation === 'media-export' && mediaExports.request?.busy} + mediaExports.cancel()} + /> +{/if} {#if deletion.objectRequest} void programGeneration.submit()} /> {/if} -{#if audioImport.request && pickerRequest?.parentDialog !== 'audio-import'} +{#if audioImport.request && pickerRequest?.parentDialog !== 'audio-import' && directChoiceVisible('audio-import', audioImport.request.files.length > 0)} void audioImport.chooseWorkspace()} onchooselocal={transport.supportsClientUploads && audioFileInput ? () => audioImport.chooseLocal(audioFileInput) : undefined} + ondestinationmode={(mode) => audioImport.setDestinationMode(mode)} + ondestinationvolume={(partitionIndex, volumeName) => + void audioImport.setExistingVolume(partitionIndex, volumeName)} + ondestinationpartition={(partitionIndex) => audioImport.setDestinationPartition(partitionIndex)} + ondestinationname={(volumeName) => audioImport.setDestinationVolumeName(volumeName)} oncommit={(items, grouping) => audioImport.commit(items, grouping)} oncancel={() => (audioImport.request = null)} /> {/if} -{#if sequenceImport.request && pickerRequest?.parentDialog !== 'sequence-import'} +{#if sequenceImport.request && pickerRequest?.parentDialog !== 'sequence-import' && directChoiceVisible('sequence-import', sequenceImport.request.files.length > 0)} 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/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/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/PackageImportDialog.svelte b/apps/axkdeck/src/lib/components/PackageImportDialog.svelte index 16c822ec..7836da1b 100644 --- a/apps/axkdeck/src/lib/components/PackageImportDialog.svelte +++ b/apps/axkdeck/src/lib/components/PackageImportDialog.svelte @@ -1,13 +1,26 @@
- +
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/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() } }); 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 8c6e99ad..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')); @@ -176,6 +177,9 @@ class UnavailableTransport implements ImageTransport { openImage(): Promise { return this.unavailable(); } + keepImageAlive(): Promise { + return this.unavailable(); + } refreshImage(): Promise { return this.unavailable(); } diff --git a/apps/axkdeck/src/lib/desktopBuildInfo.ts b/apps/axkdeck/src/lib/desktopBuildInfo.ts new file mode 100644 index 00000000..690aca16 --- /dev/null +++ b/apps/axkdeck/src/lib/desktopBuildInfo.ts @@ -0,0 +1,19 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface DesktopBuildInfo { + schemaVersion: number; + semanticVersion: string; + projectVersion: string; + sourceIdentity: string; + releaseTag: string; + isRelease: boolean; + webviewEngine: string; + webviewVersion: string | null; +} + +export type DesktopBuildInfoState = + { status: 'loading' } | { status: 'ready'; buildInfo: DesktopBuildInfo } | { status: 'error' }; + +export function desktopBuildInfo(): Promise { + return invoke('desktop_build_info'); +} 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/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/generated/axklibApiV1.ts b/apps/axkdeck/src/lib/generated/axklibApiV1.ts index 86e6067e..9d02a17b 100644 --- a/apps/axkdeck/src/lib/generated/axklibApiV1.ts +++ b/apps/axkdeck/src/lib/generated/axklibApiV1.ts @@ -2754,8 +2754,14 @@ export interface components { totalPayloadBytes: number; valid: boolean; }; - /** @description Either one existing SFS volume shared by every package root or one new SFS volume per volume package, named from its placement hint with optional indexed overrides. */ + /** @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 */ + kind: 'CREATE_VOLUME'; + partitionIndex: number; + volumeName: string; + } | { /** @constant */ kind: 'EXISTING_VOLUME'; diff --git a/apps/axkdeck/src/lib/httpImageSessionReads.ts b/apps/axkdeck/src/lib/httpImageSessionReads.ts index 772f9bba..6f051f4b 100644 --- a/apps/axkdeck/src/lib/httpImageSessionReads.ts +++ b/apps/axkdeck/src/lib/httpImageSessionReads.ts @@ -20,6 +20,10 @@ export class HttpImageSessionReads { return this.imageSessions.open(location); } + keepImageAlive(sessionId: number): Promise { + return this.imageSessions.keepAlive(sessionId); + } + refreshImage(sessionId: number): Promise { return this.imageSessions.refresh(sessionId); } diff --git a/apps/axkdeck/src/lib/httpImageSessions.ts b/apps/axkdeck/src/lib/httpImageSessions.ts index 319e87fa..004b95f2 100644 --- a/apps/axkdeck/src/lib/httpImageSessions.ts +++ b/apps/axkdeck/src/lib/httpImageSessions.ts @@ -88,6 +88,15 @@ export class HttpImageSessions { return this.openedImage(sessionId, summary); } + async keepAlive(sessionId: number): Promise { + const session = this.get(sessionId); + const summary = await this.client.request( + 'GET', + `/images/${encodeURIComponent(session.remoteId)}`, + ); + if (summary.revision > session.revision) this.replaceRevision(session, summary.revision); + } + async attachCompanions(sessionId: number, selection: CompanionSelection): Promise { const session = this.get(sessionId); const wireSelection: components['schemas']['CompanionSelection'] = @@ -443,6 +452,7 @@ export class HttpImageSessions { mediaConversionAvailable: (summary.availableOperations ?? []).includes('images.media_conversion'), extentLayoutRepairAvailable: (summary.availableOperations ?? []).includes('images.extent_layout.repair'), allocationInspectionAvailable: summary.format === 'sfs', + format: summary.format, tree: [disk], }; } diff --git a/apps/axkdeck/src/lib/httpImportOperations.test.ts b/apps/axkdeck/src/lib/httpImportOperations.test.ts index 3187c523..efb0f1cb 100644 --- a/apps/axkdeck/src/lib/httpImportOperations.test.ts +++ b/apps/axkdeck/src/lib/httpImportOperations.test.ts @@ -2,11 +2,60 @@ import { describe, expect, it } from 'vitest'; import { clientUploadLocation, serverFileLocation } from './storageLocations'; import { + audioImportRequest, sampleBankAssignmentRequest, sampleBankCreationRequest, tx16wDiskSetImportRequest, } from './httpImportOperations'; +describe('audioImportRequest', () => { + it('creates a new volume and imports its audio in one alteration manifest', () => { + const source = serverFileLocation({ rootId: 'workspace', relativePath: 'audio/Tone.wav' }); + + const result = audioImportRequest( + 'image-1', + 8, + { kind: 'CREATE_VOLUME', partitionIndex: 1, volumeName: 'Imported' }, + [ + { + source, + sampleName: 'Tone', + waveformNames: ['Tone Wave'], + rootKey: 60, + fineTuneCents: 0, + keyLow: 0, + keyHigh: 127, + velocityLow: 0, + velocityHigh: 127, + loopMode: 4, + loopStartFrame: 0, + loopLengthFrames: 0, + targetSampleRate: 44_100, + }, + ], + { kind: 'SAMPLES' }, + ); + + expect(result.manifest.inline.operations.map((operation) => operation.type)).toEqual([ + 'insert_volume', + 'insert_waveform', + 'insert_sbnk', + ]); + expect(result.manifest.inline.operations[0]).toEqual({ + id: 'volume-audio-import', + type: 'insert_volume', + partition_index: 1, + volume: { name: 'Imported', waveforms: [], samples: [] }, + }); + expect(result.inputBindings).toEqual([ + { + manifestPath: 'audio/import-0', + input: { fileRef: { rootId: 'workspace', relativePath: 'audio/Tone.wav' } }, + }, + ]); + }); +}); + describe('sampleBankCreationRequest', () => { it('creates one ordered Sample Bank insertion without input bindings', () => { expect( diff --git a/apps/axkdeck/src/lib/httpImportOperations.ts b/apps/axkdeck/src/lib/httpImportOperations.ts index 4d095cc8..f23dffc4 100644 --- a/apps/axkdeck/src/lib/httpImportOperations.ts +++ b/apps/axkdeck/src/lib/httpImportOperations.ts @@ -1,6 +1,7 @@ import type { AudioImportItem, AudioImportGrouping, + AudioImportDestination, AudioImportTarget, SampleBankCreation, SampleBankAssignment, @@ -83,7 +84,7 @@ export class HttpImportOperations { startAudioImport( sessionId: number, - target: AudioImportTarget, + target: AudioImportDestination, items: AudioImportItem[], grouping: AudioImportGrouping, ): Promise { @@ -179,12 +180,20 @@ function request( export function audioImportRequest( imageId: string, expectedRevision: number, - target: AudioImportTarget, + target: AudioImportDestination, items: AudioImportItem[], grouping: AudioImportGrouping, ): ImportAlterationRequest { const operations: Record[] = []; const inputBindings: ImportAlterationRequest['inputBindings'] = []; + if (target.kind === 'CREATE_VOLUME') { + operations.push({ + id: 'volume-audio-import', + type: 'insert_volume', + partition_index: target.partitionIndex, + volume: { name: target.volumeName, waveforms: [], samples: [] }, + }); + } items.forEach((item, index) => { const logicalPath = `audio/import-${index}`; operations.push({ diff --git a/apps/axkdeck/src/lib/httpTransport.test.ts b/apps/axkdeck/src/lib/httpTransport.test.ts index ddfbd1fe..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], @@ -225,6 +229,23 @@ describe('HttpImageTransport', () => { expect(url.searchParams.get('cursor')).toBe('objects-next'); return json({ items: [], totalCount: 2, nextCursor: null }); } + if (url.pathname.endsWith('/images/image-remote') && init?.method !== 'DELETE') { + return json({ + imageId: 'image-remote', + revision: 1, + source: { + kind: 'FILE', + file: { rootId: 'workspace', relativePath: 'images/test.hds' }, + }, + companionSources: [], + floppySet: null, + format: 'sfs', + rootCount: 1, + objectCount: 2, + relationshipCount: 1, + validation: { valid: true, infoCount: 0, warningCount: 0, errorCount: 0 }, + }); + } if (url.pathname.endsWith('/images/image-remote') && init?.method === 'DELETE') { return json({ closed: true }); } @@ -235,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' } }); @@ -269,8 +291,10 @@ describe('HttpImageTransport', () => { items: [{ id: 'volume-1', partitionIndex: 0 }], totalCount: 1, }); + await transport.keepImageAlive(1); await transport.closeImage(1); - expect(requests).toHaveLength(6); + expect(requests).toHaveLength(7); + expect(requests.filter((request) => request.includes('/content'))).toHaveLength(3); }); it('opens an AXK object directory through the explicit directory source contract', async () => { @@ -312,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' }), ); @@ -383,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' }), ); @@ -457,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); @@ -507,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')); @@ -543,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'); @@ -581,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; @@ -703,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'); @@ -851,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')); @@ -959,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); @@ -1305,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 }); @@ -1456,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); @@ -1555,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); @@ -1673,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); @@ -1766,13 +1815,14 @@ 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'); const opened = await transport.openImage(serverFile('images/base.hds')); const job = await transport.startAudioImport( opened.sessionId, - { partitionIndex: 3, volumeName: 'Imported' }, + { kind: 'EXISTING_VOLUME', partitionIndex: 3, volumeName: 'Imported' }, [ { source: mono, @@ -1981,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'); @@ -2038,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'); }); @@ -2088,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( @@ -2193,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'); @@ -2325,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', @@ -2638,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, @@ -2946,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'); @@ -3071,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); @@ -3171,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 178257b2..cf3c44d9 100644 --- a/apps/axkdeck/src/lib/httpTransport.ts +++ b/apps/axkdeck/src/lib/httpTransport.ts @@ -9,6 +9,7 @@ import type { AudioImportGrouping, AudioImportItem, AudioImportCapabilities, + AudioImportDestination, AudioImportTarget, SampleBankCreation, SampleBankAssignment, @@ -59,6 +60,7 @@ import type { Tx16wImportMode, VolumeMutation, VolumeDeletionInspection, + ConnectionMode, } from './transport'; import { clientUploadLocation, @@ -91,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; @@ -110,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(); @@ -170,7 +174,7 @@ export class HttpImageTransport extends HttpImageSessionReads implements ImageTr } startAudioImport( sessionId: number, - target: AudioImportTarget, + target: AudioImportDestination, items: AudioImportItem[], grouping: AudioImportGrouping, ): Promise { 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/nativeMediaDrop.test.ts b/apps/axkdeck/src/lib/nativeMediaDrop.test.ts index 9c79fc78..551705f8 100644 --- a/apps/axkdeck/src/lib/nativeMediaDrop.test.ts +++ b/apps/axkdeck/src/lib/nativeMediaDrop.test.ts @@ -162,4 +162,25 @@ describe('native media drops', () => { ]); expect(mocks.open).not.toHaveBeenCalled(); }); + + it('admits portable packages and A3K archives without reading them eagerly', async () => { + const onDrop = vi.fn(); + + await listenForNativeMediaDrops({ onHover: vi.fn(), onDrop, onError: vi.fn() }); + mocks.dragHandler!({ + payload: { + type: 'drop', + paths: ['/packages/Program.axkprg', '/packages/Archive.A3K'], + position: { x: 15, y: 25 }, + }, + }); + + await vi.waitFor(() => expect(onDrop).toHaveBeenCalledOnce()); + const files = onDrop.mock.calls[0][0] as { name: string; type: string }[]; + expect(files.map((file) => ({ name: file.name, type: file.type }))).toEqual([ + { name: 'Program.axkprg', type: 'application/octet-stream' }, + { name: 'Archive.A3K', type: 'application/octet-stream' }, + ]); + expect(mocks.open).not.toHaveBeenCalled(); + }); }); diff --git a/apps/axkdeck/src/lib/nativeMediaDrop.ts b/apps/axkdeck/src/lib/nativeMediaDrop.ts index 95a30afc..579bdef6 100644 --- a/apps/axkdeck/src/lib/nativeMediaDrop.ts +++ b/apps/axkdeck/src/lib/nativeMediaDrop.ts @@ -4,6 +4,7 @@ import { audioExtensions, audioMediaType } from './audioImport'; import type { ClientUploadSource } from './clientUploadSource'; import { midiExtensions, midiMediaType } from './midiImport'; import { nativeExtension, nativeFileSource } from './nativeFileSource'; +import { packageImportExtensions } from './packageImportMedia'; import { tx16wDiskExtensions, tx16wDiskMediaType } from './tx16wImport'; export interface NativeDropPosition { @@ -21,7 +22,12 @@ interface NativeMediaDropCallbacks { onError: (reason: unknown) => void; } -const supportedExtensions = new Set([...audioExtensions, ...midiExtensions, ...tx16wDiskExtensions]); +const supportedExtensions = new Set([ + ...audioExtensions, + ...midiExtensions, + ...tx16wDiskExtensions, + ...packageImportExtensions, +]); const maximumNativeDropFileBytes = 4 * 1024 * 1024 * 1024; const maximumNativeDropTotalBytes = 8 * 1024 * 1024 * 1024; 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/axkdeck/src/lib/packageImportMedia.ts b/apps/axkdeck/src/lib/packageImportMedia.ts new file mode 100644 index 00000000..19c53ece --- /dev/null +++ b/apps/axkdeck/src/lib/packageImportMedia.ts @@ -0,0 +1,22 @@ +export const packageImportExtensions = [ + 'a3k', + 'axkvol', + 'axkprg', + 'axksbac', + 'axksbnk', + 'axksmpl', + 'axkseq', + 'axkpkg', +] as const; + +const packageImportExtensionSet = new Set(packageImportExtensions); + +export function packageImportUploadKind(filename: string): 'PACKAGE' | 'DISK_IMAGE' | null { + const extension = filename.split('.').pop()?.toLowerCase() ?? ''; + if (!packageImportExtensionSet.has(extension)) return null; + return extension === 'a3k' ? 'DISK_IMAGE' : 'PACKAGE'; +} + +export function packageImportExtensionSetCopy(): Set { + return new Set(packageImportExtensions); +} 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/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/lib/testing/inMemoryTransport.ts b/apps/axkdeck/src/lib/testing/inMemoryTransport.ts index 68174daf..092f1ef7 100644 --- a/apps/axkdeck/src/lib/testing/inMemoryTransport.ts +++ b/apps/axkdeck/src/lib/testing/inMemoryTransport.ts @@ -2,6 +2,7 @@ import type { AudioImportGrouping, AllocationMapReference, AudioImportItem, + AudioImportDestination, AudioImportTarget, AudioSourceInfo, AudioImportCapabilities, @@ -79,6 +80,7 @@ import type { ClientUploadSource } from '../clientUploadSource'; export interface InMemoryImageTransportOptions { storageMode?: ImageTransport['storageMode']; + connectionMode?: ImageTransport['connectionMode']; supportsClientUploads?: boolean; opened: Omit< OpenedImage, @@ -128,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; } @@ -217,6 +221,11 @@ export class InMemoryImageTransport implements ImageTransport { }; } + async keepImageAlive(sessionId: number): Promise { + this.calls.push('keepImageAlive'); + await this.options.operations?.keepImageAlive?.(sessionId); + } + attachCompanions(sessionId: number, selection: CompanionSelection): Promise { return this.invoke('attachCompanions', [sessionId, selection]); } @@ -381,7 +390,7 @@ export class InMemoryImageTransport implements ImageTransport { startAudioImport( sessionId: number, - target: AudioImportTarget, + target: AudioImportDestination, items: AudioImportItem[], grouping: AudioImportGrouping, ): Promise { diff --git a/apps/axkdeck/src/lib/transport.ts b/apps/axkdeck/src/lib/transport.ts index d575247d..bd2db196 100644 --- a/apps/axkdeck/src/lib/transport.ts +++ b/apps/axkdeck/src/lib/transport.ts @@ -48,8 +48,13 @@ export interface OpenedImage { mediaConversionAvailable: boolean; extentLayoutRepairAvailable: boolean; allocationInspectionAvailable: boolean; + format?: string; } +export type AudioImportDestination = + | { kind: 'EXISTING_VOLUME'; partitionIndex: number; volumeName: string } + | { kind: 'CREATE_VOLUME'; partitionIndex: number; volumeName: string }; + export interface AllocationMapReference { imageId: string; revision: number; @@ -457,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; @@ -467,6 +475,7 @@ export interface ImageTransport { renameSandboxEntry(entry: FileRef, name: string): Promise; deleteSandboxEntry(entry: FileRef): Promise; openImage(source: ImageLocation): Promise; + keepImageAlive(sessionId: number): Promise; refreshImage(sessionId: number): Promise; attachCompanions(sessionId: number, selection: CompanionSelection): Promise; contentChildren(sessionId: number, parentId: string, offset: number, limit: number): Promise; @@ -538,7 +547,7 @@ export interface ImageTransport { ): Promise; startAudioImport( sessionId: number, - target: AudioImportTarget, + target: AudioImportDestination, items: AudioImportItem[], grouping: AudioImportGrouping, ): 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' }), ); }); diff --git a/apps/axkdeck/src/main.ts b/apps/axkdeck/src/main.ts index 6f5b5647..f8498544 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 { revealAfterInterfaceScale } from './lib/startupVisibility'; 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 diagnosticsReady = installDiagnostics().finally(() => frontendStartup.markDiagnosticsInstalled()); + const interfaceScalingReady = (async () => { + if (!isDesktop) return; try { interfaceScaling = await createInterfaceScaleController( createTauriInterfaceScaleAdapter(), @@ -27,27 +77,81 @@ async function bootstrap(mountTarget: HTMLElement): Promise { } catch (error) { reportDiagnostic('interface_scale_initialization_failed', { message: String(error) }, 'warn'); } - } - if (isDesktop) { + })().finally(() => frontendStartup.markInterfaceScaleComplete()); + const shellFirstFrame = revealAfterInterfaceScale(interfaceScalingReady, () => + frontendStartup.waitForShellFirstFrame(), + ); + + 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/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/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/contracts/openapi-v1.base.json b/apps/server/contracts/openapi-v1.base.json index 768a306c..c97ec3ac 100644 --- a/apps/server/contracts/openapi-v1.base.json +++ b/apps/server/contracts/openapi-v1.base.json @@ -2586,8 +2586,18 @@ } }, "ImageSessionPackageImportDestination": { - "description": "Either one existing SFS volume shared by every package root or one new SFS volume per volume package, named from its placement hint with optional indexed overrides.", + "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", + "additionalProperties": false, + "required": ["kind", "partitionIndex", "volumeName"], + "properties": { + "kind": {"const": "CREATE_VOLUME"}, + "partitionIndex": {"type": "integer", "minimum": 0, "maximum": 255}, + "volumeName": {"type": "string", "minLength": 1, "maxLength": 16} + } + }, { "type": "object", "additionalProperties": false, diff --git a/apps/server/contracts/openapi-v1.json b/apps/server/contracts/openapi-v1.json index c48c07e8..fbe608ac 100644 --- a/apps/server/contracts/openapi-v1.json +++ b/apps/server/contracts/openapi-v1.json @@ -5478,8 +5478,32 @@ "type": "object" }, "ImageSessionPackageImportDestination": { - "description": "Either one existing SFS volume shared by every package root or one new SFS volume per volume package, named from its placement hint with optional indexed overrides.", + "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, + "properties": { + "kind": { + "const": "CREATE_VOLUME" + }, + "partitionIndex": { + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "volumeName": { + "maxLength": 16, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "partitionIndex", + "volumeName" + ], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -22969,7 +22993,7 @@ "default": { "value": { "destination": { - "kind": "EXISTING_VOLUME", + "kind": "CREATE_VOLUME", "partitionIndex": 0, "volumeName": "x" }, 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/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..525982af 100644 --- a/tools/python/tests/test_release_tools.py +++ b/tools/python/tests/test_release_tools.py @@ -862,12 +862,46 @@ 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 ( + "!define MUI_CUSTOMFUNCTION_GUIINIT axkdeck_webview2_consent" in installer_hook + ) + assert "Function axkdeck_webview2_consent" in installer_hook + assert "Function .onGUIInit" not 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") 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" ] } }