diff --git a/.github/workflows/scrolls.yml b/.github/workflows/scrolls.yml index 06743fe0..ef360b4a 100644 --- a/.github/workflows/scrolls.yml +++ b/.github/workflows/scrolls.yml @@ -34,6 +34,18 @@ jobs: - name: Build and test Scroll UI working-directory: ui run: npm ci && npm test && npm run type-check && npm run build + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: Build and test Minecraft Rust UI + working-directory: ui-minecraft + run: | + cargo install --locked wasm-tools --version 1.262.0 + cargo test --manifest-path guest/Cargo.toml --locked + cargo fmt --manifest-path guest/Cargo.toml --check + ./build.sh guest/Cargo.toml dist --document=src/view.openui + cargo fmt --manifest-path public/guest/Cargo.toml --check + ./build.sh public/guest/Cargo.toml public/dist --document=public/src/view.openui - name: Build scroll tree if: github.event_name == 'pull_request' run: make build-tree diff --git a/scripts/push.sh b/scripts/push.sh index de18cb97..88251471 100755 --- a/scripts/push.sh +++ b/scripts/push.sh @@ -18,6 +18,8 @@ SCROLL_PUSH_CATEGORIES="${SCROLL_PUSH_CATEGORIES:-1}" SCROLL_PUSH_ARTIFACTS="${SCROLL_PUSH_ARTIFACTS:-1}" SCROLL_PUSH_JOBS="${SCROLL_PUSH_JOBS:-1}" SCROLL_PUSH_UI="${SCROLL_PUSH_UI:-1}" +# Optional fixed substring of an artifact's source path, e.g. /minecraft/. +SCROLL_PUSH_SOURCE_FILTER="${SCROLL_PUSH_SOURCE_FILTER:-}" if [[ ! "$SCROLL_PUSH_JOBS" =~ ^[1-9][0-9]*$ ]]; then echo "SCROLL_PUSH_JOBS must be a positive integer (got: $SCROLL_PUSH_JOBS)" >&2 exit 2 @@ -89,6 +91,9 @@ rewrite_registry_reference() { run() { local -a command=("$@") local index + if [[ "${command[2]:-}" != "category" && -n "$SCROLL_PUSH_SOURCE_FILTER" && "${command[3]:-}" != *"$SCROLL_PUSH_SOURCE_FILTER"* ]]; then + return 0 + fi # Add the optional suffix only to artifact tags. Category pushes are metadata # for the stable repository and do not get PR suffixes. @@ -127,9 +132,14 @@ run() { if [[ "$SCROLL_PUSH_UI" = "1" && "${command[1]:-}" == "push" && "${command[2]:-}" != "category" ]]; then local staged_root - staged_root="$(mktemp -d)" + mkdir -p "$repo_root/.builds" + staged_root="$(mktemp -d "$repo_root/.builds/stage-XXXXXX")" staged_dirs+=("$staged_root") - go run ./scripts/stage-scroll-ui "${command[3]}" "$staged_root/artifact" "$repo_root/ui/dist/app.wasm" + local ui_bundle="$repo_root/ui/dist/app.wasm" + case "${command[3]}" in + */minecraft/*) ui_bundle="$repo_root/ui-minecraft/dist/app.wasm" ;; + esac + go run ./scripts/stage-scroll-ui "${command[3]}" "$staged_root/artifact" "$ui_bundle" command[3]="$staged_root/artifact" fi diff --git a/scripts/stage-scroll-ui/main.go b/scripts/stage-scroll-ui/main.go index 39be9165..aab5c1dc 100644 --- a/scripts/stage-scroll-ui/main.go +++ b/scripts/stage-scroll-ui/main.go @@ -371,7 +371,27 @@ func stage(source, destination, bundle string) error { return err } runtimeManifest := runtimeConfigManifest(configManifest) - scroll["ui"] = map[string]any{"private": map[string]any{"path": "private/dist/app.wasm"}} + ui, _ := scroll["ui"].(map[string]any) + if ui == nil { + ui = map[string]any{} + } + uiSource := filepath.Dir(filepath.Dir(bundle)) + commands, _ := scroll["commands"].(map[string]any) + if commands == nil { + commands = map[string]any{} + scroll["commands"] = commands + } + ui["private"] = declareUIBuild(commands, "private", uiSource) + defaultPublic := strings.Contains(filepath.ToSlash(source), "/minecraft/") && ui["public"] == nil + if defaultPublic { + ui["public"] = declareUIBuild(commands, "public", filepath.Join(uiSource, "public")) + } + scroll["ui"] = ui + if ui["public"] != nil { + if err := ensureChunk(scroll, "public-ui", "public"); err != nil { + return err + } + } if err := ensureChunk(scroll, "config-admin-ui", "private"); err != nil { return err } @@ -390,25 +410,20 @@ func stage(source, destination, bundle string) error { } privateDir := filepath.Join(destination, "private") - uiSource := filepath.Dir(filepath.Dir(bundle)) - if err := cp.Copy(uiSource, privateDir, cp.Options{Skip: func(_ os.FileInfo, src, _ string) (bool, error) { - relative, err := filepath.Rel(uiSource, src) - if err != nil { - return false, err - } - topLevel := strings.Split(filepath.ToSlash(relative), "/")[0] - return topLevel == "dist" || topLevel == "node_modules", nil - }}); err != nil { - return fmt.Errorf("copy UI source: %w", err) - } - if err := os.MkdirAll(filepath.Join(privateDir, "dist"), 0755); err != nil { + if err := stageUIProject(uiSource, privateDir, bundle); err != nil { return err } - if err := copyFile(bundle, filepath.Join(privateDir, "dist", "app.wasm")); err != nil { - return err - } - if err := copyFile(strings.TrimSuffix(bundle, ".wasm")+".openui", filepath.Join(privateDir, "dist", "app.openui")); err != nil { - return err + if defaultPublic { + publicDir := filepath.Join(destination, "public") + publicSource := filepath.Join(uiSource, "public") + if err := stageUIProject(publicSource, publicDir, filepath.Join(publicSource, "dist", "app.wasm")); err != nil { + return fmt.Errorf("stage default public UI: %w", err) + } + for _, shared := range []string{"build.sh", "wit"} { + if err := cp.Copy(filepath.Join(uiSource, shared), filepath.Join(publicDir, shared)); err != nil { + return fmt.Errorf("copy public UI build support: %w", err) + } + } } manifestBytes, err := json.MarshalIndent(runtimeManifest, "", " ") if err != nil { @@ -425,6 +440,43 @@ func stage(source, destination, bundle string) error { return ensureConfigFiles(destination, configManifest) } +func declareUIBuild(commands map[string]any, scope, source string) map[string]any { + name := "build_ui_" + scope + image := "node:24-bookworm-slim" + recipe := "npm ci --include=optional && npm run build" + if _, err := os.Stat(filepath.Join(source, "guest", "Cargo.toml")); err == nil { + image = "rust:1.99-slim-bookworm" + recipe = "rustup target add wasm32-unknown-unknown && (command -v wasm-tools >/dev/null || cargo install --locked wasm-tools --version 1.262.0) && sh ./build.sh guest/Cargo.toml dist --document=src/view.openui" + } + commands[name] = map[string]any{"procedures": []any{map[string]any{ + "image": image, "working_dir": "/app/resources/deployment/" + scope, + "mounts": []any{map[string]any{"path": "/app/resources/deployment", "sub_path": "."}}, + "command": []string{"sh", "-ec", recipe}, + }}} + return map[string]any{"buildcommand": name, "wasm": scope + "/dist/app.wasm", "openui": scope + "/dist/app.openui"} +} + +func stageUIProject(uiSource, destination, bundle string) error { + if err := cp.Copy(uiSource, destination, cp.Options{Skip: func(_ os.FileInfo, src, _ string) (bool, error) { + relative, err := filepath.Rel(uiSource, src) + if err != nil { + return false, err + } + topLevel := strings.Split(filepath.ToSlash(relative), "/")[0] + path := filepath.ToSlash(relative) + return topLevel == "dist" || topLevel == "node_modules" || topLevel == "public" || path == "guest/target" || strings.HasPrefix(path, "guest/target/"), nil + }}); err != nil { + return fmt.Errorf("copy UI source: %w", err) + } + if err := os.MkdirAll(filepath.Join(destination, "dist"), 0755); err != nil { + return err + } + if err := copyFile(bundle, filepath.Join(destination, "dist", "app.wasm")); err != nil { + return err + } + return copyFile(strings.TrimSuffix(bundle, ".wasm")+".openui", filepath.Join(destination, "dist", "app.openui")) +} + func ensureChunk(scroll map[string]any, name string, path string) error { rawChunks, exists := scroll["chunks"] if !exists { diff --git a/scripts/stage-scroll-ui/main_test.go b/scripts/stage-scroll-ui/main_test.go index fd39816e..a3dfce90 100644 --- a/scripts/stage-scroll-ui/main_test.go +++ b/scripts/stage-scroll-ui/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -33,6 +34,15 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { t.Fatal(err) } uiSource, bundle := writeTestUISource(t) + for _, name := range []string{"guest/src/lib.rs", "guest/Cargo.toml", "guest/Cargo.lock", "build.sh", "wit/client.wit", "guest/target/build/cache"} { + file := filepath.Join(uiSource, name) + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("fixture"), 0644); err != nil { + t.Fatal(err) + } + } if err := os.WriteFile(filepath.Join(uiSource, "dist", "stale.js"), []byte("stale\n"), 0644); err != nil { t.Fatal(err) } @@ -55,13 +65,37 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if err != nil { t.Fatal(err) } - if string(stagedYAML) == "" || !contains(string(stagedYAML), "path: private/dist/app.wasm") { + if string(stagedYAML) == "" || !contains(string(stagedYAML), "wasm: private/dist/app.wasm") { t.Fatalf("staged yaml = %s", stagedYAML) } + if !strings.Contains(string(stagedYAML), "wasm: public/dist/app.wasm") { + t.Fatal("Minecraft release is missing its default public declaration") + } + for _, name := range []string{"dist/app.wasm", "dist/app.openui", "guest/Cargo.toml", "build.sh", "wit/client.wit"} { + if _, err := os.Stat(filepath.Join(destination, "public", name)); err != nil { + t.Fatal("default public project is incomplete", err) + } + } + if _, err := os.Stat(filepath.Join(destination, "private", "public")); !os.IsNotExist(err) { + t.Fatal("public project was duplicated inside the private project") + } var stagedScroll map[string]any if err := yaml.Unmarshal(stagedYAML, &stagedScroll); err != nil { t.Fatal(err) } + ui := stagedScroll["ui"].(map[string]any) + commands := stagedScroll["commands"].(map[string]any) + for _, scope := range []string{"private", "public"} { + declaration := ui[scope].(map[string]any) + if declaration["openui"] != scope+"/dist/app.openui" || declaration["buildcommand"] != "build_ui_"+scope { + t.Fatalf("incomplete declaration: %#v", declaration) + } + command := commands[declaration["buildcommand"].(string)].(map[string]any) + procedure := command["procedures"].([]any)[0].(map[string]any) + if procedure["image"] != "rust:1.99-slim-bookworm" || !strings.Contains(fmt.Sprint(procedure["command"]), "sh ./build.sh") { + t.Fatalf("Rust build recipe missing: %#v", procedure) + } + } if chunks, exists := stagedScroll["chunks"]; exists { t.Fatalf("automatic data chunking was replaced by explicit chunks: %#v", chunks) } @@ -100,11 +134,114 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if _, err := os.Stat(filepath.Join(destination, "private", "node_modules")); !os.IsNotExist(err) { t.Fatalf("node_modules was packaged: %v", err) } + for _, name := range []string{"guest/src/lib.rs", "guest/Cargo.toml", "guest/Cargo.lock", "build.sh", "wit/client.wit"} { + if _, err := os.Stat(filepath.Join(destination, "private", name)); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(destination, "private", "guest", "target")); !os.IsNotExist(err) { + t.Fatalf("Rust build output was packaged: %v", err) + } if _, err := os.Stat(filepath.Join(destination, "data", "server.properties")); !os.IsNotExist(err) { t.Fatalf("active config was unexpectedly created beside packaged default: %v", err) } } +func TestStagePreservesDeclaredPublicUI(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "scrolls", "minecraft", "vanilla", "1.20.4") + if err := os.MkdirAll(filepath.Join(source, "public", "custom"), 0755); err != nil { + t.Fatal(err) + } + content := "name: fixture\ndesc: fixture\nversion: 1.0.0\napp_version: 1.20.4\ncommands: {}\nchunks:\n - name: data\n path: data\nui:\n public:\n wasm: public/custom/view.wasm\n openui: public/custom/layout.openui\n" + if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + for _, name := range []string{"view.wasm", "layout.openui"} { + if err := os.WriteFile(filepath.Join(source, "public", "custom", name), []byte("public fixture"), 0644); err != nil { + t.Fatal(err) + } + } + _, bundle := writeTestUISource(t) + destination := filepath.Join(root, "staged") + if err := stage(source, destination, bundle); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(destination, "scroll.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "public/custom/view.wasm") || !strings.Contains(string(data), "public-ui") { + t.Fatal("public declaration/chunk lost") + } + for _, name := range []string{"view.wasm", "layout.openui"} { + data, err := os.ReadFile(filepath.Join(destination, "public", "custom", name)) + if err != nil || string(data) != "public fixture" { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(destination, "public", "dist", "app.wasm")); !os.IsNotExist(err) { + t.Fatal("custom public UI was supplemented with the default") + } +} + +func TestStageRequiresBothDefaultPublicArtifacts(t *testing.T) { + for _, missing := range []string{"app.wasm", "app.openui"} { + t.Run(missing, func(t *testing.T) { + uiSource, bundle := writeTestUISource(t) + if err := os.Remove(filepath.Join(uiSource, "public", "dist", missing)); err != nil { + t.Fatal(err) + } + source := filepath.Join(t.TempDir(), "minecraft", "fixture") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: fixture\ncommands: {}\n"), 0644); err != nil { + t.Fatal(err) + } + if err := stage(source, filepath.Join(t.TempDir(), "staged"), bundle); err == nil { + t.Fatal("staged an incomplete default public UI") + } + }) + } +} + +func TestStageRustProjectWithoutNpm(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "scrolls", "minecraft", "minecraft-vanilla", "1.20.4") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: test\ndesc: test\nversion: 0.0.1\napp_version: 1.20.4\ncommands: {}\n"), 0644); err != nil { + t.Fatal(err) + } + uiSource, bundle := writeTestUISource(t) + if err := os.Remove(filepath.Join(uiSource, "package.json")); err != nil { + t.Fatal(err) + } + for _, name := range []string{"build.sh", "guest/Cargo.toml", "guest/Cargo.lock", "wit/client.wit"} { + path := filepath.Join(uiSource, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture"), 0755); err != nil { + t.Fatal(err) + } + } + destination := filepath.Join(root, "staged") + if err := stage(source, destination, bundle); err != nil { + t.Fatal(err) + } + for _, name := range []string{"build.sh", "guest/Cargo.toml", "guest/Cargo.lock", "wit/client.wit", "dist/app.wasm", "dist/app.openui"} { + if _, err := os.Stat(filepath.Join(destination, "private", name)); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(destination, "private", "package.json")); !os.IsNotExist(err) { + t.Fatal("Rust staging must not require or synthesize an npm manifest", err) + } +} + func TestStageAppendsPrivateDataChunkToExistingExplicitChunks(t *testing.T) { root := t.TempDir() source := filepath.Join(root, "scrolls", "lgsm", "pzserver") @@ -366,6 +503,15 @@ func TestEveryCheckedInGameServerScrollStagesAsACompleteUIPackage(t *testing.T) t.Errorf("stage %s: %v", path, err) return nil } + publicWASM := filepath.Join(destination, "public", "dist", "app.wasm") + _, publicErr := os.Stat(publicWASM) + minecraft := strings.Contains(filepath.ToSlash(path), "/minecraft/") + if minecraft && publicErr != nil { + t.Errorf("%s is missing its default public UI: %v", path, publicErr) + } + if !minecraft && !os.IsNotExist(publicErr) { + t.Errorf("%s unexpectedly received a Minecraft public UI", path) + } manifestBytes, err := os.ReadFile(filepath.Join(destination, "data", ".druid", "config-editor.manifest.json")) if err != nil { t.Errorf("manifest %s: %v", path, err) @@ -426,6 +572,15 @@ func writeTestUISource(t *testing.T) (string, string) { if err := os.WriteFile(bundle, []byte("wasm"), 0644); err != nil { t.Fatal(err) } + for _, name := range []string{"build.sh", "wit/client.wit", "public/dist/app.wasm", "public/dist/app.openui", "public/guest/Cargo.toml"} { + file := filepath.Join(uiSource, name) + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("public fixture"), 0644); err != nil { + t.Fatal(err) + } + } return uiSource, bundle } diff --git a/scripts/tests/fixtures/fake-stage-go.sh b/scripts/tests/fixtures/fake-stage-go.sh new file mode 100644 index 00000000..9346baf7 --- /dev/null +++ b/scripts/tests/fixtures/fake-stage-go.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == run && "$2" == ./scripts/stage-scroll-ui ]] +case "$3" in + */minecraft/*) [[ "$5" == */ui-minecraft/dist/app.wasm ]] ;; + *) [[ "$5" == */ui/dist/app.wasm ]] ;; +esac +printf '%s|%s\n' "$3" "$5" >> "$FAKE_DRUID_STATE_DIR/staging.log" +mkdir -p "$4" diff --git a/scripts/tests/push-parallel.test.sh b/scripts/tests/push-parallel.test.sh index db17564a..6f25bfe3 100644 --- a/scripts/tests/push-parallel.test.sh +++ b/scripts/tests/push-parallel.test.sh @@ -50,6 +50,13 @@ artifact_dry_run="$( fail "category metadata must retain the configured registry namespace" expected_categories="$(printf '%s\n' "$category_dry_run" | grep -Ec '^"?druid"? push category ')" expected_artifacts="$(printf '%s\n' "$artifact_dry_run" | grep -Ec '^"?druid"? push ')" +filtered_dry_run="$(SCROLL_PUSH_DRY_RUN=1 SCROLL_PUSH_CATEGORIES=0 SCROLL_PUSH_SOURCE_FILTER=/minecraft/ bash "$push_script")" +assert_equals "$(printf '%s\n' "$artifact_dry_run" | grep -c '/minecraft/')" \ + "$(printf '%s\n' "$filtered_dry_run" | grep -c '/minecraft/')" \ + "source filtering must preserve every Minecraft artifact" +if printf '%s\n' "$filtered_dry_run" | grep -qv '/minecraft/'; then + fail "source filtering must exclude unrelated artifacts" +fi preview_dry_run="$( SCROLL_PUSH_DRY_RUN=1 \ @@ -167,4 +174,19 @@ assert_equals "$failure_starts" "$failure_ends" \ assert_equals "0" "$(<"$tmp_dir/state/active")" \ "push.sh must not leave background push jobs running after failure" +reset_state +mkdir -p "$tmp_dir/bin" +cp "$repo_root/scripts/tests/fixtures/fake-stage-go.sh" "$tmp_dir/bin/go" +chmod +x "$tmp_dir/bin/go" +PATH="$tmp_dir/bin:$PATH" \ +DRUID_BIN="$fake_druid" \ +FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ +FAKE_DRUID_SLEEP=0 \ +SCROLL_PUSH_UI=1 \ +SCROLL_PUSH_CATEGORIES=0 \ +SCROLL_PUSH_ARTIFACTS=1 \ + bash "$push_script" >"$tmp_dir/ui-selection.out" +grep -q '/ui-minecraft/dist/app.wasm' "$tmp_dir/state/staging.log" || fail "Minecraft Rust UI was not selected" +grep -q '/ui/dist/app.wasm' "$tmp_dir/state/staging.log" || fail "non-Minecraft UI selection changed" + echo "push parallelism tests passed ($expected_categories categories, $expected_artifacts artifacts)" diff --git a/scripts/validate_ui_coverage.sh b/scripts/validate_ui_coverage.sh index e08ab070..dfa465ad 100644 --- a/scripts/validate_ui_coverage.sh +++ b/scripts/validate_ui_coverage.sh @@ -6,6 +6,10 @@ cd "$repo_root" test -s ui/dist/app.wasm test -s ui/dist/app.openui +test -s ui-minecraft/dist/app.wasm +test -s ui-minecraft/dist/app.openui +test -s ui-minecraft/public/dist/app.wasm +test -s ui-minecraft/public/dist/app.openui go test ./scripts/stage-scroll-ui echo "Scroll UI bundle and all released family mappings are valid." diff --git a/ui-minecraft/.gitignore b/ui-minecraft/.gitignore new file mode 100644 index 00000000..86e5033c --- /dev/null +++ b/ui-minecraft/.gitignore @@ -0,0 +1,3 @@ +dist/ +guest/target/ +node_modules/ diff --git a/ui-minecraft/README.md b/ui-minecraft/README.md new file mode 100644 index 00000000..632d50bc --- /dev/null +++ b/ui-minecraft/README.md @@ -0,0 +1,57 @@ +# Minecraft dashboard + +The Rust WASM guest owns access/section-tab state and the manifest-driven Minecraft configuration editor. `src/view.openui` composes the dashboard from Druid's restricted component library; connected host components retain endpoint, developer-service, task, statistics and backup operations. + +The SPA keeps the deployment header and mounts this package once below it. A generic 12-column grid gives the access panel 3 columns and the main content 9; both stack below desktop width. The Rust guest switches Dashboard, Configuration and Backups through underline-style section tabs without React routes, retaining editor state between sections. The same navigation bar includes the Documentation and Discord support links; icons and links are document-defined content. The SPA's bundled generic dashboard stays visible until this custom package is ready. Other Scroll families continue using `../ui`. + +## Build and verify + +Use Rust stable and the native Bytecode Alliance component tools. No Node.js, +npm, package registry or JavaScript dependencies are required: + +```sh +rustup target add wasm32-unknown-unknown +cargo install --locked wasm-tools --version 1.262.0 +cargo test --manifest-path guest/Cargo.toml --locked +cargo fmt --manifest-path guest/Cargo.toml --check +./build.sh guest/Cargo.toml dist --document=src/view.openui +./build.sh public/guest/Cargo.toml public/dist --document=public/src/view.openui +# Optional live rebuilds (also Rust-only): +./build.sh guest/Cargo.toml dist --document=src/view.openui --watch +``` + +`build.sh` is the standalone builder from Druid UI's `packages/client/build-rust.sh`. +`wit/` is a checked-in snapshot of that SDK's canonical `packages/client/wit/` +contract; update them together when changing the SDK contract. The builder supplies +its absolute path through `DRUID_WIT_PATH` (which can explicitly select custom WIT). +It compiles with Cargo, wraps the module with `wasm-tools component new`, and +checks the Druid client export before replacing the paired artifacts. +Output is `dist/app.wasm` and `dist/app.openui`; never publish only one file. +Native tests exercise parsing, validation and lossless properties editing. +Browser verification must also execute the compiled guest with host-tool fixtures +and real file-service calls. + +A standard `rust:1.99-slim-bookworm` container works with the target and +`wasm-tools` installed using the commands above; no combined Node/Rust image is needed. + +`../scripts/push.sh` selects this project for Minecraft and stages its source, +build script, WIT contract, Cargo.lock, manifest and artifacts under `private/`. +It excludes build output and Cargo's target directory from source staging. +Run the staging tests and `../scripts/validate_ui_coverage.sh` before publication. + +## Default public page + +`public/` is a separate, minimal Rust guest and OpenUI welcome page. It makes no +host-tool calls and contains no dashboard controls, configuration or private data. +Minecraft staging declares `ui.public` with `buildcommand: build_ui_public`, +`wasm: public/dist/app.wasm` and `openui: public/dist/app.openui` and packages its +source, Cargo.lock, shared Rust builder/WIT and matching artifacts under `public/`. +An explicitly declared public UI is preserved instead of replaced. Other game +families do not receive this template. + +Initialization uploads this public pair alongside the private dashboard. The +deployment remains unlisted: its owner must explicitly use **Publish deployment** +to create the Core catalog entry. Releasing a newer template does not rewrite +existing deployments or their pinned public listings. + +The editor retains unsaved per-file state, masks secrets, validates fields and verifies saved bytes by reloading. Like the existing file-service gateway, saving uses `save_file`; it does not add atomic compare-and-swap protection against concurrent writers. diff --git a/ui-minecraft/build.sh b/ui-minecraft/build.sh new file mode 100755 index 00000000..61ba54bc --- /dev/null +++ b/ui-minecraft/build.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# Standalone Rust guest builder. Requires Cargo and wasm-tools, not Node/npm. +set -eu + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 [--name=app] [--document=view.openui] [--watch]" >&2 + exit 1 +fi +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +manifest=$(CDPATH= cd -- "$(dirname -- "$1")" && pwd)/$(basename -- "$1") +output=$2 +shift 2 +name=app +document= +watch=0 +for option do + case "$option" in + --name=*) name=${option#*=} ;; + --document=*) document=${option#*=} ;; + --watch) watch=1 ;; + *) echo "Unknown option: $option" >&2; exit 1 ;; + esac +done +case "$name" in + ''|*[!a-zA-Z0-9_-]*) echo "Artifact name must be a filename without a path" >&2; exit 1 ;; +esac +command -v cargo >/dev/null 2>&1 || { + echo "Cargo is required; install Rust and rustup target add wasm32-unknown-unknown" >&2 + exit 1 +} +command -v wasm-tools >/dev/null 2>&1 || { + echo "wasm-tools is required; cargo install --locked wasm-tools --version 1.262.0" >&2 + exit 1 +} +wit=$(CDPATH= cd -- "${DRUID_WIT_PATH:-$script_dir/wit}" && pwd) +if [ -n "$document" ]; then + test -s "$document" || { echo "OpenUI document is missing or empty: $document" >&2; exit 1; } + document=$(CDPATH= cd -- "$(dirname -- "$document")" && pwd)/$(basename -- "$document") +fi +mkdir -p -- "$output" +output=$(CDPATH= cd -- "$output" && pwd) +if [ "$watch" = 1 ]; then + fingerprint() { + { + find "$(dirname -- "$manifest")" -type d \( -name target -o -name .git \) -prune -o \ + -type f \( -name '*.rs' -o -name '*.toml' -o -name '*.lock' \) -exec cksum {} + + find "$wit" -type f -name '*.wit' -exec cksum {} + + if [ -n "$document" ]; then cksum "$document"; fi + } | cksum + } + previous= + trap 'exit 0' HUP INT TERM + while :; do + current=$(fingerprint) + if [ "$current" != "$previous" ]; then + previous=$current + if [ -n "$document" ]; then + "$0" "$manifest" "$output" "--name=$name" "--document=$document" || true + else + "$0" "$manifest" "$output" "--name=$name" || true + fi + fi + sleep 1 + done +fi +staging=$(mktemp -d "$output/.build-XXXXXX") +trap 'rm -rf -- "$staging"' EXIT +trap 'exit 1' HUP INT TERM + +# Explicit rustc output avoids assumptions about crate names, workspaces or target-dir. +( + cd -- "$(dirname -- "$manifest")" + DRUID_WIT_PATH="$wit" cargo rustc --manifest-path "$manifest" \ + --target wasm32-unknown-unknown --release --locked --lib --crate-type cdylib \ + -- --emit="link=$staging/core.wasm" +) +wasm-tools component new "$staging/core.wasm" -o "$staging/$name.wasm" +wasm-tools component wit "$staging/$name.wasm" > "$staging/component.wit" +if ! grep -Eq '^[[:space:]]*export druid:openui/client;' "$staging/component.wit"; then + echo "The guest must export druid:openui/client" >&2 + exit 1 +fi +if [ -n "$document" ]; then + cp -- "$document" "$staging/$name.openui" + mv -- "$staging/$name.openui" "$output/$name.openui" +fi +mv -- "$staging/$name.wasm" "$output/$name.wasm" +echo "Built $output/$name.wasm" diff --git a/ui-minecraft/guest/Cargo.lock b/ui-minecraft/guest/Cargo.lock new file mode 100644 index 00000000..67eb4bac --- /dev/null +++ b/ui-minecraft/guest/Cargo.lock @@ -0,0 +1,420 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "druid-minecraft-dashboard" +version = "0.1.0" +dependencies = [ + "regex-lite", + "serde", + "serde_json", + "sha2", + "wit-bindgen", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/ui-minecraft/guest/Cargo.toml b/ui-minecraft/guest/Cargo.toml new file mode 100644 index 00000000..c57600d7 --- /dev/null +++ b/ui-minecraft/guest/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "druid-minecraft-dashboard" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +regex-lite = "0.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wit-bindgen = "=0.63.0" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/ui-minecraft/guest/src/editor.rs b/ui-minecraft/guest/src/editor.rs new file mode 100644 index 00000000..f4b0c7bd --- /dev/null +++ b/ui-minecraft/guest/src/editor.rs @@ -0,0 +1,456 @@ +use crate::{ + manifest::{Field, File, Section}, + properties::{escape, Document}, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const MASK: &str = "••••••••"; +pub fn fingerprint(source: &str) -> String { + format!("{:x}", Sha256::digest(source.as_bytes())) +} +pub fn sensitive(key: &str) -> bool { + let normalized = regex_lite::Regex::new(r"([a-z\d])([A-Z])") + .unwrap() + .replace_all(key, "$1.$2") + .to_lowercase(); + [ + "password", + "passwd", + "apikey", + "api-key", + "api_key", + "api.key", + "privatekey", + "private-key", + "gslt", + "steampass", + ] + .iter() + .any(|word| normalized.contains(word)) + || normalized + .split(|c: char| !c.is_alphanumeric()) + .any(|part| matches!(part, "pass" | "secret" | "token")) +} +fn secret(field: &Field) -> bool { + field.sensitive || field.kind == "secret" || sensitive(&field.key) +} +fn scalar(value: &Value) -> String { + value.as_str().map(str::to_owned).unwrap_or_else(|| { + if value.is_null() { + String::new() + } else { + value.to_string() + } + }) +} +fn placeholder(value: &str) -> bool { + value.starts_with("__DRUID_") + && value.ends_with("__") + && value + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') +} +fn validate(field: &Field, value: Option<&str>) -> Result { + let Some(raw) = value + .map(str::to_owned) + .or_else(|| field.default_value.as_ref().map(scalar)) + else { + return Ok(String::new()); + }; + let text = raw.trim(); + let normalized = match field.kind.as_str() { + "boolean" => match text.to_lowercase().as_str() { + "true" => "true".to_string(), + "false" => "false".to_string(), + _ => return Err("Value must be true or false.".into()), + }, + "integer" | "number" => { + if placeholder(&raw) { + return Ok(String::new()); + } + if field.kind == "integer" + && !regex_lite::Regex::new(r"^[+-]?\d+(?:\.0+)?$") + .unwrap() + .is_match(text) + { + return Err("Value must be an integer.".into()); + } + let number: f64 = text.parse().map_err(|_| "Value must be a finite number.")?; + if !number.is_finite() { + return Err("Value must be a finite number.".into()); + } + if field.kind == "integer" + && (number.fract() != 0.0 || number.abs() > 9007199254740991.0) + { + return Err("Value must be an integer.".into()); + } + if field.min.is_some_and(|min| number < min) + || field.max.is_some_and(|max| number > max) + { + return Err(match (field.min, field.max) { + (Some(min), Some(max)) => { + format!("Value must be between {min} and {max} (inclusive).") + } + (Some(min), _) => format!("Value must be at least {min}."), + (_, Some(max)) => format!("Value must be at most {max}."), + _ => unreachable!(), + }); + } + number.to_string() + } + "enum" if !field.values.contains(&raw) => { + return Err(format!( + "Value must be one of: {}.", + field.values.join(", ") + )) + } + _ => raw, + }; + if let Some(pattern) = &field.pattern { + if !regex_lite::Regex::new(&format!("^(?:{pattern})$")) + .map_err(|_| "Invalid field pattern.")? + .is_match(&normalized) + { + return Err("Value does not match the required format.".into()); + } + } + Ok(normalized) +} + +#[derive(Clone)] +pub struct Editor { + pub schema: File, + pub original: String, + pub document: Document, + pub resolved_path: String, + displays: BTreeMap, + errors: BTreeMap, +} + +impl Editor { + pub fn new(schema: File, source: String, resolved_path: String) -> Self { + let ini = schema.format == "ini"; + Self { + schema, + original: source.clone(), + document: Document::parse_format(source, ini), + resolved_path, + displays: BTreeMap::new(), + errors: BTreeMap::new(), + } + } + fn sections(&self) -> Vec
{ + let mut sections = self.schema.sections.clone(); + if self.schema.format == "raw" { + return sections; + } + let mut seen: BTreeSet = sections + .iter() + .flat_map(|s| s.fields.iter().map(|f| f.key.clone())) + .collect(); + let mut fields = Vec::new(); + for entry in &self.document.entries { + if !seen.insert(entry.key.clone()) { + continue; + } + let secret = sensitive(&entry.key); + let label = entry + .key + .split(['.', '/']) + .next_back() + .unwrap_or(&entry.key) + .replace(['-', '_'], " "); + let label = regex_lite::Regex::new(r"([a-z\d])([A-Z])") + .unwrap() + .replace_all(&label, "$1 $2") + .to_string(); + let mut chars = label.chars(); + let label = chars + .next() + .map(|c| c.to_uppercase().to_string() + chars.as_str()) + .unwrap_or_default(); + let value = self.document.get(&entry.key).unwrap_or_default(); + fields.push(Field { + key: entry.key.clone(), + label, + description: format!("Setting discovered in {}.", self.schema.label), + kind: if secret { + "secret" + } else if ["true", "false"].contains(&value.to_lowercase().as_str()) { + "boolean" + } else { + "string" + } + .into(), + sensitive: secret, + restart_required: true, + ..Field::default() + }); + } + if !fields.is_empty() { + sections.push(Section { + id: "detected-settings".into(), + label: "Detected settings".into(), + description: "Settings discovered from the loaded configuration file.".into(), + fields, + }); + } + sections + } + pub fn has_fields(&self) -> bool { + self.sections() + .iter() + .any(|section| !section.fields.is_empty()) + } + pub fn set_field(&mut self, key: &str, input: &str) -> Result<(), String> { + let field = self + .sections() + .into_iter() + .flat_map(|s| s.fields) + .find(|f| f.key == key) + .ok_or("Unknown configuration field.")?; + if secret(&field) && input == MASK { + self.errors.remove(key); + return Ok(()); + } + self.displays.insert(key.into(), input.into()); + if input.trim().is_empty() + && self.document.get(key).is_none() + && matches!(field.kind.as_str(), "integer" | "number") + { + self.errors.remove(key); + return Ok(()); + } + match validate(&field, Some(input)) { + Ok(value) => { + self.document.set(key, &value); + self.errors.remove(key); + if secret(&field) { + self.displays.remove(key); + } + } + Err(message) => { + self.errors.insert(key.into(), message); + } + } + Ok(()) + } + fn secret_keys(&self) -> BTreeSet { + self.sections() + .iter() + .flat_map(|s| s.fields.iter().filter(|f| secret(f)).map(|f| f.key.clone())) + .collect() + } + pub fn display_source(&self) -> String { + let keys = self.secret_keys(); + self.document + .replace_tokens( + self.document + .entries + .iter() + .filter(|e| keys.contains(&e.key)) + .map(|e| (e.span.clone(), escape(MASK, false))) + .collect(), + ) + .source + } + pub fn set_raw(&mut self, source: String) -> Result<(), String> { + let document = Document::parse_format(source, self.document.ini); + let keys = self.secret_keys(); + let mut replacements = Vec::new(); + for key in &keys { + let incoming: Vec<_> = document.entries.iter().filter(|e| &e.key == key).collect(); + if !incoming.iter().any(|e| e.value == MASK) { + continue; + } + let originals: Vec<_> = self + .document + .entries + .iter() + .filter(|e| &e.key == key) + .collect(); + if incoming.len() != originals.len() { + return Err(format!( + "Secret occurrences for \"{key}\" cannot be added or removed in raw mode." + )); + } + for (entry, original) in incoming.into_iter().zip(originals) { + if entry.value == MASK { + replacements.push(( + entry.span.clone(), + self.document.source[original.span.clone()].to_string(), + )); + } + } + } + replacements.sort_by_key(|(span, _)| span.start); + self.document = document.replace_tokens(replacements); + self.displays.clear(); + self.errors.clear(); + Ok(()) + } + pub fn accept_saved(&mut self, source: String) { + self.original = source.clone(); + self.document = Document::parse_format(source, self.document.ini); + self.displays.clear(); + self.errors.clear(); + self.resolved_path = self.schema.path.clone(); + } + pub fn errors(&self) -> Vec { + let mut issues: Vec<_> = self.errors.values().cloned().collect(); + for field in self.sections().iter().flat_map(|s| &s.fields) { + if self.errors.contains_key(&field.key) { + continue; + } + if let Err(error) = validate(field, self.document.get(&field.key)) { + issues.push(error); + } + } + issues + } + pub fn view(&self) -> Value { + let sections: Vec<_> = self.sections().into_iter().map(|section| { + let fields: Vec<_> = section.fields.iter().map(|field| { + let result = validate(field, self.document.get(&field.key)); + let value = self.displays.get(&field.key).cloned().unwrap_or_else(|| result.clone().unwrap_or_else(|_| self.document.get(&field.key).unwrap_or_default().into())); + let display = if secret(field) && !value.is_empty() { MASK.to_string() } else { value }; + let errors: Vec<_> = self.errors.get(&field.key).cloned().or_else(|| result.err()).into_iter().collect(); + json!({ + "key":field.key,"label":field.label,"description":field.description, + "type":if secret(field) { "secret" } else { &field.kind }, + "value":display,"checked":display == "true","errors":errors, + "min":field.min,"max":field.max,"step":if field.kind == "integer" { json!(1) } else { json!("any") }, + "restartRequired":field.restart_required, + "options":field.values.iter().map(|value|json!({"value":value,"label":value})).collect::>() + }) + }).collect(); + json!({"id":section.id,"label":section.label,"description":section.description,"fields":fields}) + }).collect(); + let dirty = self.document.source != self.original; + let original = Document::parse_format(self.original.clone(), self.document.ini); + let fields: Vec<_> = self + .sections() + .into_iter() + .flat_map(|section| section.fields) + .collect(); + let mut structured = original.clone(); + let mut restart_required = false; + for field in fields { + let current = self.document.get(&field.key); + if current != original.get(&field.key) { + restart_required |= field.restart_required; + if let Some(value) = current { + structured.set(&field.key, value); + } + } + } + restart_required |= dirty && structured.source != self.document.source; + json!({ + "schema":self.schema,"source":self.display_source(),"sections":sections, + "validation":self.errors().join("\n"),"dirty":dirty, + "snapshot":{"dirty":dirty,"restartRequired":restart_required} + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn schema() -> File { + serde_json::from_value(json!({"path":"server.properties","format":"java-properties","label":"server.properties","sections":[{"id":"general","label":"General","fields":[ + {"key":"max-players","label":"Max players","type":"integer","min":1,"max":1000}, + {"key":"rcon.password","label":"Password","type":"secret"}, + {"key":"online-mode","label":"Online mode","type":"boolean"}, + {"key":"difficulty","label":"Difficulty","type":"enum","values":["easy","hard"]} + ]}]})).unwrap() + } + fn editor(source: &str) -> Editor { + Editor::new(schema(), source.into(), "server.properties".into()) + } + #[test] + fn preserves_invalid_input_and_validates_raw() { + let mut e = editor("# keep\nmax-players=20\ncustom=true\n"); + e.set_field("max-players", "not a number").unwrap(); + assert_eq!(e.document.source, e.original); + assert!(!e.errors().is_empty()); + e.set_field("max-players", "30").unwrap(); + assert!(e.errors().is_empty()); + assert!(e.document.source.contains("max-players=30")); + e.set_raw("max-players=0\n".into()).unwrap(); + assert!(!e.errors().is_empty()); + } + #[test] + fn masks_and_restores_duplicate_secret_tokens() { + let source = "# comment\r\nrcon.password=one\\u0020secret\r\nrcon.password=two\\\\secret\r\nmax-players=20\r\n"; + let mut e = editor(source); + let display = e.display_source(); + assert!(!display.contains("secret")); + e.set_raw(display.replace("20", "25")).unwrap(); + assert_eq!( + e.document.source, + source.replace("max-players=20", "max-players=25") + ); + assert!(!e.view().to_string().contains("two")); + e.set_field("rcon.password", MASK).unwrap(); + assert!(e.document.source.contains("two\\\\secret")); + assert!(e.set_raw("rcon.password=••••••••\n".into()).is_err()); + } + #[test] + fn detects_unknown_settings_and_secrets() { + let mut e = editor("max-players=20\ncustom-setting=yes\napi-token=keep\nnew-bool=true\n"); + assert!(e.view().to_string().contains("Custom setting")); + assert!(!e.view().to_string().contains("\"keep\"")); + e.set_field("custom-setting", "hello").unwrap(); + assert!(e.document.source.contains("custom-setting=hello")); + assert!(sensitive("apiToken")); + assert!(!sensitive("tokenizer")); + assert!(!sensitive("secretary")); + } + #[test] + fn cuberite_ini_keeps_form_edits_and_raw_secrets_lossless() { + let file: File = serde_json::from_value( + json!({"path":"settings.ini","format":"ini","label":"settings.ini","sections":[]}), + ) + .unwrap(); + let source = "; keep\r\n[Server]\r\nMaxPlayers=20\r\nPassword='fixture' ; secret\r\n"; + let mut e = Editor::new(file, source.into(), "settings.ini".into()); + assert!(e.has_fields()); + assert!(!e.display_source().contains("fixture")); + e.set_field("Server.MaxPlayers", "21").unwrap(); + e.set_raw(e.display_source()).unwrap(); + assert_eq!(e.document.source, source.replace("=20", "=21")); + } + #[test] + fn restart_feedback_tracks_fields_and_unstructured_changes() { + let mut e = editor("max-players=20\n"); + e.set_field("max-players", "21").unwrap(); + assert_eq!(e.view()["snapshot"]["restartRequired"], false); + e.set_raw("# added comment\nmax-players=21\n".into()) + .unwrap(); + assert_eq!(e.view()["snapshot"]["restartRequired"], true); + e.accept_saved(e.document.source.clone()); + assert_eq!(e.view()["snapshot"]["restartRequired"], false); + } + #[test] + fn empty_numeric_and_runtime_placeholders_stay_absent() { + let mut e = editor("motd=test\n"); + e.set_field("max-players", "").unwrap(); + assert_eq!(e.document.source, e.original); + let e = editor("max-players=__DRUID_PLAYER_LIMIT__\n"); + assert!(e.errors().is_empty()); + } + #[test] + fn validates_scalar_types_enums_and_hashes() { + let mut e = editor("max-players=20\nonline-mode=true\ndifficulty=easy\n"); + e.set_field("online-mode", "false").unwrap(); + e.set_field("difficulty", "wrong").unwrap(); + assert_eq!(e.document.get("online-mode"), Some("false")); + assert!(!e.errors().is_empty()); + assert_eq!( + fingerprint("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } +} diff --git a/ui-minecraft/guest/src/ini.rs b/ui-minecraft/guest/src/ini.rs new file mode 100644 index 00000000..ee8d037b --- /dev/null +++ b/ui-minecraft/guest/src/ini.rs @@ -0,0 +1,125 @@ +//! Cuberite's native INI files retain section names, quoting, comments and byte spans. +use crate::properties::{Document, Entry}; + +pub fn parse(source: String) -> Document { + let mut entries = Vec::new(); + let mut section = String::new(); + let mut offset = 0; + for line in source.split_inclusive(['\r', '\n']) { + let raw = line.trim_end_matches(['\r', '\n']); + let trimmed = raw.trim_start(); + if trimmed.starts_with('[') { + if let Some(end) = trimmed.find(']') { + section = trimmed[1..end].trim().into(); + } + } else if !trimmed.is_empty() + && !trimmed.starts_with([';', '#']) + && !trimmed.starts_with("//") + { + let start = raw.len() - trimmed.len(); + let key_end = trimmed + .find(|c: char| c == '=' || c == ':' || c.is_ascii_whitespace()) + .unwrap_or(trimmed.len()) + + start; + let key = &raw[start..key_end]; + let mut value_start = key_end; + while raw + .as_bytes() + .get(value_start) + .is_some_and(|c| c.is_ascii_whitespace()) + { + value_start += 1; + } + if raw + .as_bytes() + .get(value_start) + .is_some_and(|c| *c == b'=' || *c == b':') + { + value_start += 1; + } + while raw + .as_bytes() + .get(value_start) + .is_some_and(|c| c.is_ascii_whitespace()) + { + value_start += 1; + } + let value = &raw[value_start..]; + let mut quote = None; + let mut comment = value.len(); + let mut previous = None; + for (i, c) in value.char_indices() { + if let Some(q) = quote { + if c == q && previous != Some('\\') { + quote = None; + } + } else if c == '"' || c == '\'' { + quote = Some(c); + } else if previous.is_some_and(char::is_whitespace) + && (c == ';' || c == '#' || value[i..].starts_with("//")) + { + comment = i; + break; + } + previous = Some(c); + } + let mut value_end = value_start + value[..comment].trim_end().len(); + let first = raw[value_start..value_end].chars().next(); + let quoted = first.filter(|c| { + matches!(c, '"' | '\'') + && value_end > value_start + 1 + && raw[..value_end].ends_with(*c) + }); + if quoted.is_some() { + value_start += 1; + value_end -= 1; + } + let mut decoded = raw[value_start..value_end].to_owned(); + if quoted == Some('"') { + decoded = decoded.replace("\\\"", "\"").replace("\\\\", "\\"); + } + if quoted == Some('\'') { + decoded = decoded.replace("'\\''", "'"); + } + if !key.is_empty() { + entries.push(Entry { + key: if section.is_empty() { + key.into() + } else { + format!("{section}.{key}") + }, + value: decoded, + span: offset + value_start..offset + value_end, + quote: quoted, + }); + } + } + offset += line.len(); + } + Document { + source, + entries, + ini: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preserves_cuberite_sections_quotes_comments_and_duplicates() { + let source = "; comment\r\n[Server]\r\nMaxPlayers=20 ; keep\r\nDescription=\"Druid\"\r\n[WebAdmin]\r\nPassword='secret'\r\nPassword='active'\r\n"; + let mut doc = parse(source.into()); + assert_eq!(doc.get("Server.MaxPlayers"), Some("20")); + assert_eq!(doc.get("WebAdmin.Password"), Some("active")); + doc.set("Server.MaxPlayers", "21"); + doc.set("Server.Description", "My \"server\""); + assert_eq!( + doc.source, + source + .replace("=20", "=21") + .replace("\"Druid\"", "\"My \\\"server\\\"\"") + ); + assert_eq!(doc.get("Server.Description"), Some("My \"server\"")); + } +} diff --git a/ui-minecraft/guest/src/lib.rs b/ui-minecraft/guest/src/lib.rs new file mode 100644 index 00000000..bee74e3c --- /dev/null +++ b/ui-minecraft/guest/src/lib.rs @@ -0,0 +1,206 @@ +pub mod editor; +pub mod ini; +pub mod manifest; +pub mod properties; +#[cfg(target_arch = "wasm32")] +mod tools; + +#[cfg(target_arch = "wasm32")] +wit_bindgen::generate!({ + path: env!("DRUID_WIT_PATH"), + world: "openui-client", +}); + +#[cfg(target_arch = "wasm32")] +mod runtime { + use super::druid::openui::state; + use super::{ + editor::Editor, + manifest::{self, Manifest}, + tools, + }; + use serde_json::{json, Value}; + use std::{cell::RefCell, collections::BTreeMap}; + + #[derive(Default)] + struct Dashboard { + manifest: Option, + editors: BTreeMap, + selected: String, + mode: String, + busy: bool, + status: String, + error: Option, + } + thread_local! { static DASHBOARD: RefCell = RefCell::new(Dashboard::default()); } + fn render() { + let view = DASHBOARD.with_borrow(|dashboard| { + let editor = dashboard.editors.get(&dashboard.selected); + let mut view = editor.map(Editor::view).unwrap_or_else(||json!({})); + view["manifest"] = json!(dashboard.manifest.as_ref().map(|m| &m.server).map(|server| json!({"server":server})).unwrap_or(Value::Null)); + view["selectedPath"] = json!(dashboard.selected); + view["mode"] = json!(dashboard.mode); + view["saving"] = json!(dashboard.busy); + view["ready"] = json!(editor.is_some()); + view["status"] = json!(dashboard.status); + view["error"] = json!(dashboard.error); + view["files"] = json!(dashboard.manifest.as_ref().map(|m| m.files.iter().map(|f|json!({"value":f.path,"label":f.label})).collect::>()).unwrap_or_default()); + view["tabs"] = json!([{"value":"form","label":"Form","disabled":editor.is_none_or(|e| !e.has_fields())},{"value":"raw","label":"Raw"}]); + view["canSave"] = json!(editor.is_some_and(|e| e.document.source != e.original && e.errors().is_empty()) && !dashboard.busy); + view["copy"] = json!({ + "loading":"Loading configuration…","formTab":"Form","rawTab":"Raw","modeHeading":"Editor mode", + "filesHeading":"Configuration files","restartRequired":"Restart required","restartAfterSave":"Restart required after saving", + "save":"Save changes","saving":"Saving…","rawLabel":"Raw configuration" + }); + view + }); + state::set_value("$editor", &view.to_string()); + } + async fn select(path: &str) -> Result<(), String> { + let file = DASHBOARD.with_borrow(|s| { + s.manifest + .as_ref() + .and_then(|m| m.files.iter().find(|f| f.path == path)) + .cloned() + .ok_or("Manifest has no selected file.".to_string()) + })?; + let exists = DASHBOARD.with_borrow(|s| s.editors.contains_key(path)); + if !exists { + let editor = tools::load(file).await?; + DASHBOARD.with_borrow_mut(|s| { + s.editors.insert(path.to_owned(), editor); + }); + } + DASHBOARD.with_borrow_mut(|s| { + s.selected = path.to_owned(); + s.mode = if !s.editors[path].has_fields() { + "raw" + } else { + "form" + } + .into(); + }); + Ok(()) + } + pub async fn init() { + DASHBOARD.with_borrow_mut(|s| *s = Dashboard::default()); + state::set_value("$access", "\"services\""); + state::set_value("$section", "\"dashboard\""); + render(); + if let Err(error) = initialize().await { + DASHBOARD.with_borrow_mut(|s| s.error = Some(error)); + } + render(); + } + async fn initialize() -> Result<(), String> { + tools::call("ensure_file_service", json!({"watch":false})).await?; + let manifest = manifest::parse(&tools::text(".druid/config-editor.manifest.json").await?)?; + let path = manifest.files[0].path.clone(); + DASHBOARD.with_borrow_mut(|s| s.manifest = Some(manifest)); + select(&path).await + } + pub async fn action(action: String, params: String) { + let params: Value = match serde_json::from_str(¶ms) { + Ok(value) => value, + Err(_) => return, + }; + if action == "SetAccessTab" || action == "SetSectionTab" { + let (key, allowed): (&str, &[&str]) = if action == "SetAccessTab" { + ("$access", &["services", "developer-tools"]) + } else { + ("$section", &["dashboard", "configuration", "backups"]) + }; + if params["value"] + .as_str() + .is_some_and(|v| allowed.contains(&v)) + { + state::set_value(key, ¶ms["value"].to_string()); + } + return; + } + let accepted = DASHBOARD.with_borrow_mut(|s| { + if s.busy || s.editors.is_empty() { + return false; + } + s.busy = true; + s.status.clear(); + true + }); + if !accepted { + return; + } + render(); + let result = perform(&action, ¶ms).await; + DASHBOARD.with_borrow_mut(|s| { + s.busy = false; + if let Err(error) = result { + s.status = error; + } + }); + render(); + } + async fn perform(action: &str, params: &Value) -> Result<(), String> { + let text = |key: &str| { + params[key] + .as_str() + .ok_or_else(|| format!("Missing string parameter: {key}")) + }; + match action { + "SelectFile" => select(text("value")?).await?, + "SetMode" => { + let mode = text("value")?; + if mode != "form" && mode != "raw" { + return Err("Invalid editor mode.".into()); + } + DASHBOARD.with_borrow_mut(|s| s.mode = mode.into()); + } + "SetField" | "SetRaw" => { + DASHBOARD.with_borrow_mut(|s| { + let selected = s.selected.clone(); + let editor = s + .editors + .get_mut(&selected) + .ok_or("No configuration file loaded.")?; + if action == "SetRaw" { + editor.set_raw(text("value")?.to_owned()) + } else { + let input = match ¶ms["value"] { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => return Err("Missing configuration field value.".into()), + }; + editor.set_field(text("key")?, &input) + } + })?; + } + "Save" => { + let (path, editor) = + DASHBOARD.with_borrow(|s| (s.selected.clone(), s.editors[&s.selected].clone())); + DASHBOARD.with_borrow_mut(|s| s.status = "Saving…".into()); + render(); + let remote = tools::save(&editor).await?; + DASHBOARD.with_borrow_mut(|s| { + s.editors.get_mut(&path).unwrap().accept_saved(remote); + s.status = "Saved and verified".into(); + }); + } + _ => {} + } + Ok(()) + } +} + +#[cfg(target_arch = "wasm32")] +struct MinecraftDashboard; +#[cfg(target_arch = "wasm32")] +impl exports::druid::openui::client::Guest for MinecraftDashboard { + async fn init() { + runtime::init().await; + } + async fn handle_action(action: String, params: String) { + runtime::action(action, params).await; + } +} +#[cfg(target_arch = "wasm32")] +export!(MinecraftDashboard); diff --git a/ui-minecraft/guest/src/manifest.rs b/ui-minecraft/guest/src/manifest.rs new file mode 100644 index 00000000..784f5537 --- /dev/null +++ b/ui-minecraft/guest/src/manifest.rs @@ -0,0 +1,200 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Manifest { + pub version: u32, + pub server: Server, + pub files: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Server { + pub family: String, + pub display_name: String, + #[serde(default)] + pub app_version: String, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct File { + pub path: String, + pub format: String, + pub label: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub documentation: String, + pub sections: Vec
, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Section { + pub id: String, + pub label: String, + #[serde(default)] + pub description: String, + pub fields: Vec, +} +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Field { + pub key: String, + pub label: String, + #[serde(default)] + pub description: String, + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub documentation: String, + #[serde(default)] + pub values: Vec, + pub min: Option, + pub max: Option, + pub pattern: Option, + #[serde(default)] + pub sensitive: bool, + #[serde(default)] + pub restart_required: bool, + pub default_value: Option, + pub since: Option, + pub until: Option, +} +pub fn safe_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.contains(['\\', ':', '\0']) + && path + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") +} +fn version(input: &str) -> Result, String> { + input + .split('.') + .map(|part| { + part.parse() + .map_err(|_| "Invalid manifest version range.".into()) + }) + .collect() +} +fn compare(left: &str, right: &str) -> Result { + let (mut l, mut r) = (version(left)?, version(right)?); + let len = l.len().max(r.len()); + l.resize(len, 0); + r.resize(len, 0); + Ok(l.cmp(&r)) +} +pub fn parse(source: &str) -> Result { + let mut manifest: Manifest = + serde_json::from_str(source).map_err(|_| "Invalid configuration manifest.".to_string())?; + if manifest.version != 1 + || !matches!( + manifest.server.family.as_str(), + "minecraft" | "minecraft-cuberite" + ) + || manifest.server.display_name.trim().is_empty() + || manifest.files.is_empty() + { + return Err("Unsupported Minecraft configuration manifest.".into()); + } + let mut paths = BTreeSet::new(); + for file in &mut manifest.files { + if !safe_path(&file.path) + || !paths.insert(file.path.clone()) + || file.label.trim().is_empty() + || !matches!(file.format.as_str(), "java-properties" | "ini" | "raw") + { + return Err("Invalid or duplicate configuration file.".into()); + } + let mut ids = BTreeSet::new(); + let mut keys = BTreeSet::new(); + for section in &mut file.sections { + if section.id.is_empty() || section.label.is_empty() || !ids.insert(section.id.clone()) + { + return Err("Invalid or duplicate section.".into()); + } + for field in §ion.fields { + if field.key.is_empty() + || field.label.is_empty() + || !keys.insert(field.key.clone()) + || !matches!( + field.kind.as_str(), + "string" | "secret" | "integer" | "number" | "boolean" | "enum" + ) + || matches!((field.min, field.max), (Some(min), Some(max)) if min > max) + || (field.kind == "enum" && field.values.is_empty()) + || field + .default_value + .as_ref() + .is_some_and(|v| v.is_array() || v.is_object()) + { + return Err("Invalid or duplicate configuration field.".into()); + } + if let Some(pattern) = &field.pattern { + regex_lite::Regex::new(&format!("^(?:{pattern})$")) + .map_err(|_| "Invalid field pattern.")?; + } + if let Some(since) = &field.since { + version(since)?; + } + if let Some(until) = &field.until { + version(until)?; + } + if let (Some(since), Some(until)) = (&field.since, &field.until) { + if compare(since, until)?.is_gt() { + return Err("Invalid field version range.".into()); + } + } + } + let mut filtered = Vec::new(); + for field in section.fields.drain(..) { + let include = (field.since.is_none() + || compare(&manifest.server.app_version, field.since.as_ref().unwrap())? + .is_ge()) + && (field.until.is_none() + || compare(&manifest.server.app_version, field.until.as_ref().unwrap())? + .is_le()); + if include { + filtered.push(field); + } + } + section.fields = filtered; + } + file.sections.retain(|section| !section.fields.is_empty()); + } + Ok(manifest) +} +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + pub fn source() -> Value { + json!({"version":1,"server":{"family":"minecraft","displayName":"Minecraft Server","appVersion":"1.21.7"},"files":[{"path":"server.properties","format":"java-properties","label":"server.properties","sections":[{"id":"players","label":"Players","fields":[{"key":"max-players","label":"Max players","type":"integer","min":1,"max":1000}]}]}]}) + } + #[test] + fn validates_paths_and_duplicates() { + assert!(parse(&source().to_string()).is_ok()); + for path in [ + "../secret", + "/absolute", + "C:/file", + "data/../secret", + "a\\b", + ] { + let mut value = source(); + value["files"][0]["path"] = json!(path); + assert!(parse(&value.to_string()).is_err()); + } + let mut value = source(); + let duplicate = value["files"][0].clone(); + value["files"].as_array_mut().unwrap().push(duplicate); + assert!(parse(&value.to_string()).is_err()); + } + #[test] + fn filters_versions() { + let mut value = source(); + value["files"][0]["sections"][0]["fields"][0]["until"] = json!("1.20.4"); + assert!(parse(&value.to_string()).unwrap().files[0] + .sections + .is_empty()); + } +} diff --git a/ui-minecraft/guest/src/properties.rs b/ui-minecraft/guest/src/properties.rs new file mode 100644 index 00000000..263f81a4 --- /dev/null +++ b/ui-minecraft/guest/src/properties.rs @@ -0,0 +1,305 @@ +use std::ops::Range; + +#[derive(Clone, Debug)] +pub struct Entry { + pub key: String, + pub value: String, + pub span: Range, + pub quote: Option, +} +#[derive(Clone, Debug)] +pub struct Document { + pub source: String, + pub entries: Vec, + pub ini: bool, +} + +fn whitespace(c: char) -> bool { + matches!(c, ' ' | '\t' | '\x0c') +} + +fn decode(input: &str) -> String { + let mut chars = input.chars().peekable(); + let mut units = Vec::::new(); + while let Some(c) = chars.next() { + let decoded = if c != '\\' { + c + } else { + match chars.next() { + Some('t') => '\t', + Some('n') => '\n', + Some('r') => '\r', + Some('f') => '\x0c', + Some('u') => { + while chars.peek() == Some(&'u') { + chars.next(); + } + let mut probe = chars.clone(); + let hex: String = probe.by_ref().take(4).collect(); + if hex.len() == 4 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + units.push(u16::from_str_radix(&hex, 16).unwrap()); + chars = probe; + continue; + } + 'u' + } + Some(other) => other, + None => '\\', + } + }; + let mut buf = [0; 2]; + units.extend_from_slice(decoded.encode_utf16(&mut buf)); + } + String::from_utf16_lossy(&units) +} + +pub fn escape(value: &str, key: bool) -> String { + let mut result = String::new(); + let mut leading = true; + for c in value.chars() { + match c { + '\\' => result.push_str("\\\\"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\x0c' => result.push_str("\\f"), + ' ' if key || leading => result.push_str("\\ "), + '=' | ':' | '#' | '!' if key => { + result.push('\\'); + result.push(c); + } + _ => result.push(c), + } + if c != ' ' { + leading = false; + } + } + result +} + +impl Document { + pub fn parse(source: String) -> Self { + // Physical spans retain original UTF-8 byte positions, including mixed line endings. + let mut lines = Vec::new(); + let bytes = source.as_bytes(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\r' || bytes[i] == b'\n' { + let end = i; + if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') { + i += 1; + } + i += 1; + lines.push((start, end)); + start = i; + } else { + i += 1; + } + } + if start < source.len() { + lines.push((start, source.len())); + } + let mut entries = Vec::new(); + let mut index = 0; + while index < lines.len() { + let (start, stop) = lines[index]; + // Comments are physical lines, even when their text ends with a backslash. + if source[start..stop] + .trim_start_matches(whitespace) + .starts_with(['#', '!']) + { + index += 1; + continue; + } + let mut logical = Vec::<(char, usize)>::new(); + let mut continued = false; + let end; + loop { + let (start, stop) = lines[index]; + let raw = &source[start..stop]; + let trim = if continued { + raw.len() - raw.trim_start_matches(whitespace).len() + } else { + 0 + }; + let content = &raw[trim..]; + let more = content.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1; + let kept = if more { + &content[..content.len() - 1] + } else { + content + }; + logical.extend( + kept.char_indices() + .map(|(offset, c)| (c, start + trim + offset)), + ); + index += 1; + if !more || index == lines.len() { + end = stop; + break; + } + continued = true; + } + let mut pos = 0; + while pos < logical.len() && whitespace(logical[pos].0) { + pos += 1; + } + if pos == logical.len() || matches!(logical[pos].0, '#' | '!') { + continue; + } + let key_start = pos; + let mut escaped = false; + while pos < logical.len() { + let c = logical[pos].0; + if !escaped && (whitespace(c) || matches!(c, '=' | ':')) { + break; + } + escaped = !escaped && c == '\\'; + pos += 1; + } + let key: String = logical[key_start..pos].iter().map(|(c, _)| c).collect(); + while pos < logical.len() && whitespace(logical[pos].0) { + pos += 1; + } + if pos < logical.len() && matches!(logical[pos].0, '=' | ':') { + pos += 1; + } + while pos < logical.len() && whitespace(logical[pos].0) { + pos += 1; + } + let value: String = logical[pos..].iter().map(|(c, _)| c).collect(); + entries.push(Entry { + key: decode(&key), + value: decode(&value), + span: logical.get(pos).map(|p| p.1).unwrap_or(end)..end, + quote: None, + }); + } + Self { + source, + entries, + ini: false, + } + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.entries + .iter() + .rev() + .find(|entry| entry.key == key) + .map(|entry| entry.value.as_str()) + } + + pub fn set(&mut self, key: &str, value: &str) { + if let Some(entry) = self.entries.iter().rev().find(|entry| entry.key == key) { + let escaped = if self.ini { + let value = value.replace('\r', "\\r").replace('\n', "\\n"); + match entry.quote { + Some('"') => value.replace('\\', "\\\\").replace('"', "\\\""), + Some('\'') => value.replace('\'', "'\\''"), + _ => value, + } + } else { + escape(value, false) + }; + self.source.replace_range(entry.span.clone(), &escaped); + } else { + let escaped = escape(value, false); + let newline = if self.source.contains("\r\n") { + "\r\n" + } else if self.source.contains('\r') { + "\r" + } else { + "\n" + }; + let final_newline = self.source.ends_with(['\r', '\n']); + if !self.source.is_empty() && !final_newline { + self.source.push_str(newline); + } + self.source + .push_str(&format!("{}={escaped}", escape(key, true))); + if final_newline { + self.source.push_str(newline); + } + } + *self = Self::parse_format(std::mem::take(&mut self.source), self.ini); + } + + pub fn replace_tokens(&self, replacements: Vec<(Range, String)>) -> Self { + let mut source = self.source.clone(); + for (span, value) in replacements.into_iter().rev() { + source.replace_range(span, &value); + } + Self::parse_format(source, self.ini) + } + + pub fn parse_format(source: String, ini: bool) -> Self { + if ini { + crate::ini::parse(source) + } else { + Self::parse(source) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn lossless_and_targeted_change() { + let source = "#Minecraft\r\nmax-players = 20\r\nunknown.future:key\\ value\r\n\r\n"; + let mut doc = Document::parse(source.into()); + assert_eq!(doc.source, source); + doc.set("max-players", "50"); + assert_eq!(doc.source, source.replace("20", "50")); + } + #[test] + fn properties_syntax_and_unicode() { + for (source, key, expected) in [ + ("name=value\n", "name", "value"), + ("name:value\n", "name", "value"), + ("name value\n", "name", "value"), + ("name=\n", "name", ""), + ("part\\=name=value\n", "part=name", "value"), + ("server\\ name=Druid\n", "server name", "Druid"), + ("motd=Grüße 世界\n", "motd", "Grüße 世界"), + ("motd=Hello\\u0020World\n", "motd", "Hello World"), + ("motd=\\uD83D\\uDE00\n", "motd", "😀"), + ( + "motd=Hello \\\r\n Druid\\ World\r\n", + "motd", + "Hello Druid World", + ), + ] { + assert_eq!(Document::parse(source.into()).get(key), Some(expected)); + } + } + #[test] + fn duplicates_and_appending() { + let mut doc = Document::parse("motd=old\nmotd=new\n".into()); + assert_eq!(doc.get("motd"), Some("new")); + doc.set("motd", "final"); + assert_eq!(doc.source, "motd=old\nmotd=final\n"); + for (source, expected) in [ + ("motd=Druid\r\n", "motd=Druid\r\nmax-players=20\r\n"), + ("motd=Druid", "motd=Druid\nmax-players=20"), + ] { + let mut doc = Document::parse(source.into()); + doc.set("max-players", "20"); + assert_eq!(doc.source, expected); + } + let mut empty = Document::parse("".into()); + empty.set("server name", "Druid\\Realm\nLine"); + assert_eq!(empty.source, "server\\ name=Druid\\\\Realm\\nLine"); + } + #[test] + fn comments_do_not_consume_following_settings() { + let source = "# comment\\\r\nmax-players=20\r\n! second\\\nmotd=Hello"; + let mut doc = Document::parse(source.into()); + assert_eq!(doc.get("max-players"), Some("20")); + assert_eq!(doc.get("motd"), Some("Hello")); + doc.set("max-players", "21"); + assert_eq!(doc.source, source.replace("20", "21")); + } +} diff --git a/ui-minecraft/guest/src/tools.rs b/ui-minecraft/guest/src/tools.rs new file mode 100644 index 00000000..3d59a083 --- /dev/null +++ b/ui-minecraft/guest/src/tools.rs @@ -0,0 +1,63 @@ +use crate::druid::openui::host; +use crate::{ + editor::{fingerprint, Editor}, + manifest::File, +}; +use serde_json::{json, Value}; + +pub async fn call(operation: &str, params: Value) -> Result { + let response = host::call(operation.to_owned(), params.to_string()).await?; + serde_json::from_str(&response).map_err(|_| format!("Invalid response from {operation}.")) +} +pub async fn text(path: &str) -> Result { + call("load_file", json!({"path":path})) + .await? + .as_str() + .map(str::to_owned) + .ok_or("File service returned non-text content.".into()) +} +fn missing(error: &str) -> bool { + let message = error.to_lowercase(); + message.contains("404") + || message.contains("not found") + || message.contains("not-found") + || message.contains("missing") +} +pub async fn load(file: File) -> Result { + match text(&file.path).await { + Ok(source) => { + let path = file.path.clone(); + Ok(Editor::new(file, source, path)) + } + Err(error) if missing(&error) => { + for suffix in [".scroll_template", ".default"] { + let path = format!("{}{suffix}", file.path); + match text(&path).await { + Ok(source) => return Ok(Editor::new(file, source, path)), + Err(error) if missing(&error) => {} + Err(error) => return Err(error), + } + } + Err(format!("Missing configuration file: {}", file.path)) + } + Err(error) => Err(error), + } +} +pub async fn save(editor: &Editor) -> Result { + if !editor.errors().is_empty() { + return Err("Configuration has validation errors and cannot be saved.".into()); + } + let source = &editor.document.source; + call( + "save_file", + json!({"path":editor.schema.path,"content":source}), + ) + .await?; + let remote = text(&editor.schema.path).await?; + if fingerprint(&remote) != fingerprint(source) || remote != *source { + return Err( + "Save verification failed: reloaded content differs from the saved content.".into(), + ); + } + Ok(remote) +} diff --git a/ui-minecraft/public/.gitignore b/ui-minecraft/public/.gitignore new file mode 100644 index 00000000..820837ca --- /dev/null +++ b/ui-minecraft/public/.gitignore @@ -0,0 +1,2 @@ +dist/ +guest/target/ diff --git a/ui-minecraft/public/guest/Cargo.lock b/ui-minecraft/public/guest/Cargo.lock new file mode 100644 index 00000000..bedeb5b0 --- /dev/null +++ b/ui-minecraft/public/guest/Cargo.lock @@ -0,0 +1,326 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "druid-minecraft-public-page" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/ui-minecraft/public/guest/Cargo.toml b/ui-minecraft/public/guest/Cargo.toml new file mode 100644 index 00000000..b90c774f --- /dev/null +++ b/ui-minecraft/public/guest/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "druid-minecraft-public-page" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wit-bindgen = "=0.63.0" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/ui-minecraft/public/guest/src/lib.rs b/ui-minecraft/public/guest/src/lib.rs new file mode 100644 index 00000000..771005b2 --- /dev/null +++ b/ui-minecraft/public/guest/src/lib.rs @@ -0,0 +1,17 @@ +#[cfg(target_arch = "wasm32")] +wit_bindgen::generate!({ + path: env!("DRUID_WIT_PATH"), + world: "openui-client", +}); + +#[cfg(target_arch = "wasm32")] +struct PublicPage; + +#[cfg(target_arch = "wasm32")] +impl exports::druid::openui::client::Guest for PublicPage { + async fn init() {} + async fn handle_action(_action: String, _params: String) {} +} + +#[cfg(target_arch = "wasm32")] +export!(PublicPage); diff --git a/ui-minecraft/public/src/view.openui b/ui-minecraft/public/src/view.openui new file mode 100644 index 00000000..12ec56b6 --- /dev/null +++ b/ui-minecraft/public/src/view.openui @@ -0,0 +1,10 @@ +root = Stack([ + Card("Welcome to our Minecraft server", [ + TextContent("Build, explore, and play together."), + TextContent("Ask the server owner for the connection address and any joining requirements.") + ], "Minecraft multiplayer"), + Card("Before you join", [ + TextContent("Use the Minecraft edition and version provided by the server owner."), + TextContent("Respect other players, their builds, and the server rules.") + ]) +], "lg") diff --git a/ui-minecraft/src/view.openui b/ui-minecraft/src/view.openui new file mode 100644 index 00000000..0cac5776 --- /dev/null +++ b/ui-minecraft/src/view.openui @@ -0,0 +1,70 @@ +$access = "services" +$section = "dashboard" +root = Grid([ + GridItem([Card(null, [ + PanelTabs([{value: "services", label: "Endpoints"}, {value: "developer-tools", label: "Developer tools"}], $access, {type: "SetAccessTab", params: {}}, "Deployment access", [ + $access == "services" ? DeploymentEndpoints() : DeveloperTools() + ]) + ], null, "none")], 3), + GridItem([SectionTabs([{value: "dashboard", label: "Dashboard", icon: "dashboard"}, {value: "configuration", label: "Configuration", icon: "settings"}, {value: "backups", label: "Backups", icon: "archive"}], $section, {type: "SetSectionTab", params: {}}, "Deployment sections", false, [ + $section == "dashboard" ? overview : + $section == "configuration" ? Card("Configuration", [configuration]) : + Card("Backups", [DeploymentBackups()]) + ], null, null, [ + {label: "Documentation", href: "https://docs.druid.gg", icon: "book"}, + {label: "Get Support on Discord", href: "https://discord.gg/UUXpmx24ua", icon: "message"} + ])], 9) +], null, null, 12) +overview = Stack([ + DeploymentState([Card("Task Graph", [DeploymentTaskGraph()], "Inspect running tasks, dependencies, and console output.")]), + StatisticsToolbar(), + Grid([ + Card("CPU usage", [DeploymentResourceChart("cpu")]), + Card("Memory usage", [DeploymentResourceChart("memory")]) + ]) +]) +$editor = null +configuration = Stack([ + $editor == null ? StatusMessage("Loading configuration…") : + $editor.error ? StatusMessage($editor.error, "error") : + !$editor.ready ? StatusMessage($editor.copy.loading) : Stack([ + Row([ + Heading($editor.manifest.server.displayName), + $editor.manifest.server.appVersion ? Badge($editor.manifest.server.appVersion) : null + ]), + Grid([ + Field($editor.copy.filesHeading, "", [ + Select($editor.files, $editor.selectedPath, {type: "SelectFile", params: {}}, $editor.saving) + ]), + Row([ + Badge($editor.schema.format), + Button($editor.saving ? $editor.copy.saving : $editor.copy.save, {type: "Save", params: {}}, !$editor.canSave) + ]) + ]), + StatusMessage($editor.status), + $editor.snapshot.restartRequired ? StatusMessage($editor.copy.restartAfterSave, "warning") : null, + Tabs($editor.tabs, $editor.mode, {type: "SetMode", params: {}}, $editor.copy.modeHeading, $editor.saving, [ + Separator(), + $editor.mode == "raw" ? Field($editor.copy.rawLabel, "", [ + Textarea($editor.source, {type: "SetRaw", params: {}}, $editor.saving) + ]) : Stack(@Each($editor.sections, "section", Card(section.label, [ + Grid(@Each(section.fields, "field", Field(field.label, field.description, [ + field.type == "secret" ? + PasswordInput(field.value, {type: "SetField", params: {key: field.key}}, $editor.saving) : + field.type == "boolean" ? + Switch(field.checked, {type: "SetField", params: {key: field.key}}, $editor.saving) : + field.type == "enum" ? + Select(field.options, field.value, {type: "SetField", params: {key: field.key}}, $editor.saving) : + field.type == "integer" || field.type == "number" ? + NumberInput(field.value, {type: "SetField", params: {key: field.key}}, $editor.saving, null, field.min, field.max, field.step) : + Input(field.value, {type: "SetField", params: {key: field.key}}, $editor.saving), + Row([ + TextContent(field.key), + field.restartRequired ? Badge($editor.copy.restartRequired) : null + ]) + ], field.errors, $editor.saving))) + ], section.description))), + StatusMessage($editor.validation, "error") + ]) + ], "md") +]) diff --git a/ui-minecraft/wit/client.wit b/ui-minecraft/wit/client.wit new file mode 100644 index 00000000..0c49a60f --- /dev/null +++ b/ui-minecraft/wit/client.wit @@ -0,0 +1,14 @@ +package druid:openui; + +interface client { + /// Called once after the host imports are ready. + init: async func(); + /// Params contains the JSON-encoded parameters of a named OpenUI action. + handle-action: async func(action: string, params: string); +} + +world openui-client { + import host; + import state; + export client; +} diff --git a/ui-minecraft/wit/host.wit b/ui-minecraft/wit/host.wit new file mode 100644 index 00000000..e564d96c --- /dev/null +++ b/ui-minecraft/wit/host.wit @@ -0,0 +1,7 @@ +package druid:openui; + +interface host { + log: func(message: string); + /// Await a named operation. Both successful values and errors cross as JSON/text. + call: async func(operation: string, params: string) -> result; +} diff --git a/ui-minecraft/wit/state.wit b/ui-minecraft/wit/state.wit new file mode 100644 index 00000000..1e3ec782 --- /dev/null +++ b/ui-minecraft/wit/state.wit @@ -0,0 +1,8 @@ +package druid:openui; + +interface state { + /// Read top-level state as JSON. None means absent, not JSON null. + get-value: func(name: string) -> option; + /// Write a JSON value to top-level state, including a $variable. + set-value: func(name: string, json: string); +} diff --git a/ui/README.md b/ui/README.md index 41ee1a04..ee250543 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,6 +1,6 @@ # Scroll configuration admin UI -Every released game-server Scroll receives the same sandboxed configuration editor during publication. Staging generates a family manifest, copies `dist/app.wasm` and its companion `dist/app.openui` into `private/dist/`, and adds the private UI entry to the staged `scroll.yaml`. +Non-Minecraft game-server Scrolls receive this sandboxed configuration editor during publication. Minecraft uses the Rust dashboard in `../ui-minecraft`. Staging generates a family manifest, copies the selected `dist/app.wasm` and its companion `dist/app.openui` into `private/dist/`, and adds the private UI entry to the staged `scroll.yaml`. Minecraft variants share one typed `server.properties` schema. Other families expose their native configuration files and always retain a lossless Raw view, so newly introduced or uncommon options remain editable even before a typed control is added. diff --git a/ui/dist/app.openui b/ui/dist/app.openui new file mode 100644 index 00000000..918d4ffb --- /dev/null +++ b/ui/dist/app.openui @@ -0,0 +1,45 @@ +$editor = null +root = Stack([ + $editor == null ? StatusMessage("Loading configuration…") : + $editor.error ? StatusMessage($editor.error, "error") : + !$editor.ready ? StatusMessage($editor.copy.loading) : Stack([ + Row([ + Heading($editor.manifest.server.displayName), + $editor.manifest.server.appVersion ? Badge($editor.manifest.server.appVersion) : null + ]), + Grid([ + Field($editor.copy.filesHeading, "", [ + Select($editor.files, $editor.selectedPath, {type: "SelectFile", params: {}}, $editor.saving) + ]), + Row([ + Badge($editor.schema.format), + Button($editor.saving ? $editor.copy.saving : $editor.copy.save, {type: "Save", params: {}}, !$editor.canSave) + ]) + ]), + StatusMessage($editor.status), + $editor.snapshot.restartRequired ? StatusMessage($editor.copy.restartAfterSave, "warning") : null, + Tabs($editor.tabs, $editor.mode, {type: "SetMode", params: {}}, $editor.copy.modeHeading, $editor.saving, [ + Separator(), + $editor.mode == "raw" ? Field($editor.copy.rawLabel, "", [ + Textarea($editor.source, {type: "SetRaw", params: {}}, $editor.saving) + ]) : Stack(@Each($editor.sections, "section", Card(section.label, [ + Grid(@Each(section.fields, "field", Field(field.label, field.description, [ + field.type == "secret" ? + PasswordInput(field.value, {type: "SetField", params: {key: field.key}}, $editor.saving) : + field.type == "boolean" ? + Switch(field.checked, {type: "SetField", params: {key: field.key}}, $editor.saving) : + field.type == "enum" ? + Select(field.options, field.value, {type: "SetField", params: {key: field.key}}, $editor.saving) : + field.type == "integer" || field.type == "number" ? + NumberInput(field.value, {type: "SetField", params: {key: field.key}}, $editor.saving, null, field.min, field.max, field.step) : + Input(field.value, {type: "SetField", params: {key: field.key}}, $editor.saving), + Row([ + TextContent(field.key), + field.restartRequired ? Badge($editor.copy.restartRequired) : null + ]) + ], field.errors, $editor.saving))) + ], section.description))), + StatusMessage($editor.validation, "error") + ]) + ], "md") +]) diff --git a/ui/dist/app.wasm b/ui/dist/app.wasm index 9d4568eb..e7c3c761 100644 Binary files a/ui/dist/app.wasm and b/ui/dist/app.wasm differ