Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions apps/application/src/package_operations_support.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#include "package_operations_internal.hpp"

#include <algorithm>
#include <array>
#include <cctype>
#include <cstdint>
#include <format>
#include <fstream>
Expand Down Expand Up @@ -108,8 +110,13 @@ axk::app::Result<ResolvedPackage> 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<char>(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)
Expand All @@ -122,6 +129,23 @@ axk::app::Result<ResolvedPackage> resolve_package(const PackageInput &input, std

axk::app::Result<axk::PortablePackage> 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<char>(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)
Expand Down
13 changes: 11 additions & 2 deletions apps/application/src/package_plan_store.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#include "package_plan_store.hpp"

#include <algorithm>
#include <cctype>
#include <limits>
#include <optional>
#include <set>
Expand Down Expand Up @@ -160,8 +162,15 @@ axk::app::package_plan_internal::retain_sources(std::span<const PackageInput> 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<char>(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());
Expand Down
17 changes: 17 additions & 0 deletions apps/application/src/session_package_import_plan.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ prepare_session_import(const Json &input, std::span<const axk::PortablePackage>
{package_index, root_index, *partition, {}, volume_name, {}, {}, false});
}
}
} else if (kind == "CREATE_VOLUME") {
const auto volume_name = destination.at("volumeName").get<std::string>();
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<std::size_t, std::string> overrides;
for (const auto &override_value : destination.value("volumeNameOverrides", Json::array())) {
Expand Down
4 changes: 2 additions & 2 deletions apps/application/src/uploads.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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";
}
Expand Down
77 changes: 77 additions & 0 deletions apps/application/tests/package_operations_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>());
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<std::string>()}}, 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;
Expand Down Expand Up @@ -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<bool>());
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<std::string>()}}, 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;
Expand Down
10 changes: 9 additions & 1 deletion apps/application/tests/uploads_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,22 @@ 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,
.media_type = "application/octet-stream",
.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",
Expand Down
69 changes: 65 additions & 4 deletions apps/axkdeck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -217,16 +227,16 @@ 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
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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading