diff --git a/.github/workflows/startos-iso.yaml b/.github/workflows/startos-iso.yaml index 72e37eb9f..1efc3f9c1 100644 --- a/.github/workflows/startos-iso.yaml +++ b/.github/workflows/startos-iso.yaml @@ -251,10 +251,8 @@ jobs: mkdir -p patch-db/client/dist mkdir -p web/.angular mkdir -p web/dist/raw/ui - mkdir -p web/dist/raw/install-wizard mkdir -p web/dist/raw/setup-wizard mkdir -p web/dist/static/ui - mkdir -p web/dist/static/install-wizard mkdir -p web/dist/static/setup-wizard PLATFORM=${{ matrix.platform }} make -t compiled-${{ env.ARCH }}.tar diff --git a/Makefile b/Makefile index dc4e1e27f..d7772e371 100644 --- a/Makefile +++ b/Makefile @@ -12,8 +12,8 @@ RUST_ARCH := $(shell if [ "$(ARCH)" = "riscv64" ]; then echo riscv64gc; else ech REGISTRY_BASENAME := $(shell PROJECT=start-registry PLATFORM=$(ARCH) ./build/env/basename.sh) TUNNEL_BASENAME := $(shell PROJECT=start-tunnel PLATFORM=$(ARCH) ./build/env/basename.sh) IMAGE_TYPE=$(shell if [ "$(PLATFORM)" = raspberrypi ]; then echo img; else echo iso; fi) -WEB_UIS := web/dist/raw/ui/index.html web/dist/raw/setup-wizard/index.html web/dist/raw/install-wizard/index.html -COMPRESSED_WEB_UIS := web/dist/static/ui/index.html web/dist/static/setup-wizard/index.html web/dist/static/install-wizard/index.html +WEB_UIS := web/dist/raw/ui/index.html web/dist/raw/setup-wizard/index.html +COMPRESSED_WEB_UIS := web/dist/static/ui/index.html web/dist/static/setup-wizard/index.html FIRMWARE_ROMS := build/lib/firmware/$(PLATFORM) $(shell jq --raw-output '.[] | select(.platform[] | contains("$(PLATFORM)")) | "./build/lib/firmware/$(PLATFORM)/" + .id + ".rom.gz"' build/lib/firmware.json) BUILD_SRC := $(call ls-files, build/lib) build/lib/depends build/lib/conflicts $(FIRMWARE_ROMS) IMAGE_RECIPE_SRC := $(call ls-files, build/image-recipe/) @@ -22,7 +22,6 @@ CORE_SRC := $(call ls-files, core) $(shell git ls-files --recurse-submodules pat WEB_SHARED_SRC := $(call ls-files, web/projects/shared) $(call ls-files, web/projects/marketplace) $(shell ls -p web/ | grep -v / | sed 's/^/web\//g') web/node_modules/.package-lock.json web/config.json patch-db/client/dist/index.js sdk/baseDist/package.json web/patchdb-ui-seed.json sdk/dist/package.json WEB_UI_SRC := $(call ls-files, web/projects/ui) WEB_SETUP_WIZARD_SRC := $(call ls-files, web/projects/setup-wizard) -WEB_INSTALL_WIZARD_SRC := $(call ls-files, web/projects/install-wizard) WEB_START_TUNNEL_SRC := $(call ls-files, web/projects/start-tunnel) PATCH_DB_CLIENT_SRC := $(shell git ls-files --recurse-submodules patch-db/client) GZIP_BIN := $(shell which pigz || which gzip) @@ -333,10 +332,6 @@ web/dist/raw/setup-wizard/index.html: $(WEB_SETUP_WIZARD_SRC) $(WEB_SHARED_SRC) npm --prefix web run build:setup touch web/dist/raw/setup-wizard/index.html -web/dist/raw/install-wizard/index.html: $(WEB_INSTALL_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular/.updated - npm --prefix web run build:install - touch web/dist/raw/install-wizard/index.html - web/dist/raw/start-tunnel/index.html: $(WEB_START_TUNNEL_SRC) $(WEB_SHARED_SRC) web/.angular/.updated npm --prefix web run build:tunnel touch web/dist/raw/start-tunnel/index.html diff --git a/START-TUNNEL.md b/START-TUNNEL.md deleted file mode 100644 index 8fe02e755..000000000 --- a/START-TUNNEL.md +++ /dev/null @@ -1,95 +0,0 @@ -# StartTunnel - -A self-hosted WireGuard VPN optimized for creating VLANs and reverse tunneling to personal servers. - -You can think of StartTunnel as "virtual router in the cloud". - -Use it for private remote access to self-hosted services running on a personal server, or to expose self-hosted services to the public Internet without revealing the host server's IP address. - -## Features - -- **Create Subnets**: Each subnet creates a private, virtual local area network (VLAN), similar to the LAN created by a home router. - -- **Add Devices**: When you add a device (server, phone, laptop) to a subnet, it receives a LAN IP address on that subnet as well as a unique WireGuard config that must be copied, downloaded, or scanned into the device. - -- **Forward Ports**: Forwarding a port creates a "reverse tunnel", exposing a specific port on a specific device to the public Internet. - -## Installation - -1. Rent a low cost VPS. For most use cases, the cheapest option should be enough. - - - It must have a dedicated public IP address. - - For compute (CPU), memory (RAM), and storage (disk), choose the minimum spec. - - For transfer (bandwidth), it depends on (1) your use case and (2) your home Internet's _upload_ speed. Even if you intend to serve large files or stream content from your server, there is no reason to pay for speeds that exceed your home Internet's upload speed. - -1. Provision the VPS with the latest version of Debian. - -1. Access the VPS via SSH. - -1. Run the StartTunnel install script: - - curl -fsSL https://start9labs.github.io/start-tunnel | sh - -1. [Initialize the web interface](#web-interface) (recommended) - -## Updating - -Simply re-run the install command: - -```sh -curl -fsSL https://start9labs.github.io/start-tunnel | sh -``` - -## CLI - -By default, StartTunnel is managed via the `start-tunnel` command line interface, which is self-documented. - -``` -start-tunnel --help -``` - -## Web Interface - -Enable the web interface (recommended in most cases) to access your StartTunnel from the browser or via API. - -1. Initialize the web interface. - - start-tunnel web init - -1. If your VPS has multiple public IP addresses, you will be prompted to select the IP address at which to host the web interface. - -1. When prompted, enter the port at which to host the web interface. The default is 8443, and we recommend using it. If you change the default, choose an uncommon port to avoid future conflicts. - -1. To access your StartTunnel web interface securely over HTTPS, you need an SSL certificate. When prompted, select whether to autogenerate a certificate or provide your own. _This is only for accessing your StartTunnel web interface_. - -1. You will receive a success message with 3 pieces of information: - - - ****: the URL where you can reach your personal web interface. - - **Password**: an autogenerated password for your interface. If you lose/forget it, you can reset it using the start-tunnel CLI. - - **Root Certificate Authority**: the Root CA of your StartTunnel instance. - -1. If you autogenerated your SSL certificate, visiting the `https://IP:port` URL in the browser will warn you that the website is insecure. This is expected. You have two options for getting past this warning: - - option 1 (recommended): [Trust your StartTunnel Root CA on your connecting device](#trusting-your-starttunnel-root-ca). - - Option 2: bypass the warning in the browser, creating a one-time security exception. - -### Trusting your StartTunnel Root CA - -1. Copy the contents of your Root CA (starting with -----BEGIN CERTIFICATE----- and ending with -----END CERTIFICATE-----). - -2. Open a text editor: - - - Linux: gedit, nano, or any editor - - Mac: TextEdit - - Windows: Notepad - -3. Paste the contents of your Root CA. - -4. Save the file with a `.crt` extension (e.g. `start-tunnel.crt`) (make sure it saves as plain text, not rich text). - -5. Trust the Root CA on your client device(s): - - - [Linux](https://staging.docs.start9.com/device-guides/linux/ca.html) - - [Mac](https://staging.docs.start9.com/device-guides/mac/ca.html) - - [Windows](https://staging.docs.start9.com/device-guides/windows/ca.html) - - [Android/Graphene](https://staging.docs.start9.com/device-guides/android/ca.html) - - [iOS](https://staging.docs.start9.com/device-guides/ios/ca.html) diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index 641f16c7c..5f48d9b55 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -154,9 +154,12 @@ prompt 0 timeout 50 EOF -cp $SOURCE_DIR/splash.png config/bootloaders/syslinux_common/splash.png -cp $SOURCE_DIR/splash.png config/bootloaders/isolinux/splash.png -cp $SOURCE_DIR/splash.png config/bootloaders/grub-pc/splash.png +# Extract splash.png from the deb package +dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xf - ./usr/lib/startos/splash.png > /tmp/splash.png +cp /tmp/splash.png config/bootloaders/syslinux_common/splash.png +cp /tmp/splash.png config/bootloaders/isolinux/splash.png +cp /tmp/splash.png config/bootloaders/grub-pc/splash.png +rm /tmp/splash.png sed -i -e '2i set timeout=5' config/bootloaders/grub-pc/config.cfg @@ -289,8 +292,8 @@ fi if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then ln -sf /usr/bin/pi-beep /usr/local/bin/beep KERNEL_VERSION=${RPI_KERNEL_VERSION} sh /boot/config.sh > /boot/config.txt - mkinitramfs -c gzip -o initrd.img-${RPI_KERNEL_VERSION}-rpi-v8 ${RPI_KERNEL_VERSION}-rpi-v8 - mkinitramfs -c gzip -o initrd.img-${RPI_KERNEL_VERSION}-rpi-2712 ${RPI_KERNEL_VERSION}-rpi-2712 + mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-v8 ${RPI_KERNEL_VERSION}-rpi-v8 + mkinitramfs -c gzip -o /boot/initrd.img-${RPI_KERNEL_VERSION}-rpi-2712 ${RPI_KERNEL_VERSION}-rpi-2712 fi useradd --shell /bin/bash -G startos -m start9 diff --git a/build/lib/grub-theme/theme.txt b/build/lib/grub-theme/theme.txt new file mode 100644 index 000000000..549e6abce --- /dev/null +++ b/build/lib/grub-theme/theme.txt @@ -0,0 +1,51 @@ +desktop-image: "../splash.png" +title-color: "#ffffff" +title-font: "Unifont Regular 16" +title-text: "StartOS Boot Menu with GRUB" +message-font: "Unifont Regular 16" +terminal-font: "Unifont Regular 16" + +#help bar at the bottom ++ label { + top = 100%-50 + left = 0 + width = 100% + height = 20 + text = "@KEYMAP_SHORT@" + align = "center" + color = "#ffffff" + font = "Unifont Regular 16" +} + +#boot menu ++ boot_menu { + left = 10% + width = 80% + top = 52% + height = 48%-80 + item_color = "#a8a8a8" + item_font = "Unifont Regular 16" + selected_item_color= "#ffffff" + selected_item_font = "Unifont Regular 16" + item_height = 16 + item_padding = 0 + item_spacing = 4 + icon_width = 0 + icon_heigh = 0 + item_icon_space = 0 +} + +#progress bar ++ progress_bar { + id = "__timeout__" + left = 15% + top = 100%-80 + height = 16 + width = 70% + font = "Unifont Regular 16" + text_color = "#000000" + fg_color = "#ffffff" + bg_color = "#a8a8a8" + border_color = "#ffffff" + text = "@TIMEOUT_NOTIFICATION_LONG@" +} \ No newline at end of file diff --git a/build/lib/scripts/prune-images b/build/lib/scripts/prune-images index 1203d2377..d32c14bf5 100755 --- a/build/lib/scripts/prune-images +++ b/build/lib/scripts/prune-images @@ -29,10 +29,13 @@ if [ -z "$needed" ]; then exit 1 fi +MARGIN=${MARGIN:-1073741824} +target=$((needed + MARGIN)) + if [ -h /media/startos/config/current.rootfs ] && [ -e /media/startos/config/current.rootfs ]; then echo 'Pruning...' current="$(readlink -f /media/startos/config/current.rootfs)" - while [[ "$(df -B1 --output=avail --sync /media/startos/images | tail -n1)" -lt "$needed" ]]; do + while [[ "$(df -B1 --output=avail --sync /media/startos/images | tail -n1)" -lt "$target" ]]; do to_prune="$(ls -t1 /media/startos/images/*.rootfs /media/startos/images/*.squashfs 2> /dev/null | grep -v "$current" | tail -n1)" if [ -e "$to_prune" ]; then echo " Pruning $to_prune" diff --git a/build/image-recipe/splash.png b/build/lib/splash.png similarity index 100% rename from build/image-recipe/splash.png rename to build/lib/splash.png diff --git a/container-runtime/package-lock.json b/container-runtime/package-lock.json index a49691ff4..1e0af6585 100644 --- a/container-runtime/package-lock.json +++ b/container-runtime/package-lock.json @@ -38,7 +38,7 @@ }, "../sdk/dist": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.47", + "version": "0.4.0-beta.48", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/container-runtime/src/Adapters/EffectCreator.ts b/container-runtime/src/Adapters/EffectCreator.ts index f38746c52..44c5d40b2 100644 --- a/container-runtime/src/Adapters/EffectCreator.ts +++ b/container-runtime/src/Adapters/EffectCreator.ts @@ -319,6 +319,7 @@ export function makeEffects(context: EffectContext): Effects { } if (context.callbacks?.onLeaveContext) self.onLeaveContext(() => { + self.constRetry = undefined self.isInContext = false self.onLeaveContext = () => { console.warn( diff --git a/core/Cargo.lock b/core/Cargo.lock index 4dcc5641e..efd112abf 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -239,6 +239,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] + [[package]] name = "archery" version = "1.2.2" @@ -848,6 +857,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base62" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111" + [[package]] name = "base64" version = "0.13.1" @@ -1109,7 +1124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", - "regex-automata", + "regex-automata 0.4.13", "serde", ] @@ -1162,9 +1177,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.52" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "jobserver", @@ -2711,9 +2726,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "fixed-capacity-vec" @@ -3094,12 +3109,42 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glob-match" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.13", + "regex-syntax 0.8.8", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -3740,6 +3785,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.13", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.9" @@ -4125,9 +4186,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -4254,7 +4315,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata", + "regex-automata 0.4.13", ] [[package]] @@ -4479,11 +4540,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ - "regex-automata", + "regex-automata 0.1.10", ] [[package]] @@ -4784,6 +4845,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "notify" version = "8.2.0" @@ -4818,11 +4888,12 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.3" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ - "windows-sys 0.61.2", + "overload", + "winapi", ] [[package]] @@ -5199,6 +5270,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "owo-colors" version = "4.2.3" @@ -6364,10 +6441,19 @@ checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata", + "regex-automata 0.4.13", "regex-syntax 0.8.8", ] +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -6501,7 +6587,7 @@ dependencies = [ [[package]] name = "rpc-toolkit" version = "0.3.2" -source = "git+https://github.com/Start9Labs/rpc-toolkit.git#406ee9e88bf20e3155f150eb755b5b9c2aefd167" +source = "git+https://github.com/Start9Labs/rpc-toolkit.git#39e547ff99d997c19f9b6483b28a4394ca5a07bc" dependencies = [ "async-stream", "async-trait", @@ -6586,10 +6672,64 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.26" +name = "rust-i18n" +version = "3.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.114", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml 0.8.23", + "triomphe", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" @@ -6666,7 +6806,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -6694,9 +6834,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -6716,7 +6856,7 @@ dependencies = [ "rustls 0.23.36", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.9", "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", @@ -6742,9 +6882,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -7106,6 +7246,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serde_yml" version = "0.0.12" @@ -7664,7 +7817,7 @@ dependencies = [ [[package]] name = "start-os" -version = "0.4.0-alpha.17" +version = "0.4.0-alpha.18" dependencies = [ "aes 0.7.5", "arti-client", @@ -7763,6 +7916,7 @@ dependencies = [ "rpassword", "rpc-toolkit", "rust-argon2", + "rust-i18n", "safelog", "semver", "serde", @@ -9402,7 +9556,7 @@ dependencies = [ "paste", "pin-project", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.9", "thiserror 2.0.17", "tokio", "tokio-util", @@ -9616,14 +9770,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex-automata", + "regex", "sharded-slab", "smallvec", "thread_local", @@ -9653,6 +9807,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -9854,6 +10019,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -10030,9 +10201,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] @@ -10054,9 +10225,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -10067,11 +10238,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -10080,9 +10252,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10090,9 +10262,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -10103,9 +10275,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -10204,9 +10376,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -10803,9 +10975,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" diff --git a/core/Cargo.toml b/core/Cargo.toml index ddd3c4463..506fc86c8 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -15,7 +15,7 @@ license = "MIT" name = "start-os" readme = "README.md" repository = "https://github.com/Start9Labs/start-os" -version = "0.4.0-alpha.17" # VERSION_BUMP +version = "0.4.0-alpha.18" # VERSION_BUMP [lib] name = "startos" @@ -213,6 +213,7 @@ reqwest = { version = "0.12.25", features = [ reqwest_cookie_store = "0.9.0" rpassword = "7.2.0" rust-argon2 = "3.0.0" +rust-i18n = "3.1.5" rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git" } safelog = { version = "0.4.8", git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true } semver = { version = "1.0.20", features = ["serde"] } @@ -263,7 +264,7 @@ tower-service = "0.3.3" tracing = "0.1.39" tracing-error = "0.2.0" tracing-journald = "0.3.0" -tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +tracing-subscriber = { version = "=0.3.19", features = ["env-filter"] } ts-rs = "9.0.1" typed-builder = "0.23.2" url = { version = "2.4.1", features = ["serde"] } diff --git a/core/build/build-cli.sh b/core/build/build-cli.sh index 3d6f0aede..d809a189f 100755 --- a/core/build/build-cli.sh +++ b/core/build/build-cli.sh @@ -26,7 +26,7 @@ PROFILE=${PROFILE:-release} if [ "${PROFILE}" = "release" ]; then BUILD_FLAGS="--release" else - if [ "$PROFILE" != "debug"]; then + if [ "$PROFILE" != "debug" ]; then >&2 echo "Unknown profile $PROFILE: falling back to debug..." PROFILE=debug fi diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml new file mode 100644 index 000000000..f0ac17ca5 --- /dev/null +++ b/core/locales/i18n.yaml @@ -0,0 +1,5322 @@ +_version: 2 + +bins.deprecated.renamed: + en_US: "%{old} has been renamed to %{new}" + de_DE: "%{old} wurde in %{new} umbenannt" + es_ES: "%{old} ha sido renombrado a %{new}" + fr_FR: "%{old} a été renommé en %{new}" + pl_PL: "%{old} zostało zmienione na %{new}" + +bins.deprecated.removed: + en_US: "%{name} has been removed" + de_DE: "%{name} wurde entfernt" + es_ES: "%{name} ha sido eliminado" + fr_FR: "%{name} a été supprimé" + pl_PL: "%{name} zostało usunięte" + +bins.mod.unknown-executable: + en_US: "unknown executable: %{name}" + de_DE: "Unbekannte ausführbare Datei: %{name}" + es_ES: "ejecutable desconocido: %{name}" + fr_FR: "exécutable inconnu : %{name}" + pl_PL: "nieznany plik wykonywalny: %{name}" + +bins.mod.does-not-exist: + en_US: "%{name} does not exist in MultiExecutable" + de_DE: "%{name} existiert nicht in MultiExecutable" + es_ES: "%{name} no existe en MultiExecutable" + fr_FR: "%{name} n'existe pas dans MultiExecutable" + pl_PL: "%{name} nie istnieje w MultiExecutable" + +bins.start-init.updating-firmware: + en_US: "Updating Firmware" + de_DE: "Firmware wird aktualisiert" + es_ES: "Actualizando firmware" + fr_FR: "Mise à jour du firmware" + pl_PL: "Aktualizacja oprogramowania" + +bins.start-init.rebooting: + en_US: "Rebooting" + de_DE: "Neustart" + es_ES: "Reiniciando" + fr_FR: "Redémarrage" + pl_PL: "Ponowne uruchamianie" + +bins.start-init.opening-data-drive: + en_US: "Opening data drive" + de_DE: "Datenlaufwerk wird geöffnet" + es_ES: "Abriendo unidad de datos" + fr_FR: "Ouverture du disque de données" + pl_PL: "Otwieranie dysku danych" + +bins.start-init.setup-mode-exited: + en_US: "Setup mode exited before setup completed" + de_DE: "Der Einrichtungsmodus wurde beendet, bevor die Einrichtung abgeschlossen war" + es_ES: "El modo de configuración se cerró antes de completar la configuración" + fr_FR: "Le mode configuration s'est terminé avant la fin de la configuration" + pl_PL: "Tryb konfiguracji zakończył się przed ukończeniem konfiguracji" + +bins.start-init.loaded-disk: + en_US: "Loaded Disk" + de_DE: "Festplatte geladen" + es_ES: "Disco cargado" + fr_FR: "Disque chargé" + pl_PL: "Dysk załadowany" + +bins.start-init.error-checking-firmware: + en_US: "Error checking for firmware update: %{error}" + de_DE: "Fehler bei der Suche nach Firmware-Update: %{error}" + es_ES: "Error al buscar actualización de firmware: %{error}" + fr_FR: "Erreur lors de la vérification de la mise à jour du firmware : %{error}" + pl_PL: "Błąd podczas sprawdzania aktualizacji oprogramowania: %{error}" + +bins.start-init.error-firmware-update: + en_US: "Error performing firmware update: %{error}" + de_DE: "Fehler beim Durchführen des Firmware-Updates: %{error}" + es_ES: "Error al realizar la actualización de firmware: %{error}" + fr_FR: "Erreur lors de la mise à jour du firmware : %{error}" + pl_PL: "Błąd podczas wykonywania aktualizacji oprogramowania: %{error}" + +bins.start-init.failed-to-kill-kiosk: + en_US: "Failed to kill kiosk: %{error}" + de_DE: "Kiosk konnte nicht beendet werden: %{error}" + es_ES: "Error al cerrar el kiosco: %{error}" + fr_FR: "Échec de la fermeture du kiosque : %{error}" + pl_PL: "Nie udało się zamknąć kiosku: %{error}" + +bins.startd.metrics-daemon-panicked: + en_US: "Metrics daemon panicked!" + de_DE: "Metriken-Daemon ist abgestürzt!" + es_ES: "El demonio de métricas entró en pánico!" + fr_FR: "Le démon de métriques a paniqué !" + pl_PL: "Demon metryk uległ panice!" + +bins.startd.metrics-daemon-shutdown: + en_US: "Metrics daemon Shutdown" + de_DE: "Metriken-Daemon heruntergefahren" + es_ES: "Demonio de métricas apagado" + fr_FR: "Démon de métriques arrêté" + pl_PL: "Demon metryk zamknięty" + +bins.startd.failed-to-initialize-runtime: + en_US: "failed to initialize runtime" + de_DE: "Laufzeit konnte nicht initialisiert werden" + es_ES: "error al inicializar el entorno de ejecución" + fr_FR: "échec de l'initialisation du runtime" + pl_PL: "nie udało się zainicjować środowiska wykonawczego" + +bins.registry.failed-to-initialize-runtime: + en_US: "failed to initialize runtime" + de_DE: "Laufzeit konnte nicht initialisiert werden" + es_ES: "error al inicializar el entorno de ejecución" + fr_FR: "échec de l'initialisation du runtime" + pl_PL: "nie udało się zainicjować środowiska wykonawczego" + +bins.tunnel.failed-to-initialize-runtime: + en_US: "failed to initialize runtime" + de_DE: "Laufzeit konnte nicht initialisiert werden" + es_ES: "error al inicializar el entorno de ejecución" + fr_FR: "échec de l'initialisation du runtime" + pl_PL: "nie udało się zainicjować środowiska wykonawczego" + +bins.tunnel.error-adding-ssl-listener: + en_US: "error adding ssl listener: %{error}" + de_DE: "Fehler beim Hinzufügen des SSL-Listeners: %{error}" + es_ES: "error al agregar el listener SSL: %{error}" + fr_FR: "erreur lors de l'ajout de l'écouteur SSL : %{error}" + pl_PL: "błąd podczas dodawania nasłuchiwacza SSL: %{error}" + +bins.tunnel.error-updating-webserver-bind: + en_US: "error updating webserver bind: %{error}" + de_DE: "Fehler beim Aktualisieren der Webserver-Bindung: %{error}" + es_ES: "error al actualizar el enlace del servidor web: %{error}" + fr_FR: "erreur lors de la mise à jour de la liaison du serveur web : %{error}" + pl_PL: "błąd podczas aktualizacji powiązania serwera WWW: %{error}" + +# setup.rs +setup.opening-data-drive: + en_US: "Opening data drive" + de_DE: "Datenlaufwerk wird geöffnet" + es_ES: "Abriendo unidad de datos" + fr_FR: "Ouverture du disque de données" + pl_PL: "Otwieranie dysku danych" + +setup.couldnt-decode-password: + en_US: "Couldn't decode password" + de_DE: "Passwort konnte nicht dekodiert werden" + es_ES: "No se pudo decodificar la contraseña" + fr_FR: "Impossible de décoder le mot de passe" + pl_PL: "Nie można zdekodować hasła" + +setup.disk-errors-corrected-restart-required: + en_US: "Errors were corrected with your disk, but the server must be restarted in order to proceed" + de_DE: "Fehler auf Ihrer Festplatte wurden korrigiert, aber der Server muss neu gestartet werden, um fortzufahren" + es_ES: "Se corrigieron errores en su disco, pero el servidor debe reiniciarse para continuar" + fr_FR: "Des erreurs ont été corrigées sur votre disque, mais le serveur doit être redémarré pour continuer" + pl_PL: "Błędy na dysku zostały naprawione, ale serwer musi zostać uruchomiony ponownie, aby kontynuować" + +setup.no-backup-found: + en_US: "No Backup Found" + de_DE: "Keine Sicherung gefunden" + es_ES: "No se encontró copia de seguridad" + fr_FR: "Aucune sauvegarde trouvée" + pl_PL: "Nie znaleziono kopii zapasowej" + +setup.couldnt-decode-startos-password: + en_US: "Couldn't decode password" + de_DE: "Passwort konnte nicht dekodiert werden" + es_ES: "No se pudo decodificar la contraseña" + fr_FR: "Impossible de décoder le mot de passe" + pl_PL: "Nie można zdekodować hasła" + +setup.couldnt-decode-recovery-password: + en_US: "Couldn't decode recoveryPassword" + de_DE: "recoveryPassword konnte nicht dekodiert werden" + es_ES: "No se pudo decodificar recoveryPassword" + fr_FR: "Impossible de décoder recoveryPassword" + pl_PL: "Nie można zdekodować recoveryPassword" + +setup.execute-not-completed: + en_US: "setup.execute has not completed successfully" + de_DE: "setup.execute wurde nicht erfolgreich abgeschlossen" + es_ES: "setup.execute no se completó correctamente" + fr_FR: "setup.execute ne s'est pas terminé avec succès" + pl_PL: "setup.execute nie zakończyło się pomyślnie" + +setup.restoring-backup: + en_US: "Restoring backup" + de_DE: "Sicherung wird wiederhergestellt" + es_ES: "Restaurando copia de seguridad" + fr_FR: "Restauration de la sauvegarde" + pl_PL: "Przywracanie kopii zapasowej" + +setup.transferring-data: + en_US: "Transferring data" + de_DE: "Daten werden übertragen" + es_ES: "Transfiriendo datos" + fr_FR: "Transfert de données" + pl_PL: "Przesyłanie danych" + +# system.rs +system.governor-not-available: + en_US: "Governor %{governor} not available" + de_DE: "Governor %{governor} nicht verfügbar" + es_ES: "El governor %{governor} no está disponible" + fr_FR: "Le gouverneur %{governor} n'est pas disponible" + pl_PL: "Governor %{governor} nie jest dostępny" + +system.could-not-get-initial-temperature: + en_US: "Could not get initial temperature: %{error}" + de_DE: "Anfangstemperatur konnte nicht abgerufen werden: %{error}" + es_ES: "No se pudo obtener la temperatura inicial: %{error}" + fr_FR: "Impossible d'obtenir la température initiale : %{error}" + pl_PL: "Nie można uzyskać początkowej temperatury: %{error}" + +system.could-not-get-initial-cpu-info: + en_US: "Could not get initial cpu info: %{error}" + de_DE: "Anfangs-CPU-Informationen konnten nicht abgerufen werden: %{error}" + es_ES: "No se pudo obtener la información inicial de la CPU: %{error}" + fr_FR: "Impossible d'obtenir les informations CPU initiales : %{error}" + pl_PL: "Nie można uzyskać początkowych informacji o CPU: %{error}" + +system.could-not-get-initial-proc-stat: + en_US: "Could not get initial proc stat: %{error}" + de_DE: "Anfangs-Prozessstatistiken konnten nicht abgerufen werden: %{error}" + es_ES: "No se pudo obtener el estado inicial del proceso: %{error}" + fr_FR: "Impossible d'obtenir les statistiques de processus initiales : %{error}" + pl_PL: "Nie można uzyskać początkowych statystyk procesów: %{error}" + +system.could-not-get-initial-mem-info: + en_US: "Could not get initial mem info: %{error}" + de_DE: "Anfangs-Speicherinformationen konnten nicht abgerufen werden: %{error}" + es_ES: "No se pudo obtener la información inicial de memoria: %{error}" + fr_FR: "Impossible d'obtenir les informations mémoire initiales : %{error}" + pl_PL: "Nie można uzyskać początkowych informacji o pamięci: %{error}" + +system.could-not-get-initial-disk-info: + en_US: "Could not get initial disk info: %{error}" + de_DE: "Anfangs-Festplatteninformationen konnten nicht abgerufen werden: %{error}" + es_ES: "No se pudo obtener la información inicial del disco: %{error}" + fr_FR: "Impossible d'obtenir les informations disque initiales : %{error}" + pl_PL: "Nie można uzyskać początkowych informacji o dysku: %{error}" + +system.could-not-get-new-temperature: + en_US: "Could not get new temperature: %{error}" + de_DE: "Neue Temperatur konnte nicht abgerufen werden: %{error}" + es_ES: "No se pudo obtener la nueva temperatura: %{error}" + fr_FR: "Impossible d'obtenir la nouvelle température : %{error}" + pl_PL: "Nie można uzyskać nowej temperatury: %{error}" + +system.could-not-get-new-cpu-metrics: + en_US: "Could not get new CPU Metrics: %{error}" + de_DE: "Neue CPU-Metriken konnten nicht abgerufen werden: %{error}" + es_ES: "No se pudieron obtener las nuevas métricas de CPU: %{error}" + fr_FR: "Impossible d'obtenir les nouvelles métriques CPU : %{error}" + pl_PL: "Nie można uzyskać nowych metryk CPU: %{error}" + +system.could-not-get-new-memory-metrics: + en_US: "Could not get new Memory Metrics: %{error}" + de_DE: "Neue Speichermetriken konnten nicht abgerufen werden: %{error}" + es_ES: "No se pudieron obtener las nuevas métricas de memoria: %{error}" + fr_FR: "Impossible d'obtenir les nouvelles métriques mémoire : %{error}" + pl_PL: "Nie można uzyskać nowych metryk pamięci: %{error}" + +system.could-not-get-new-disk-metrics: + en_US: "Could not get new Disk Metrics: %{error}" + de_DE: "Neue Festplattenmetriken konnten nicht abgerufen werden: %{error}" + es_ES: "No se pudieron obtener las nuevas métricas de disco: %{error}" + fr_FR: "Impossible d'obtenir les nouvelles métriques disque : %{error}" + pl_PL: "Nie można uzyskać nowych metryk dysku: %{error}" + +system.no-temperatures-available: + en_US: "No temperatures available" + de_DE: "Keine Temperaturen verfügbar" + es_ES: "No hay temperaturas disponibles" + fr_FR: "Aucune température disponible" + pl_PL: "Brak dostępnych temperatur" + +system.invalid-proc-stat-column: + en_US: "Invalid /proc/stat column value: %{error}" + de_DE: "Ungültiger /proc/stat-Spaltenwert: %{error}" + es_ES: "Valor de columna /proc/stat inválido: %{error}" + fr_FR: "Valeur de colonne /proc/stat invalide : %{error}" + pl_PL: "Nieprawidłowa wartość kolumny /proc/stat: %{error}" + +system.columns-missing-from-proc-stat: + en_US: "Columns missing from /proc/stat. Need 10, found %{count}" + de_DE: "Spalten fehlen in /proc/stat. Benötigt 10, gefunden %{count}" + es_ES: "Faltan columnas en /proc/stat. Se necesitan 10, se encontraron %{count}" + fr_FR: "Colonnes manquantes dans /proc/stat. 10 requises, %{count} trouvées" + pl_PL: "Brakujące kolumny w /proc/stat. Wymagane 10, znaleziono %{count}" + +system.invalid-meminfo-line: + en_US: "Invalid meminfo line: %{line}" + de_DE: "Ungültige meminfo-Zeile: %{line}" + es_ES: "Línea de meminfo inválida: %{line}" + fr_FR: "Ligne meminfo invalide : %{line}" + pl_PL: "Nieprawidłowa linia meminfo: %{line}" + +system.field-missing-from-meminfo: + en_US: "%{field} missing from /proc/meminfo" + de_DE: "%{field} fehlt in /proc/meminfo" + es_ES: "%{field} falta en /proc/meminfo" + fr_FR: "%{field} manquant dans /proc/meminfo" + pl_PL: "%{field} brakuje w /proc/meminfo" + +# error.rs - ErrorKind strings +error.unknown: + en_US: "Unknown Error" + de_DE: "Unbekannter Fehler" + es_ES: "Error Desconocido" + fr_FR: "Erreur Inconnue" + pl_PL: "Nieznany Błąd" + +error.filesystem: + en_US: "Filesystem I/O Error" + de_DE: "Dateisystem-E/A-Fehler" + es_ES: "Error de E/S del Sistema de Archivos" + fr_FR: "Erreur E/S du Système de Fichiers" + pl_PL: "Błąd Wejścia/Wyjścia Systemu Plików" + +error.docker: + en_US: "Docker Error" + de_DE: "Docker-Fehler" + es_ES: "Error de Docker" + fr_FR: "Erreur Docker" + pl_PL: "Błąd Docker" + +error.config-spec-violation: + en_US: "Config Spec Violation" + de_DE: "Verletzung der Konfigurationsspezifikation" + es_ES: "Violación de Especificación de Configuración" + fr_FR: "Violation de Spécification de Configuration" + pl_PL: "Naruszenie Specyfikacji Konfiguracji" + +error.config-rules-violation: + en_US: "Config Rules Violation" + de_DE: "Verletzung der Konfigurationsregeln" + es_ES: "Violación de Reglas de Configuración" + fr_FR: "Violation des Règles de Configuration" + pl_PL: "Naruszenie Reguł Konfiguracji" + +error.not-found: + en_US: "Not Found" + de_DE: "Nicht Gefunden" + es_ES: "No Encontrado" + fr_FR: "Non Trouvé" + pl_PL: "Nie Znaleziono" + +error.incorrect-password: + en_US: "Incorrect Password" + de_DE: "Falsches Passwort" + es_ES: "Contraseña Incorrecta" + fr_FR: "Mot de Passe Incorrect" + pl_PL: "Nieprawidłowe Hasło" + +error.version-incompatible: + en_US: "Version Incompatible" + de_DE: "Version Inkompatibel" + es_ES: "Versión Incompatible" + fr_FR: "Version Incompatible" + pl_PL: "Wersja Niekompatybilna" + +error.network: + en_US: "Network Error" + de_DE: "Netzwerkfehler" + es_ES: "Error de Red" + fr_FR: "Erreur Réseau" + pl_PL: "Błąd Sieci" + +error.registry: + en_US: "Registry Error" + de_DE: "Registrierungsfehler" + es_ES: "Error de Registro" + fr_FR: "Erreur de Registre" + pl_PL: "Błąd Rejestru" + +error.serialization: + en_US: "Serialization Error" + de_DE: "Serialisierungsfehler" + es_ES: "Error de Serialización" + fr_FR: "Erreur de Sérialisation" + pl_PL: "Błąd Serializacji" + +error.deserialization: + en_US: "Deserialization Error" + de_DE: "Deserialisierungsfehler" + es_ES: "Error de Deserialización" + fr_FR: "Erreur de Désérialisation" + pl_PL: "Błąd Deserializacji" + +error.utf8: + en_US: "UTF-8 Parse Error" + de_DE: "UTF-8-Analysefehler" + es_ES: "Error de Análisis UTF-8" + fr_FR: "Erreur d'Analyse UTF-8" + pl_PL: "Błąd Parsowania UTF-8" + +error.parse-version: + en_US: "Version Parsing Error" + de_DE: "Fehler beim Analysieren der Version" + es_ES: "Error al Analizar la Versión" + fr_FR: "Erreur d'Analyse de Version" + pl_PL: "Błąd Parsowania Wersji" + +error.incorrect-disk: + en_US: "Incorrect Disk" + de_DE: "Falsche Festplatte" + es_ES: "Disco Incorrecto" + fr_FR: "Disque Incorrect" + pl_PL: "Nieprawidłowy Dysk" + +error.dependency: + en_US: "Dependency Error" + de_DE: "Abhängigkeitsfehler" + es_ES: "Error de Dependencia" + fr_FR: "Erreur de Dépendance" + pl_PL: "Błąd Zależności" + +error.parse-s9pk: + en_US: "S9PK Parsing Error" + de_DE: "S9PK-Analysefehler" + es_ES: "Error al Analizar S9PK" + fr_FR: "Erreur d'Analyse S9PK" + pl_PL: "Błąd Parsowania S9PK" + +error.parse-url: + en_US: "URL Parsing Error" + de_DE: "URL-Analysefehler" + es_ES: "Error al Analizar URL" + fr_FR: "Erreur d'Analyse d'URL" + pl_PL: "Błąd Parsowania URL" + +error.disk-not-available: + en_US: "Disk Not Available" + de_DE: "Festplatte Nicht Verfügbar" + es_ES: "Disco No Disponible" + fr_FR: "Disque Non Disponible" + pl_PL: "Dysk Niedostępny" + +error.block-device: + en_US: "Block Device Error" + de_DE: "Blockgerätefehler" + es_ES: "Error de Dispositivo de Bloque" + fr_FR: "Erreur de Périphérique Bloc" + pl_PL: "Błąd Urządzenia Blokowego" + +error.invalid-onion-address: + en_US: "Invalid Onion Address" + de_DE: "Ungültige Onion-Adresse" + es_ES: "Dirección Onion Inválida" + fr_FR: "Adresse Onion Invalide" + pl_PL: "Nieprawidłowy Adres Onion" + +error.pack: + en_US: "Pack Error" + de_DE: "Paketfehler" + es_ES: "Error de Empaquetado" + fr_FR: "Erreur de Paquet" + pl_PL: "Błąd Pakietu" + +error.validate-s9pk: + en_US: "S9PK Validation Error" + de_DE: "S9PK-Validierungsfehler" + es_ES: "Error de Validación S9PK" + fr_FR: "Erreur de Validation S9PK" + pl_PL: "Błąd Walidacji S9PK" + +error.disk-corrupted: + en_US: "Disk Corrupted" + de_DE: "Festplatte Beschädigt" + es_ES: "Disco Dañado" + fr_FR: "Disque Corrompu" + pl_PL: "Dysk Uszkodzony" + +error.tor: + en_US: "Tor Daemon Error" + de_DE: "Tor-Daemon-Fehler" + es_ES: "Error del Daemon Tor" + fr_FR: "Erreur du Démon Tor" + pl_PL: "Błąd Demona Tor" + +error.config-gen: + en_US: "Config Generation Error" + de_DE: "Fehler bei der Konfigurationsgenerierung" + es_ES: "Error de Generación de Configuración" + fr_FR: "Erreur de Génération de Configuration" + pl_PL: "Błąd Generowania Konfiguracji" + +error.parse-number: + en_US: "Number Parsing Error" + de_DE: "Fehler beim Analysieren der Zahl" + es_ES: "Error al Analizar Número" + fr_FR: "Erreur d'Analyse de Nombre" + pl_PL: "Błąd Parsowania Liczby" + +error.database: + en_US: "Database Error" + de_DE: "Datenbankfehler" + es_ES: "Error de Base de Datos" + fr_FR: "Erreur de Base de Données" + pl_PL: "Błąd Bazy Danych" + +error.invalid-id: + en_US: "Invalid ID" + de_DE: "Ungültige ID" + es_ES: "ID Inválido" + fr_FR: "ID Invalide" + pl_PL: "Nieprawidłowe ID" + +error.invalid-signature: + en_US: "Invalid Signature" + de_DE: "Ungültige Signatur" + es_ES: "Firma Inválida" + fr_FR: "Signature Invalide" + pl_PL: "Nieprawidłowy Podpis" + +error.backup: + en_US: "Backup Error" + de_DE: "Sicherungsfehler" + es_ES: "Error de Copia de Seguridad" + fr_FR: "Erreur de Sauvegarde" + pl_PL: "Błąd Kopii Zapasowej" + +error.restore: + en_US: "Restore Error" + de_DE: "Wiederherstellungsfehler" + es_ES: "Error de Restauración" + fr_FR: "Erreur de Restauration" + pl_PL: "Błąd Przywracania" + +error.authorization: + en_US: "Unauthorized" + de_DE: "Nicht Autorisiert" + es_ES: "No Autorizado" + fr_FR: "Non Autorisé" + pl_PL: "Brak Autoryzacji" + +error.auto-configure: + en_US: "Auto-Configure Error" + de_DE: "Fehler bei der Automatischen Konfiguration" + es_ES: "Error de Configuración Automática" + fr_FR: "Erreur de Configuration Automatique" + pl_PL: "Błąd Automatycznej Konfiguracji" + +error.action: + en_US: "Action Failed" + de_DE: "Aktion Fehlgeschlagen" + es_ES: "Acción Fallida" + fr_FR: "Action Échouée" + pl_PL: "Akcja Nie Powiodła Się" + +error.rate-limited: + en_US: "Rate Limited" + de_DE: "Ratenlimit Erreicht" + es_ES: "Límite de Velocidad Alcanzado" + fr_FR: "Limite de Débit Atteinte" + pl_PL: "Przekroczono Limit Częstotliwości" + +error.invalid-request: + en_US: "Invalid Request" + de_DE: "Ungültige Anfrage" + es_ES: "Solicitud Inválida" + fr_FR: "Requête Invalide" + pl_PL: "Nieprawidłowe Żądanie" + +error.migration-failed: + en_US: "Migration Failed" + de_DE: "Migration Fehlgeschlagen" + es_ES: "Migración Fallida" + fr_FR: "Migration Échouée" + pl_PL: "Migracja Nie Powiodła Się" + +error.uninitialized: + en_US: "Uninitialized" + de_DE: "Nicht Initialisiert" + es_ES: "No Inicializado" + fr_FR: "Non Initialisé" + pl_PL: "Niezainicjalizowany" + +error.parse-net-address: + en_US: "Net Address Parsing Error" + de_DE: "Fehler beim Analysieren der Netzwerkadresse" + es_ES: "Error al Analizar Dirección de Red" + fr_FR: "Erreur d'Analyse d'Adresse Réseau" + pl_PL: "Błąd Parsowania Adresu Sieciowego" + +error.parse-ssh-key: + en_US: "SSH Key Parsing Error" + de_DE: "Fehler beim Analysieren des SSH-Schlüssels" + es_ES: "Error al Analizar Clave SSH" + fr_FR: "Erreur d'Analyse de Clé SSH" + pl_PL: "Błąd Parsowania Klucza SSH" + +error.sound-error: + en_US: "Sound Interface Error" + de_DE: "Schnittstellen-Soundfehler" + es_ES: "Error de Interfaz de Sonido" + fr_FR: "Erreur d'Interface Audio" + pl_PL: "Błąd Interfejsu Dźwiękowego" + +error.parse-timestamp: + en_US: "Timestamp Parsing Error" + de_DE: "Fehler beim Analysieren des Zeitstempels" + es_ES: "Error al Analizar Marca de Tiempo" + fr_FR: "Erreur d'Analyse d'Horodatage" + pl_PL: "Błąd Parsowania Znacznika Czasu" + +error.parse-sys-info: + en_US: "System Info Parsing Error" + de_DE: "Fehler beim Analysieren der Systeminfo" + es_ES: "Error al Analizar Información del Sistema" + fr_FR: "Erreur d'Analyse des Infos Système" + pl_PL: "Błąd Parsowania Informacji o Systemie" + +error.wifi: + en_US: "WiFi Internal Error" + de_DE: "Interner WiFi-Fehler" + es_ES: "Error Interno de WiFi" + fr_FR: "Erreur Interne WiFi" + pl_PL: "Wewnętrzny Błąd WiFi" + +error.journald: + en_US: "Journald Error" + de_DE: "Journald-Fehler" + es_ES: "Error de Journald" + fr_FR: "Erreur Journald" + pl_PL: "Błąd Journald" + +error.disk-management: + en_US: "Disk Management Error" + de_DE: "Festplattenverwaltungsfehler" + es_ES: "Error de Gestión de Disco" + fr_FR: "Erreur de Gestion de Disque" + pl_PL: "Błąd Zarządzania Dyskiem" + +error.openssl: + en_US: "OpenSSL Internal Error" + de_DE: "Interner OpenSSL-Fehler" + es_ES: "Error Interno de OpenSSL" + fr_FR: "Erreur Interne OpenSSL" + pl_PL: "Wewnętrzny Błąd OpenSSL" + +error.password-hash-generation: + en_US: "Password Hash Generation Error" + de_DE: "Fehler bei der Passwort-Hash-Generierung" + es_ES: "Error de Generación de Hash de Contraseña" + fr_FR: "Erreur de Génération de Hash de Mot de Passe" + pl_PL: "Błąd Generowania Skrótu Hasła" + +error.diagnostic-mode: + en_US: "Server is in Diagnostic Mode" + de_DE: "Server Befindet Sich im Diagnosemodus" + es_ES: "El Servidor Está en Modo de Diagnóstico" + fr_FR: "Le Serveur Est en Mode Diagnostic" + pl_PL: "Serwer Jest w Trybie Diagnostycznym" + +error.parse-db-field: + en_US: "Database Field Parse Error" + de_DE: "Fehler beim Analysieren des Datenbankfelds" + es_ES: "Error al Analizar Campo de Base de Datos" + fr_FR: "Erreur d'Analyse de Champ de Base de Données" + pl_PL: "Błąd Parsowania Pola Bazy Danych" + +error.duplicate: + en_US: "Duplication Error" + de_DE: "Duplikatfehler" + es_ES: "Error de Duplicación" + fr_FR: "Erreur de Duplication" + pl_PL: "Błąd Duplikacji" + +error.multiple-errors: + en_US: "Multiple Errors" + de_DE: "Mehrere Fehler" + es_ES: "Múltiples Errores" + fr_FR: "Erreurs Multiples" + pl_PL: "Wiele Błędów" + +error.incoherent: + en_US: "Incoherent" + de_DE: "Inkohärent" + es_ES: "Incoherente" + fr_FR: "Incohérent" + pl_PL: "Niespójny" + +error.invalid-backup-target-id: + en_US: "Invalid Backup Target ID" + de_DE: "Ungültige Sicherungsziel-ID" + es_ES: "ID de Destino de Copia de Seguridad Inválido" + fr_FR: "ID de Cible de Sauvegarde Invalide" + pl_PL: "Nieprawidłowe ID Celu Kopii Zapasowej" + +error.product-key-mismatch: + en_US: "Incompatible Product Keys" + de_DE: "Inkompatible Produktschlüssel" + es_ES: "Claves de Producto Incompatibles" + fr_FR: "Clés de Produit Incompatibles" + pl_PL: "Niekompatybilne Klucze Produktu" + +error.lan-port-conflict: + en_US: "Incompatible LAN Port Configuration" + de_DE: "Inkompatible LAN-Port-Konfiguration" + es_ES: "Configuración de Puerto LAN Incompatible" + fr_FR: "Configuration de Port LAN Incompatible" + pl_PL: "Niekompatybilna Konfiguracja Portu LAN" + +error.javascript: + en_US: "Javascript Engine Error" + de_DE: "JavaScript-Engine-Fehler" + es_ES: "Error del Motor JavaScript" + fr_FR: "Erreur du Moteur JavaScript" + pl_PL: "Błąd Silnika JavaScript" + +error.pem: + en_US: "PEM Encoding Error" + de_DE: "PEM-Kodierungsfehler" + es_ES: "Error de Codificación PEM" + fr_FR: "Erreur d'Encodage PEM" + pl_PL: "Błąd Kodowania PEM" + +error.tls-init: + en_US: "TLS Backend Initialization Error" + de_DE: "TLS-Backend-Initialisierungsfehler" + es_ES: "Error de Inicialización del Backend TLS" + fr_FR: "Erreur d'Initialisation du Backend TLS" + pl_PL: "Błąd Inicjalizacji Backendu TLS" + +error.ascii: + en_US: "ASCII Parse Error" + de_DE: "ASCII-Analysefehler" + es_ES: "Error de Análisis ASCII" + fr_FR: "Erreur d'Analyse ASCII" + pl_PL: "Błąd Parsowania ASCII" + +error.missing-header: + en_US: "Missing Header" + de_DE: "Fehlender Header" + es_ES: "Encabezado Faltante" + fr_FR: "En-Tête Manquant" + pl_PL: "Brakujący Nagłówek" + +error.grub: + en_US: "Grub Error" + de_DE: "Grub-Fehler" + es_ES: "Error de Grub" + fr_FR: "Erreur Grub" + pl_PL: "Błąd Grub" + +error.systemd: + en_US: "Systemd Error" + de_DE: "Systemd-Fehler" + es_ES: "Error de Systemd" + fr_FR: "Erreur Systemd" + pl_PL: "Błąd Systemd" + +error.openssh: + en_US: "OpenSSH Error" + de_DE: "OpenSSH-Fehler" + es_ES: "Error de OpenSSH" + fr_FR: "Erreur OpenSSH" + pl_PL: "Błąd OpenSSH" + +error.zram: + en_US: "Zram Error" + de_DE: "Zram-Fehler" + es_ES: "Error de Zram" + fr_FR: "Erreur Zram" + pl_PL: "Błąd Zram" + +error.lshw: + en_US: "LSHW Error" + de_DE: "LSHW-Fehler" + es_ES: "Error de LSHW" + fr_FR: "Erreur LSHW" + pl_PL: "Błąd LSHW" + +error.cpu-settings: + en_US: "CPU Settings Error" + de_DE: "CPU-Einstellungsfehler" + es_ES: "Error de Configuración de CPU" + fr_FR: "Erreur de Paramètres CPU" + pl_PL: "Błąd Ustawień CPU" + +error.firmware: + en_US: "Firmware Error" + de_DE: "Firmware-Fehler" + es_ES: "Error de Firmware" + fr_FR: "Erreur de Firmware" + pl_PL: "Błąd Firmware" + +error.timeout: + en_US: "Timeout Error" + de_DE: "Zeitüberschreitungsfehler" + es_ES: "Error de Tiempo de Espera" + fr_FR: "Erreur de Délai d'Attente" + pl_PL: "Błąd Przekroczenia Czasu" + +error.lxc: + en_US: "LXC Error" + de_DE: "LXC-Fehler" + es_ES: "Error de LXC" + fr_FR: "Erreur LXC" + pl_PL: "Błąd LXC" + +error.cancelled: + en_US: "Cancelled" + de_DE: "Abgebrochen" + es_ES: "Cancelado" + fr_FR: "Annulé" + pl_PL: "Anulowano" + +error.git: + en_US: "Git Error" + de_DE: "Git-Fehler" + es_ES: "Error de Git" + fr_FR: "Erreur Git" + pl_PL: "Błąd Git" + +error.dbus: + en_US: "DBus Error" + de_DE: "DBus-Fehler" + es_ES: "Error de DBus" + fr_FR: "Erreur DBus" + pl_PL: "Błąd DBus" + +error.install-failed: + en_US: "Install Failed" + de_DE: "Installation Fehlgeschlagen" + es_ES: "Instalación Fallida" + fr_FR: "Installation Échouée" + pl_PL: "Instalacja Nie Powiodła Się" + +error.update-failed: + en_US: "Update Failed" + de_DE: "Aktualisierung Fehlgeschlagen" + es_ES: "Actualización Fallida" + fr_FR: "Mise à Jour Échouée" + pl_PL: "Aktualizacja Nie Powiodła Się" + +error.smtp: + en_US: "SMTP Error" + de_DE: "SMTP-Fehler" + es_ES: "Error de SMTP" + fr_FR: "Erreur SMTP" + pl_PL: "Błąd SMTP" + +error.set-sys-info: + en_US: "Error Setting System Info" + de_DE: "Fehler beim Setzen der Systeminfo" + es_ES: "Error al Establecer Información del Sistema" + fr_FR: "Erreur de Définition des Infos Système" + pl_PL: "Błąd Ustawiania Informacji o Systemie" + +# disk/main.rs +disk.main.disk-not-found: + en_US: "StartOS disk not found." + de_DE: "StartOS-Festplatte nicht gefunden." + es_ES: "Disco StartOS no encontrado." + fr_FR: "Disque StartOS non trouvé." + pl_PL: "Nie znaleziono dysku StartOS." + +disk.main.incorrect-disk: + en_US: "A StartOS disk was found, but it is not the correct disk for this device." + de_DE: "Eine StartOS-Festplatte wurde gefunden, aber es ist nicht die richtige Festplatte für dieses Gerät." + es_ES: "Se encontró un disco StartOS, pero no es el disco correcto para este dispositivo." + fr_FR: "Un disque StartOS a été trouvé, mais ce n'est pas le bon disque pour cet appareil." + pl_PL: "Znaleziono dysk StartOS, ale nie jest to właściwy dysk dla tego urządzenia." + +# disk/util.rs +disk.util.not-canonical-block-device: + en_US: "not a canonical block device" + de_DE: "kein kanonisches Blockgerät" + es_ES: "no es un dispositivo de bloque canónico" + fr_FR: "pas un périphérique bloc canonique" + pl_PL: "nie jest kanonicznym urządzeniem blokowym" + +disk.util.could-not-get-partition-table: + en_US: "Could not get partition table of %{disk}: %{error}" + de_DE: "Konnte Partitionstabelle von %{disk} nicht abrufen: %{error}" + es_ES: "No se pudo obtener la tabla de particiones de %{disk}: %{error}" + fr_FR: "Impossible d'obtenir la table de partitions de %{disk} : %{error}" + pl_PL: "Nie można pobrać tablicy partycji %{disk}: %{error}" + +disk.util.could-not-get-vendor: + en_US: "Could not get vendor of %{disk}: %{error}" + de_DE: "Konnte Hersteller von %{disk} nicht abrufen: %{error}" + es_ES: "No se pudo obtener el fabricante de %{disk}: %{error}" + fr_FR: "Impossible d'obtenir le fabricant de %{disk} : %{error}" + pl_PL: "Nie można pobrać producenta %{disk}: %{error}" + +disk.util.could-not-get-model: + en_US: "Could not get model of %{disk}: %{error}" + de_DE: "Konnte Modell von %{disk} nicht abrufen: %{error}" + es_ES: "No se pudo obtener el modelo de %{disk}: %{error}" + fr_FR: "Impossible d'obtenir le modèle de %{disk} : %{error}" + pl_PL: "Nie można pobrać modelu %{disk}: %{error}" + +disk.util.could-not-get-capacity: + en_US: "Could not get capacity of %{disk}: %{error}" + de_DE: "Konnte Kapazität von %{disk} nicht abrufen: %{error}" + es_ES: "No se pudo obtener la capacidad de %{disk}: %{error}" + fr_FR: "Impossible d'obtenir la capacité de %{disk} : %{error}" + pl_PL: "Nie można pobrać pojemności %{disk}: %{error}" + +disk.util.could-not-get-label: + en_US: "Could not get label of %{part}: %{error}" + de_DE: "Konnte Bezeichnung von %{part} nicht abrufen: %{error}" + es_ES: "No se pudo obtener la etiqueta de %{part}: %{error}" + fr_FR: "Impossible d'obtenir l'étiquette de %{part} : %{error}" + pl_PL: "Nie można pobrać etykiety %{part}: %{error}" + +disk.util.could-not-get-capacity-part: + en_US: "Could not get capacity of %{part}: %{error}" + de_DE: "Konnte Kapazität von %{part} nicht abrufen: %{error}" + es_ES: "No se pudo obtener la capacidad de %{part}: %{error}" + fr_FR: "Impossible d'obtenir la capacité de %{part} : %{error}" + pl_PL: "Nie można pobrać pojemności %{part}: %{error}" + +disk.util.could-not-collect-usage-info: + en_US: "Could not collect usage information: %{error}" + de_DE: "Konnte Nutzungsinformationen nicht sammeln: %{error}" + es_ES: "No se pudo recopilar información de uso: %{error}" + fr_FR: "Impossible de collecter les informations d'utilisation : %{error}" + pl_PL: "Nie można zebrać informacji o użyciu: %{error}" + +disk.util.could-not-get-usage: + en_US: "Could not get usage of %{part}: %{error}" + de_DE: "Konnte Nutzung von %{part} nicht abrufen: %{error}" + es_ES: "No se pudo obtener el uso de %{part}: %{error}" + fr_FR: "Impossible d'obtenir l'utilisation de %{part} : %{error}" + pl_PL: "Nie można pobrać użycia %{part}: %{error}" + +disk.util.error-fetching-backup-metadata: + en_US: "Error fetching unencrypted backup metadata: %{error}" + de_DE: "Fehler beim Abrufen der unverschlüsselten Sicherungsmetadaten: %{error}" + es_ES: "Error al obtener metadatos de copia de seguridad sin cifrar: %{error}" + fr_FR: "Erreur lors de la récupération des métadonnées de sauvegarde non chiffrées : %{error}" + pl_PL: "Błąd pobierania niezaszyfrowanych metadanych kopii zapasowej: %{error}" + +disk.util.error-unmounting-partition: + en_US: "Error unmounting partition %{part}: %{error}" + de_DE: "Fehler beim Aushängen der Partition %{part}: %{error}" + es_ES: "Error al desmontar la partición %{part}: %{error}" + fr_FR: "Erreur lors du démontage de la partition %{part} : %{error}" + pl_PL: "Błąd odmontowywania partycji %{part}: %{error}" + +disk.util.failed-to-parse-pvscan: + en_US: "Failed to parse pvscan output line: %{line}" + de_DE: "Fehler beim Analysieren der pvscan-Ausgabezeile: %{line}" + es_ES: "Error al analizar la línea de salida de pvscan: %{line}" + fr_FR: "Échec de l'analyse de la ligne de sortie pvscan : %{line}" + pl_PL: "Nie udało się przeanalizować linii wyjścia pvscan: %{line}" + +# disk/fsck/ext4.rs +disk.fsck.process-terminated-by-signal: + en_US: "e2fsck: process terminated by signal" + de_DE: "e2fsck: Prozess durch Signal beendet" + es_ES: "e2fsck: proceso terminado por señal" + fr_FR: "e2fsck : processus terminé par un signal" + pl_PL: "e2fsck: proces zakończony sygnałem" + +disk.fsck.errors-not-corrected: + en_US: "some filesystem errors NOT corrected on %{device}:\n%{stderr}" + de_DE: "einige Dateisystemfehler wurden NICHT auf %{device} korrigiert:\n%{stderr}" + es_ES: "algunos errores del sistema de archivos NO se corrigieron en %{device}:\n%{stderr}" + fr_FR: "certaines erreurs du système de fichiers NON corrigées sur %{device} :\n%{stderr}" + pl_PL: "niektóre błędy systemu plików NIE zostały naprawione na %{device}:\n%{stderr}" + +disk.fsck.errors-corrected: + en_US: "filesystem errors corrected on %{device}:\n%{stderr}" + de_DE: "Dateisystemfehler auf %{device} korrigiert:\n%{stderr}" + es_ES: "errores del sistema de archivos corregidos en %{device}:\n%{stderr}" + fr_FR: "erreurs du système de fichiers corrigées sur %{device} :\n%{stderr}" + pl_PL: "błędy systemu plików naprawione na %{device}:\n%{stderr}" + +disk.fsck.reboot-required: + en_US: "reboot required" + de_DE: "Neustart erforderlich" + es_ES: "reinicio requerido" + fr_FR: "redémarrage requis" + pl_PL: "wymagany restart" + +disk.fsck.e2fsck-error: + en_US: "e2fsck: %{stderr}" + de_DE: "e2fsck: %{stderr}" + es_ES: "e2fsck: %{stderr}" + fr_FR: "e2fsck : %{stderr}" + pl_PL: "e2fsck: %{stderr}" + +# disk/mount/util.rs +disk.mount.binding: + en_US: "Binding %{src} to %{dst}" + de_DE: "Binde %{src} an %{dst}" + es_ES: "Vinculando %{src} a %{dst}" + fr_FR: "Liaison de %{src} à %{dst}" + pl_PL: "Wiązanie %{src} do %{dst}" + +# init.rs +init.running-preinit: + en_US: "Running preinit.sh" + de_DE: "Führe preinit.sh aus" + es_ES: "Ejecutando preinit.sh" + fr_FR: "Exécution de preinit.sh" + pl_PL: "Uruchamianie preinit.sh" + +init.enabling-local-auth: + en_US: "Enabling local authentication" + de_DE: "Aktiviere lokale Authentifizierung" + es_ES: "Habilitando autenticación local" + fr_FR: "Activation de l'authentification locale" + pl_PL: "Włączanie lokalnego uwierzytelniania" + +init.loading-database: + en_US: "Loading database" + de_DE: "Lade Datenbank" + es_ES: "Cargando base de datos" + fr_FR: "Chargement de la base de données" + pl_PL: "Ładowanie bazy danych" + +init.loading-ssh-keys: + en_US: "Loading SSH Keys" + de_DE: "Lade SSH-Schlüssel" + es_ES: "Cargando claves SSH" + fr_FR: "Chargement des clés SSH" + pl_PL: "Ładowanie kluczy SSH" + +init.starting-network-controller: + en_US: "Starting network controller" + de_DE: "Starte Netzwerkcontroller" + es_ES: "Iniciando controlador de red" + fr_FR: "Démarrage du contrôleur réseau" + pl_PL: "Uruchamianie kontrolera sieci" + +init.switching-logs-to-data-drive: + en_US: "Switching logs to write to data drive" + de_DE: "Wechsle Protokolle auf Datenlaufwerk" + es_ES: "Cambiando registros para escribir en unidad de datos" + fr_FR: "Basculement des journaux vers le disque de données" + pl_PL: "Przełączanie logów na dysk danych" + +init.loading-ca-certificate: + en_US: "Loading CA certificate" + de_DE: "Lade CA-Zertifikat" + es_ES: "Cargando certificado CA" + fr_FR: "Chargement du certificat CA" + pl_PL: "Ładowanie certyfikatu CA" + +init.loading-wifi-configuration: + en_US: "Loading WiFi configuration" + de_DE: "Lade WiFi-Konfiguration" + es_ES: "Cargando configuración WiFi" + fr_FR: "Chargement de la configuration WiFi" + pl_PL: "Ładowanie konfiguracji WiFi" + +init.initializing-temporary-files: + en_US: "Initializing temporary files" + de_DE: "Initialisiere temporäre Dateien" + es_ES: "Inicializando archivos temporales" + fr_FR: "Initialisation des fichiers temporaires" + pl_PL: "Inicjalizowanie plików tymczasowych" + +init.setting-cpu-performance-profile: + en_US: "Setting CPU performance profile" + de_DE: "Setze CPU-Leistungsprofil" + es_ES: "Configurando perfil de rendimiento de CPU" + fr_FR: "Configuration du profil de performance CPU" + pl_PL: "Ustawianie profilu wydajności CPU" + +init.synchronizing-system-clock: + en_US: "Synchronizing system clock" + de_DE: "Synchronisiere Systemuhr" + es_ES: "Sincronizando reloj del sistema" + fr_FR: "Synchronisation de l'horloge système" + pl_PL: "Synchronizowanie zegara systemowego" + +init.enabling-zram: + en_US: "Enabling ZRAM" + de_DE: "Aktiviere ZRAM" + es_ES: "Habilitando ZRAM" + fr_FR: "Activation de ZRAM" + pl_PL: "Włączanie ZRAM" + +init.updating-server-info: + en_US: "Updating server info" + de_DE: "Aktualisiere Serverinformationen" + es_ES: "Actualizando información del servidor" + fr_FR: "Mise à jour des informations du serveur" + pl_PL: "Aktualizowanie informacji o serwerze" + +init.launching-service-intranet: + en_US: "Launching service intranet" + de_DE: "Starte Service-Intranet" + es_ES: "Iniciando intranet de servicios" + fr_FR: "Lancement de l'intranet de services" + pl_PL: "Uruchamianie intranetu usług" + +init.validating-database: + en_US: "Validating database" + de_DE: "Validiere Datenbank" + es_ES: "Validando base de datos" + fr_FR: "Validation de la base de données" + pl_PL: "Walidowanie bazy danych" + +init.running-postinit: + en_US: "Running postinit.sh" + de_DE: "Führe postinit.sh aus" + es_ES: "Ejecutando postinit.sh" + fr_FR: "Exécution de postinit.sh" + pl_PL: "Uruchamianie postinit.sh" + +init.error-running-script: + en_US: "Error Running %{script}: %{error}" + de_DE: "Fehler beim Ausführen von %{script}: %{error}" + es_ES: "Error al ejecutar %{script}: %{error}" + fr_FR: "Erreur lors de l'exécution de %{script} : %{error}" + pl_PL: "Błąd uruchamiania %{script}: %{error}" + +init.cpu-governor-not-available: + en_US: 'CPU Governor "%{governor}" Not Available' + de_DE: 'CPU-Governor "%{governor}" nicht verfügbar' + es_ES: 'CPU Governor "%{governor}" no disponible' + fr_FR: 'Gouverneur CPU "%{governor}" non disponible' + pl_PL: 'Governor CPU "%{governor}" niedostępny' + +init.setting-cpu-governor: + en_US: 'Setting CPU Governor to "%{governor}"' + de_DE: 'Setze CPU-Governor auf "%{governor}"' + es_ES: 'Configurando CPU Governor a "%{governor}"' + fr_FR: 'Configuration du gouverneur CPU à "%{governor}"' + pl_PL: 'Ustawianie governora CPU na "%{governor}"' + +init.clock-sync-timeout: + en_US: "Timed out waiting for system time to synchronize" + de_DE: "Zeitüberschreitung beim Warten auf Systemzeitsynchronisierung" + es_ES: "Tiempo de espera agotado esperando sincronización de hora del sistema" + fr_FR: "Délai d'attente dépassé pour la synchronisation de l'heure système" + pl_PL: "Przekroczono limit czasu oczekiwania na synchronizację czasu systemowego" + +init.enabled-zram: + en_US: "Enabled ZRAM" + de_DE: "ZRAM aktiviert" + es_ES: "ZRAM habilitado" + fr_FR: "ZRAM activé" + pl_PL: "Włączono ZRAM" + +init.system-initialized: + en_US: "System initialized." + de_DE: "System initialisiert." + es_ES: "Sistema inicializado." + fr_FR: "Système initialisé." + pl_PL: "System zainicjalizowany." + +init.error-closing-websocket: + en_US: "error closing init progress websocket: %{error}" + de_DE: "Fehler beim Schließen des Init-Fortschritts-WebSockets: %{error}" + es_ES: "error al cerrar websocket de progreso de inicio: %{error}" + fr_FR: "erreur lors de la fermeture du websocket de progression d'init : %{error}" + pl_PL: "błąd zamykania websocketu postępu inicjalizacji: %{error}" + +init.initializing: + en_US: "Initializing..." + de_DE: "Initialisiere..." + es_ES: "Inicializando..." + fr_FR: "Initialisation..." + pl_PL: "Inicjalizowanie..." + +# backup/backup_bulk.rs +backup.bulk.complete-title: + en_US: "Backup Complete" + de_DE: "Sicherung abgeschlossen" + es_ES: "Copia de seguridad completada" + fr_FR: "Sauvegarde terminée" + pl_PL: "Kopia zapasowa zakończona" + +backup.bulk.complete-message: + en_US: "Your backup has completed" + de_DE: "Ihre Sicherung wurde abgeschlossen" + es_ES: "Su copia de seguridad se ha completado" + fr_FR: "Votre sauvegarde est terminée" + pl_PL: "Twoja kopia zapasowa została zakończona" + +backup.bulk.complete-with-failures: + en_US: "Your backup has completed, but some package(s) failed to backup" + de_DE: "Ihre Sicherung wurde abgeschlossen, aber einige Pakete konnten nicht gesichert werden" + es_ES: "Su copia de seguridad se completó, pero algunos paquetes fallaron" + fr_FR: "Votre sauvegarde est terminée, mais certains paquets n'ont pas pu être sauvegardés" + pl_PL: "Twoja kopia zapasowa została zakończona, ale niektóre pakiety nie zostały zapisane" + +backup.bulk.failed-error: + en_US: "Backup Failed: %{error}" + de_DE: "Sicherung fehlgeschlagen: %{error}" + es_ES: "Copia de seguridad fallida: %{error}" + fr_FR: "Échec de la sauvegarde : %{error}" + pl_PL: "Kopia zapasowa nie powiodła się: %{error}" + +backup.bulk.failed-title: + en_US: "Backup Failed" + de_DE: "Sicherung fehlgeschlagen" + es_ES: "Copia de seguridad fallida" + fr_FR: "Échec de la sauvegarde" + pl_PL: "Kopia zapasowa nie powiodła się" + +backup.bulk.failed-message: + en_US: "Your backup failed to complete." + de_DE: "Ihre Sicherung konnte nicht abgeschlossen werden." + es_ES: "Su copia de seguridad no se pudo completar." + fr_FR: "Votre sauvegarde n'a pas pu être terminée." + pl_PL: "Twoja kopia zapasowa nie została ukończona." + +backup.bulk.already-backing-up: + en_US: "Server is already backing up!" + de_DE: "Server führt bereits eine Sicherung durch!" + es_ES: "¡El servidor ya está realizando una copia de seguridad!" + fr_FR: "Le serveur est déjà en cours de sauvegarde !" + pl_PL: "Serwer już wykonuje kopię zapasową!" + +backup.bulk.leaked-reference: + en_US: "leaked reference to BackupMountGuard" + de_DE: "Leckreferenz auf BackupMountGuard" + es_ES: "referencia filtrada a BackupMountGuard" + fr_FR: "référence fuitée vers BackupMountGuard" + pl_PL: "wyciekła referencja do BackupMountGuard" + +# backup/restore.rs +backup.restore.package-error: + en_US: "Error restoring package %{id}: %{error}" + de_DE: "Fehler beim Wiederherstellen des Pakets %{id}: %{error}" + es_ES: "Error al restaurar el paquete %{id}: %{error}" + fr_FR: "Erreur lors de la restauration du paquet %{id} : %{error}" + pl_PL: "Błąd przywracania pakietu %{id}: %{error}" + +# backup/target/cifs.rs +backup.target.cifs.target-not-found: + en_US: "Backup Target ID %{id} Not Found" + de_DE: "Sicherungsziel-ID %{id} nicht gefunden" + es_ES: "ID de destino de copia de seguridad %{id} no encontrado" + fr_FR: "ID de cible de sauvegarde %{id} non trouvé" + pl_PL: "Nie znaleziono ID celu kopii zapasowej %{id}" + +backup.target.cifs.target-not-found-id: + en_US: "Backup Target ID %{id} Not Found" + de_DE: "Sicherungsziel-ID %{id} nicht gefunden" + es_ES: "ID de destino de copia de seguridad %{id} no encontrado" + fr_FR: "ID de cible de sauvegarde %{id} non trouvé" + pl_PL: "Nie znaleziono ID celu kopii zapasowej %{id}" + +# net/ssl.rs +net.ssl.unreachable: + en_US: "unreachable" + de_DE: "unerreichbar" + es_ES: "inalcanzable" + fr_FR: "inaccessible" + pl_PL: "nieosiągalny" + +# net/tor/arti.rs +net.tor.invalid-ed25519-key: + en_US: "invalid ed25519 expanded secret key" + de_DE: "ungültiger erweiterter ed25519-Geheimschlüssel" + es_ES: "clave secreta ed25519 expandida inválida" + fr_FR: "clé secrète ed25519 étendue invalide" + pl_PL: "nieprawidłowy rozszerzony klucz tajny ed25519" + +net.tor.bootstrap-no-progress: + en_US: "Bootstrap has not made progress for %{duration}" + de_DE: "Bootstrap hat seit %{duration} keinen Fortschritt gemacht" + es_ES: "Bootstrap no ha progresado durante %{duration}" + fr_FR: "Bootstrap n'a pas progressé depuis %{duration}" + pl_PL: "Bootstrap nie poczynił postępów przez %{duration}" + +net.tor.bootstrap-error: + en_US: "Tor Bootstrap Error: %{error}" + de_DE: "Tor-Bootstrap-Fehler: %{error}" + es_ES: "Error de Bootstrap de Tor: %{error}" + fr_FR: "Erreur de Bootstrap Tor : %{error}" + pl_PL: "Błąd Bootstrap Tor: %{error}" + +net.tor.health-error: + en_US: "Tor Health Error: %{error}" + de_DE: "Tor-Gesundheitsfehler: %{error}" + es_ES: "Error de salud de Tor: %{error}" + fr_FR: "Erreur de santé Tor : %{error}" + pl_PL: "Błąd kondycji Tor: %{error}" + +net.tor.status-stream-ended: + en_US: "status event stream ended" + de_DE: "Status-Ereignisstrom beendet" + es_ES: "flujo de eventos de estado terminado" + fr_FR: "flux d'événements de statut terminé" + pl_PL: "strumień zdarzeń statusu zakończony" + +net.tor.client-health-error: + en_US: "Tor Client Health Error: %{error}" + de_DE: "Tor-Client-Gesundheitsfehler: %{error}" + es_ES: "Error de salud del cliente Tor: %{error}" + fr_FR: "Erreur de santé du client Tor : %{error}" + pl_PL: "Błąd kondycji klienta Tor: %{error}" + +net.tor.health-check-failed-recycling: + en_US: "Client failed health check %{count} times, recycling" + de_DE: "Client hat Gesundheitsprüfung %{count} Mal nicht bestanden, wird recycelt" + es_ES: "El cliente falló la verificación de salud %{count} veces, reciclando" + fr_FR: "Le client a échoué au bilan de santé %{count} fois, recyclage" + pl_PL: "Klient nie przeszedł sprawdzenia kondycji %{count} razy, ponowne uruchamianie" + +net.tor.bootstrapper-error: + en_US: "Tor Bootstrapper Error: %{error}" + de_DE: "Tor-Bootstrapper-Fehler: %{error}" + es_ES: "Error del Bootstrapper de Tor: %{error}" + fr_FR: "Erreur du Bootstrapper Tor : %{error}" + pl_PL: "Błąd Bootstrappera Tor: %{error}" + +net.tor.client-creation-error: + en_US: "Tor Client Creation Error: %{error}" + de_DE: "Fehler bei der Erstellung des Tor-Clients: %{error}" + es_ES: "Error de creación del cliente Tor: %{error}" + fr_FR: "Erreur de création du client Tor : %{error}" + pl_PL: "Błąd tworzenia klienta Tor: %{error}" + +net.tor.failed-to-set-tcp-keepalive: + en_US: "Failed to set tcp keepalive: %{error}" + de_DE: "Fehler beim Setzen von TCP-Keepalive: %{error}" + es_ES: "Error al configurar tcp keepalive: %{error}" + fr_FR: "Échec de la configuration de tcp keepalive : %{error}" + pl_PL: "Nie udało się ustawić tcp keepalive: %{error}" + +net.tor.client-error: + en_US: "Tor Client Error: %{error}" + de_DE: "Tor-Client-Fehler: %{error}" + es_ES: "Error del cliente Tor: %{error}" + fr_FR: "Erreur du client Tor : %{error}" + pl_PL: "Błąd klienta Tor: %{error}" + +# net/wifi.rs +net.wifi.ssid-no-special-characters: + en_US: "SSID may not have special characters" + de_DE: "SSID darf keine Sonderzeichen enthalten" + es_ES: "El SSID no puede tener caracteres especiales" + fr_FR: "Le SSID ne peut pas contenir de caractères spéciaux" + pl_PL: "SSID nie może zawierać znaków specjalnych" + +net.wifi.password-no-special-characters: + en_US: "WiFi Password may not have special characters" + de_DE: "WiFi-Passwort darf keine Sonderzeichen enthalten" + es_ES: "La contraseña WiFi no puede tener caracteres especiales" + fr_FR: "Le mot de passe WiFi ne peut pas contenir de caractères spéciaux" + pl_PL: "Hasło WiFi nie może zawierać znaków specjalnych" + +net.wifi.adding-network: + en_US: "Adding new WiFi network: '%{ssid}'" + de_DE: "Füge neues WiFi-Netzwerk hinzu: '%{ssid}'" + es_ES: "Añadiendo nueva red WiFi: '%{ssid}'" + fr_FR: "Ajout d'un nouveau réseau WiFi : '%{ssid}'" + pl_PL: "Dodawanie nowej sieci WiFi: '%{ssid}'" + +net.wifi.no-interface-available: + en_US: "No WiFi interface available" + de_DE: "Keine WiFi-Schnittstelle verfügbar" + es_ES: "No hay interfaz WiFi disponible" + fr_FR: "Aucune interface WiFi disponible" + pl_PL: "Brak dostępnego interfejsu WiFi" + +net.wifi.failed-to-add-network: + en_US: "Failed to add new WiFi network '%{ssid}': %{error}" + de_DE: "Fehler beim Hinzufügen des neuen WiFi-Netzwerks '%{ssid}': %{error}" + es_ES: "Error al añadir nueva red WiFi '%{ssid}': %{error}" + fr_FR: "Échec de l'ajout du nouveau réseau WiFi '%{ssid}' : %{error}" + pl_PL: "Nie udało się dodać nowej sieci WiFi '%{ssid}': %{error}" + +net.wifi.failed-adding: + en_US: "Failed adding %{ssid}" + de_DE: "Fehler beim Hinzufügen von %{ssid}" + es_ES: "Error al añadir %{ssid}" + fr_FR: "Échec de l'ajout de %{ssid}" + pl_PL: "Nie udało się dodać %{ssid}" + +net.wifi.connected-successfully: + en_US: "Successfully connected to WiFi: '%{ssid}'" + de_DE: "Erfolgreich mit WiFi verbunden: '%{ssid}'" + es_ES: "Conectado exitosamente a WiFi: '%{ssid}'" + fr_FR: "Connecté avec succès au WiFi : '%{ssid}'" + pl_PL: "Pomyślnie połączono z WiFi: '%{ssid}'" + +net.wifi.connection-failed: + en_US: "Failed to connect to WiFi: '%{ssid}'" + de_DE: "Verbindung zu WiFi fehlgeschlagen: '%{ssid}'" + es_ES: "Error al conectar a WiFi: '%{ssid}'" + fr_FR: "Échec de la connexion au WiFi : '%{ssid}'" + pl_PL: "Nie udało się połączyć z WiFi: '%{ssid}'" + +net.wifi.no-wifi-to-revert: + en_US: "No WiFi to revert to!" + de_DE: "Kein WiFi zum Zurücksetzen!" + es_ES: "¡No hay WiFi al que volver!" + fr_FR: "Aucun WiFi vers lequel revenir !" + pl_PL: "Brak WiFi do przywrócenia!" + +net.wifi.failed-to-connect: + en_US: "Failed to connect to WiFi network '%{ssid}': %{error}" + de_DE: "Verbindung zum WiFi-Netzwerk '%{ssid}' fehlgeschlagen: %{error}" + es_ES: "Error al conectar a la red WiFi '%{ssid}': %{error}" + fr_FR: "Échec de la connexion au réseau WiFi '%{ssid}' : %{error}" + pl_PL: "Nie udało się połączyć z siecią WiFi '%{ssid}': %{error}" + +net.wifi.cant-connect: + en_US: "Can't connect to %{ssid}" + de_DE: "Kann nicht mit %{ssid} verbinden" + es_ES: "No se puede conectar a %{ssid}" + fr_FR: "Impossible de se connecter à %{ssid}" + pl_PL: "Nie można połączyć się z %{ssid}" + +net.wifi.forbidden-delete-would-disconnect: + en_US: "Forbidden: Deleting this network would make your server unreachable. Either connect to ethernet or connect to a different WiFi network to remedy this." + de_DE: "Verboten: Das Löschen dieses Netzwerks würde Ihren Server unerreichbar machen. Verbinden Sie sich mit Ethernet oder einem anderen WiFi-Netzwerk, um dies zu beheben." + es_ES: "Prohibido: Eliminar esta red haría que su servidor sea inalcanzable. Conéctese a ethernet o a una red WiFi diferente para solucionar esto." + fr_FR: "Interdit : Supprimer ce réseau rendrait votre serveur inaccessible. Connectez-vous à ethernet ou à un autre réseau WiFi pour remédier à cela." + pl_PL: "Zabronione: Usunięcie tej sieci uczyniłoby serwer nieosiągalnym. Podłącz się przez ethernet lub do innej sieci WiFi, aby to naprawić." + +net.wifi.wont-change-country-without-ethernet: + en_US: "Won't change country without hardwire connection" + de_DE: "Länderwechsel ohne Kabelverbindung nicht möglich" + es_ES: "No cambiará el país sin conexión por cable" + fr_FR: "Ne changera pas de pays sans connexion filaire" + pl_PL: "Nie zmieni kraju bez połączenia kablowego" + +net.wifi.failed-to-set-interface: + en_US: "Failed to set interface %{interface} for %{ssid}" + de_DE: "Fehler beim Setzen der Schnittstelle %{interface} für %{ssid}" + es_ES: "Error al configurar la interfaz %{interface} para %{ssid}" + fr_FR: "Échec de la configuration de l'interface %{interface} pour %{ssid}" + pl_PL: "Nie udało się ustawić interfejsu %{interface} dla %{ssid}" + +net.wifi.could-not-find-country-config: + en_US: "Could not find a country config lines" + de_DE: "Länder-Konfigurationszeilen nicht gefunden" + es_ES: "No se pudieron encontrar líneas de configuración de país" + fr_FR: "Impossible de trouver les lignes de configuration de pays" + pl_PL: "Nie można znaleźć linii konfiguracji kraju" + +net.wifi.could-not-parse-country-config: + en_US: "Could not find a country config with regex" + de_DE: "Länder-Konfiguration mit Regex nicht gefunden" + es_ES: "No se pudo encontrar una configuración de país con regex" + fr_FR: "Impossible de trouver une configuration de pays avec regex" + pl_PL: "Nie można znaleźć konfiguracji kraju za pomocą regex" + +net.wifi.invalid-country-code: + en_US: "Invalid Country Code: %{country}" + de_DE: "Ungültiger Ländercode: %{country}" + es_ES: "Código de país inválido: %{country}" + fr_FR: "Code de pays invalide : %{country}" + pl_PL: "Nieprawidłowy kod kraju: %{country}" + +net.wifi.ssid-not-found: + en_US: "SSID Not Found" + de_DE: "SSID nicht gefunden" + es_ES: "SSID no encontrado" + fr_FR: "SSID non trouvé" + pl_PL: "Nie znaleziono SSID" + +net.wifi.setting-region: + en_US: "Setting the region" + de_DE: "Region wird gesetzt" + es_ES: "Configurando la región" + fr_FR: "Configuration de la région" + pl_PL: "Ustawianie regionu" + +net.wifi.setting-region-fallback: + en_US: "Setting the region fallback" + de_DE: "Regions-Fallback wird gesetzt" + es_ES: "Configurando la región de respaldo" + fr_FR: "Configuration de la région de secours" + pl_PL: "Ustawianie regionu awaryjnego" + +# net/forward.rs +net.forward.no-dynamic-ports-available: + en_US: "No more dynamic ports available!" + de_DE: "Keine dynamischen Ports mehr verfügbar!" + es_ES: "¡No hay más puertos dinámicos disponibles!" + fr_FR: "Plus de ports dynamiques disponibles !" + pl_PL: "Brak dostępnych portów dynamicznych!" + +net.forward.error-initializing-controller: + en_US: "error initializing PortForwardController: %{error}" + de_DE: "Fehler bei der Initialisierung des PortForwardControllers: %{error}" + es_ES: "error al inicializar PortForwardController: %{error}" + fr_FR: "erreur lors de l'initialisation de PortForwardController : %{error}" + pl_PL: "błąd inicjalizacji PortForwardController: %{error}" + +net.forward.mismatched-external-port: + en_US: "Mismatched external port in InterfaceForwardEntry" + de_DE: "Nicht übereinstimmender externer Port in InterfaceForwardEntry" + es_ES: "Puerto externo no coincide en InterfaceForwardEntry" + fr_FR: "Port externe incompatible dans InterfaceForwardEntry" + pl_PL: "Niezgodny port zewnętrzny w InterfaceForwardEntry" + +net.forward.controller-thread-exited: + en_US: "PortForwardController thread has exited" + de_DE: "PortForwardController-Thread wurde beendet" + es_ES: "El hilo PortForwardController ha terminado" + fr_FR: "Le thread PortForwardController s'est terminé" + pl_PL: "Wątek PortForwardController zakończył się" + +# net/gateway.rs +net.gateway.no-devices-returned: + en_US: "NetworkManager returned no devices. Trying again..." + de_DE: "NetworkManager hat keine Geräte zurückgegeben. Versuche erneut..." + es_ES: "NetworkManager no devolvió dispositivos. Intentando de nuevo..." + fr_FR: "NetworkManager n'a renvoyé aucun appareil. Nouvelle tentative..." + pl_PL: "NetworkManager nie zwrócił żadnych urządzeń. Ponowna próba..." + +net.gateway.failed-to-determine-wan-ip: + en_US: "Failed to determine WAN IP for %{iface}: %{error}" + de_DE: "WAN-IP für %{iface} konnte nicht ermittelt werden: %{error}" + es_ES: "Error al determinar IP WAN para %{iface}: %{error}" + fr_FR: "Échec de la détermination de l'IP WAN pour %{iface} : %{error}" + pl_PL: "Nie udało się określić IP WAN dla %{iface}: %{error}" + +net.gateway.error-loading-interface-info: + en_US: "Error loading network interface info: %{error}" + de_DE: "Fehler beim Laden der Netzwerkschnittstelleninformationen: %{error}" + es_ES: "Error al cargar información de interfaz de red: %{error}" + fr_FR: "Erreur lors du chargement des infos d'interface réseau : %{error}" + pl_PL: "Błąd ładowania informacji o interfejsie sieciowym: %{error}" + +net.gateway.error-syncing-ip-info: + en_US: "Error syncing ip info to db: %{error}" + de_DE: "Fehler bei der Synchronisierung der IP-Informationen zur Datenbank: %{error}" + es_ES: "Error al sincronizar info de IP a la base de datos: %{error}" + fr_FR: "Erreur de synchronisation des infos IP vers la BDD : %{error}" + pl_PL: "Błąd synchronizacji informacji IP z bazą danych: %{error}" + +net.gateway.cannot-forget-connected-interface: + en_US: "Cannot forget currently connected interface" + de_DE: "Aktuell verbundene Schnittstelle kann nicht vergessen werden" + es_ES: "No se puede olvidar la interfaz actualmente conectada" + fr_FR: "Impossible d'oublier l'interface actuellement connectée" + pl_PL: "Nie można zapomnieć aktualnie podłączonego interfejsu" + +net.gateway.cannot-delete-without-connection: + en_US: "Cannot delete device without active connection" + de_DE: "Gerät kann nicht ohne aktive Verbindung gelöscht werden" + es_ES: "No se puede eliminar el dispositivo sin conexión activa" + fr_FR: "Impossible de supprimer l'appareil sans connexion active" + pl_PL: "Nie można usunąć urządzenia bez aktywnego połączenia" + +# net/dns.rs +net.dns.timeout-updating-catalog: + en_US: "timed out waiting to update dns catalog" + de_DE: "Zeitüberschreitung beim Warten auf Aktualisierung des DNS-Katalogs" + es_ES: "tiempo de espera agotado esperando actualizar catálogo DNS" + fr_FR: "délai d'attente dépassé pour la mise à jour du catalogue DNS" + pl_PL: "przekroczono limit czasu oczekiwania na aktualizację katalogu DNS" + +net.dns.could-not-determine-source-interface: + en_US: "Could not determine source interface of %{src}" + de_DE: "Quellschnittstelle von %{src} konnte nicht ermittelt werden" + es_ES: "No se pudo determinar la interfaz de origen de %{src}" + fr_FR: "Impossible de déterminer l'interface source de %{src}" + pl_PL: "Nie można określić interfejsu źródłowego %{src}" + +net.dns.error-resolving-internal: + en_US: "Error resolving internal DNS: %{error}" + de_DE: "Fehler bei der Auflösung des internen DNS: %{error}" + es_ES: "Error al resolver DNS interno: %{error}" + fr_FR: "Erreur de résolution DNS interne : %{error}" + pl_PL: "Błąd rozwiązywania wewnętrznego DNS: %{error}" + +net.dns.server-thread-exited: + en_US: "DNS Server Thread has exited" + de_DE: "DNS-Server-Thread wurde beendet" + es_ES: "El hilo del servidor DNS ha terminado" + fr_FR: "Le thread du serveur DNS s'est terminé" + pl_PL: "Wątek serwera DNS zakończył się" + +# lxc/mod.rs +lxc.mod.rootfs-not-empty: + en_US: "rootfs is not empty, refusing to delete" + de_DE: "rootfs ist nicht leer, Löschung verweigert" + es_ES: "rootfs no está vacío, se rechaza eliminar" + fr_FR: "rootfs n'est pas vide, suppression refusée" + pl_PL: "rootfs nie jest pusty, odmowa usunięcia" + +lxc.mod.dhcp-timeout: + en_US: "Timed out waiting for container to acquire DHCP lease" + de_DE: "Zeitüberschreitung beim Warten auf DHCP-Lease für Container" + es_ES: "Tiempo de espera agotado esperando que el contenedor adquiera lease DHCP" + fr_FR: "Délai d'attente dépassé pour l'acquisition du bail DHCP par le conteneur" + pl_PL: "Przekroczono limit czasu oczekiwania na uzyskanie dzierżawy DHCP przez kontener" + +lxc.mod.command-failed: + en_US: "Command failed with exit code: %{code}\nMessage: %{message}" + de_DE: "Befehl mit Exit-Code fehlgeschlagen: %{code}\nNachricht: %{message}" + es_ES: "Comando falló con código de salida: %{code}\nMensaje: %{message}" + fr_FR: "La commande a échoué avec le code de sortie : %{code}\nMessage : %{message}" + pl_PL: "Polecenie nie powiodło się z kodem wyjścia: %{code}\nKomunikat: %{message}" + +lxc.mod.socket-timeout: + en_US: "timed out waiting for socket" + de_DE: "Zeitüberschreitung beim Warten auf Socket" + es_ES: "tiempo de espera agotado esperando socket" + fr_FR: "délai d'attente dépassé pour le socket" + pl_PL: "przekroczono limit czasu oczekiwania na gniazdo" + +lxc.mod.connected-to-socket: + en_US: "Connected to socket in %{elapsed}" + de_DE: "Mit Socket verbunden in %{elapsed}" + es_ES: "Conectado al socket en %{elapsed}" + fr_FR: "Connecté au socket en %{elapsed}" + pl_PL: "Połączono z gniazdem w %{elapsed}" + +lxc.mod.container-ungracefully-dropped: + en_US: "Container %{container} was ungracefully dropped. Cleaning up dangling containers..." + de_DE: "Container %{container} wurde unsauber beendet. Bereinige hängende Container..." + es_ES: "El contenedor %{container} fue eliminado abruptamente. Limpiando contenedores colgantes..." + fr_FR: "Le conteneur %{container} a été abandonné brutalement. Nettoyage des conteneurs orphelins..." + pl_PL: "Kontener %{container} został nieprawidłowo usunięty. Czyszczenie wiszących kontenerów..." + +lxc.mod.error-reading-crashed-logs: + en_US: "Error reading logs from crashed container: %{error}" + de_DE: "Fehler beim Lesen der Protokolle vom abgestürzten Container: %{error}" + es_ES: "Error al leer registros del contenedor caído: %{error}" + fr_FR: "Erreur de lecture des journaux du conteneur planté : %{error}" + pl_PL: "Błąd odczytu logów z uszkodzonego kontenera: %{error}" + +lxc.mod.error-cleaning-up-containers: + en_US: "Error cleaning up dangling LXC containers: %{error}" + de_DE: "Fehler bei der Bereinigung hängender LXC-Container: %{error}" + es_ES: "Error al limpiar contenedores LXC colgantes: %{error}" + fr_FR: "Erreur lors du nettoyage des conteneurs LXC orphelins : %{error}" + pl_PL: "Błąd czyszczenia wiszących kontenerów LXC: %{error}" + +lxc.mod.cleaned-up-containers: + en_US: "Successfully cleaned up dangling LXC containers" + de_DE: "Hängende LXC-Container erfolgreich bereinigt" + es_ES: "Contenedores LXC colgantes limpiados exitosamente" + fr_FR: "Conteneurs LXC orphelins nettoyés avec succès" + pl_PL: "Pomyślnie wyczyszczono wiszące kontenery LXC" + +# registry/admin.rs +registry.admin.unknown-signer: + en_US: "Unknown signer" + de_DE: "Unbekannter Unterzeichner" + es_ES: "Firmante desconocido" + fr_FR: "Signataire inconnu" + pl_PL: "Nieznany sygnatariusz" + +registry.admin.signer-already-exists: + en_US: "A signer %{guid} (%{name}) already exists with a matching key" + de_DE: "Ein Unterzeichner %{guid} (%{name}) existiert bereits mit einem passenden Schlüssel" + es_ES: "Un firmante %{guid} (%{name}) ya existe con una clave coincidente" + fr_FR: "Un signataire %{guid} (%{name}) existe déjà avec une clé correspondante" + pl_PL: "Sygnatariusz %{guid} (%{name}) już istnieje z pasującym kluczem" + +# registry/signer.rs +registry.signer.not-accepted: + en_US: "Signer(s) not accepted" + de_DE: "Unterzeichner nicht akzeptiert" + es_ES: "Firmante(s) no aceptado(s)" + fr_FR: "Signataire(s) non accepté(s)" + pl_PL: "Sygnatariusz(e) nieakceptowany(i)" + +# registry/asset.rs +registry.asset.failed-to-load-http-url: + en_US: "Failed to load any HTTP URL" + de_DE: "Fehler beim Laden einer HTTP-URL" + es_ES: "Error al cargar cualquier URL HTTP" + fr_FR: "Échec du chargement de toute URL HTTP" + pl_PL: "Nie udało się załadować żadnego adresu HTTP" + +# registry/context.rs +registry.context.missing-hostname: + en_US: "Missing required configuration: registry-hostname" + de_DE: "Fehlende erforderliche Konfiguration: registry-hostname" + es_ES: "Falta configuración requerida: registry-hostname" + fr_FR: "Configuration requise manquante : registry-hostname" + pl_PL: "Brak wymaganej konfiguracji: registry-hostname" + +registry.context.registry-required: + en_US: "`--registry` required" + de_DE: "`--registry` erforderlich" + es_ES: "`--registry` requerido" + fr_FR: "`--registry` requis" + pl_PL: "Wymagany `--registry`" + +registry.context.unauthorized: + en_US: "Unauthorized" + de_DE: "Nicht autorisiert" + es_ES: "No autorizado" + fr_FR: "Non autorisé" + pl_PL: "Brak autoryzacji" + +# registry/os/asset/add.rs +registry.os.asset.commitment-mismatch: + en_US: "Commitment does not match" + de_DE: "Commitment stimmt nicht überein" + es_ES: "El compromiso no coincide" + fr_FR: "L'engagement ne correspond pas" + pl_PL: "Zobowiązanie nie pasuje" + +registry.os.asset.unauthorized: + en_US: "Unauthorized" + de_DE: "Nicht autorisiert" + es_ES: "No autorizado" + fr_FR: "Non autorisé" + pl_PL: "Brak autoryzacji" + +registry.os.asset.unknown-extension: + en_US: "Unknown extension" + de_DE: "Unbekannte Erweiterung" + es_ES: "Extensión desconocida" + fr_FR: "Extension inconnue" + pl_PL: "Nieznane rozszerzenie" + +registry.os.asset.failed-read-metadata: + en_US: "Failed to read file metadata" + de_DE: "Fehler beim Lesen der Dateimetadaten" + es_ES: "Error al leer los metadatos del archivo" + fr_FR: "Échec de la lecture des métadonnées du fichier" + pl_PL: "Nie udało się odczytać metadanych pliku" + +registry.os.asset.signer-not-authorized: + en_US: "Signer %{guid} is not authorized" + de_DE: "Unterzeichner %{guid} ist nicht autorisiert" + es_ES: "El firmante %{guid} no está autorizado" + fr_FR: "Le signataire %{guid} n'est pas autorisé" + pl_PL: "Sygnatariusz %{guid} nie jest autoryzowany" + +# registry/os/version/signer.rs +registry.os.version.signer-not-authorized: + en_US: "Signer %{signer} is not authorized to sign for v%{version}" + de_DE: "Unterzeichner %{signer} ist nicht autorisiert, für v%{version} zu signieren" + es_ES: "El firmante %{signer} no está autorizado para firmar v%{version}" + fr_FR: "Le signataire %{signer} n'est pas autorisé à signer pour v%{version}" + pl_PL: "Sygnatariusz %{signer} nie jest autoryzowany do podpisywania v%{version}" + +# registry/package/mod.rs +registry.package.remove-not-exist: + en_US: "%{id}@%{version}%{sighash} does not exist, so not removed" + de_DE: "%{id}@%{version}%{sighash} existiert nicht, daher nicht entfernt" + es_ES: "%{id}@%{version}%{sighash} no existe, por lo que no se eliminó" + fr_FR: "%{id}@%{version}%{sighash} n'existe pas, donc non supprimé" + pl_PL: "%{id}@%{version}%{sighash} nie istnieje, więc nie usunięto" + +# registry/package/add.rs +registry.package.add.must-specify-url: + en_US: "Must specify at least 1 URL" + de_DE: "Mindestens 1 URL muss angegeben werden" + es_ES: "Debe especificar al menos 1 URL" + fr_FR: "Doit spécifier au moins 1 URL" + pl_PL: "Należy podać co najmniej 1 adres URL" + +registry.package.add.unauthorized: + en_US: "Unauthorized" + de_DE: "Nicht autorisiert" + es_ES: "No autorizado" + fr_FR: "Non autorisé" + pl_PL: "Brak autoryzacji" + +registry.package.missing-signer: + en_US: "Missing signer" + de_DE: "Fehlender Unterzeichner" + es_ES: "Falta firmante" + fr_FR: "Signataire manquant" + pl_PL: "Brak sygnatariusza" + +registry.package.unauthorized: + en_US: "Unauthorized" + de_DE: "Nicht autorisiert" + es_ES: "No autorizado" + fr_FR: "Non autorisé" + pl_PL: "Brak autoryzacji" + +registry.package.add-mirror.unauthorized: + en_US: "Unauthorized" + de_DE: "Nicht autorisiert" + es_ES: "No autorizado" + fr_FR: "Non autorisé" + pl_PL: "Brak autoryzacji" + +registry.package.cannot-remove-last-mirror: + en_US: "Cannot remove last mirror from an s9pk" + de_DE: "Letzter Spiegel kann nicht aus einem s9pk entfernt werden" + es_ES: "No se puede eliminar el último espejo de un s9pk" + fr_FR: "Impossible de supprimer le dernier miroir d'un s9pk" + pl_PL: "Nie można usunąć ostatniego serwera lustrzanego z s9pk" + +registry.package.remove-mirror.unauthorized: + en_US: "Unauthorized" + de_DE: "Nicht autorisiert" + es_ES: "No autorizado" + fr_FR: "Non autorisé" + pl_PL: "Brak autoryzacji" + +# registry/package/get.rs +registry.package.get.version-not-found: + en_US: "Could not find a version of %{id} that satisfies %{version}" + de_DE: "Keine Version von %{id} gefunden, die %{version} erfüllt" + es_ES: "No se pudo encontrar una versión de %{id} que satisfaga %{version}" + fr_FR: "Impossible de trouver une version de %{id} qui satisfait %{version}" + pl_PL: "Nie można znaleźć wersji %{id} spełniającej %{version}" + +registry.package.get.download-complete: + en_US: "Download Complete" + de_DE: "Download abgeschlossen" + es_ES: "Descarga completada" + fr_FR: "Téléchargement terminé" + pl_PL: "Pobieranie zakończone" + +# registry/package/signer.rs +registry.package.signer.not-authorized: + en_US: "Signer %{signer} is not authorized to sign for %{id}" + de_DE: "Unterzeichner %{signer} ist nicht autorisiert, für %{id} zu signieren" + es_ES: "El firmante %{signer} no está autorizado para firmar %{id}" + fr_FR: "Le signataire %{signer} n'est pas autorisé à signer pour %{id}" + pl_PL: "Sygnatariusz %{signer} nie jest autoryzowany do podpisywania %{id}" + +# service/mod.rs +service.mod.service-actor-held-after-shutdown: + en_US: "ServiceActor held somewhere after actor shutdown" + de_DE: "ServiceActor wird nach dem Herunterfahren des Actors noch irgendwo gehalten" + es_ES: "ServiceActor retenido en algún lugar después del cierre del actor" + fr_FR: "ServiceActor détenu quelque part après l'arrêt de l'acteur" + pl_PL: "ServiceActor przetrzymywany gdzieś po zamknięciu aktora" + +service.mod.service-actor-seed-held-after-shutdown: + en_US: "ServiceActorSeed held somewhere after actor shutdown" + de_DE: "ServiceActorSeed wird nach dem Herunterfahren des Actors noch irgendwo gehalten" + es_ES: "ServiceActorSeed retenido en algún lugar después del cierre del actor" + fr_FR: "ServiceActorSeed détenu quelque part après l'arrêt de l'acteur" + pl_PL: "ServiceActorSeed przetrzymywany gdzieś po zamknięciu aktora" + +service.mod.race-condition-detected: + en_US: "Race condition detected - package state changed during load" + de_DE: "Race Condition erkannt - Paketstatus hat sich während des Ladens geändert" + es_ES: "Condición de carrera detectada - el estado del paquete cambió durante la carga" + fr_FR: "Condition de concurrence détectée - l'état du paquet a changé pendant le chargement" + pl_PL: "Wykryto wyścig - stan pakietu zmienił się podczas ładowania" + +service.mod.failed-to-parse-package-data-entry: + en_US: "Failed to parse PackageDataEntry, found %{error}" + de_DE: "Fehler beim Parsen von PackageDataEntry, gefunden: %{error}" + es_ES: "Error al analizar PackageDataEntry, encontrado: %{error}" + fr_FR: "Échec de l'analyse de PackageDataEntry, trouvé : %{error}" + pl_PL: "Nie udało się przeanalizować PackageDataEntry, znaleziono: %{error}" + +service.mod.no-matching-subcontainers: + en_US: "no matching subcontainers are running for %{id}; some possible choices are:\n%{subcontainers}" + de_DE: "keine passenden Subcontainer laufen für %{id}; einige mögliche Optionen sind:\n%{subcontainers}" + es_ES: "no hay subcontenedores coincidentes ejecutándose para %{id}; algunas opciones posibles son:\n%{subcontainers}" + fr_FR: "aucun sous-conteneur correspondant n'est en cours d'exécution pour %{id} ; voici quelques choix possibles :\n%{subcontainers}" + pl_PL: "nie działają pasujące podkontenery dla %{id}; niektóre możliwe wybory to:\n%{subcontainers}" + +service.mod.multiple-subcontainers-found: + en_US: "multiple subcontainers found for %{id}: \n%{subcontainer_ids}" + de_DE: "mehrere Subcontainer für %{id} gefunden: \n%{subcontainer_ids}" + es_ES: "se encontraron múltiples subcontenedores para %{id}: \n%{subcontainer_ids}" + fr_FR: "plusieurs sous-conteneurs trouvés pour %{id} : \n%{subcontainer_ids}" + pl_PL: "znaleziono wiele podkontenerów dla %{id}: \n%{subcontainer_ids}" + +service.mod.invalid-byte-length-for-signal: + en_US: "invalid byte length for signal: %{length}" + de_DE: "ungültige Bytelänge für Signal: %{length}" + es_ES: "longitud de bytes inválida para señal: %{length}" + fr_FR: "longueur d'octets invalide pour le signal : %{length}" + pl_PL: "nieprawidłowa długość bajtów dla sygnału: %{length}" + +service.mod.could-not-parse-etc-passwd: + en_US: "Could not parse /etc/passwd for shell: %{contents}" + de_DE: "Konnte /etc/passwd für Shell nicht parsen: %{contents}" + es_ES: "No se pudo analizar /etc/passwd para shell: %{contents}" + fr_FR: "Impossible d'analyser /etc/passwd pour le shell : %{contents}" + pl_PL: "Nie można przeanalizować /etc/passwd dla powłoki: %{contents}" + +service.mod.could-not-get-etc-passwd: + en_US: "Could not get the /etc/passwd: %{error}" + de_DE: "Konnte /etc/passwd nicht abrufen: %{error}" + es_ES: "No se pudo obtener /etc/passwd: %{error}" + fr_FR: "Impossible d'obtenir /etc/passwd : %{error}" + pl_PL: "Nie można uzyskać /etc/passwd: %{error}" + +service.mod.invalid-byte-length-for-exit-code: + en_US: "invalid byte length for exit code: %{length}" + de_DE: "ungültige Bytelänge für Exit-Code: %{length}" + es_ES: "longitud de bytes inválida para código de salida: %{length}" + fr_FR: "longueur d'octets invalide pour le code de sortie : %{length}" + pl_PL: "nieprawidłowa długość bajtów dla kodu wyjścia: %{length}" + +service.mod.deleting-task-action-no-longer-exists: + en_US: "Deleting task %{id} because action no longer exists" + de_DE: "Lösche Aufgabe %{id}, da die Aktion nicht mehr existiert" + es_ES: "Eliminando tarea %{id} porque la acción ya no existe" + fr_FR: "Suppression de la tâche %{id} car l'action n'existe plus" + pl_PL: "Usuwanie zadania %{id}, ponieważ akcja już nie istnieje" + +# service/action.rs +service.action.action-request-invalid-state: + en_US: "action request exists in an invalid state %{task}" + de_DE: "Aktionsanfrage existiert in ungültigem Zustand %{task}" + es_ES: "la solicitud de acción existe en un estado inválido %{task}" + fr_FR: "la demande d'action existe dans un état invalide %{task}" + pl_PL: "żądanie akcji istnieje w nieprawidłowym stanie %{task}" + +service.action.action-is-disabled: + en_US: "action %{action_id} is disabled" + de_DE: "Aktion %{action_id} ist deaktiviert" + es_ES: "la acción %{action_id} está deshabilitada" + fr_FR: "l'action %{action_id} est désactivée" + pl_PL: "akcja %{action_id} jest wyłączona" + +service.action.service-not-in-allowed-status: + en_US: "service is not in allowed status for %{action_id}" + de_DE: "Dienst ist nicht im erlaubten Status für %{action_id}" + es_ES: "el servicio no está en un estado permitido para %{action_id}" + fr_FR: "le service n'est pas dans un état autorisé pour %{action_id}" + pl_PL: "usługa nie jest w dozwolonym stanie dla %{action_id}" + +# service/effects/action.rs +service.effects.action.calling-actions-on-other-packages-unsupported: + en_US: "calling actions on other packages is unsupported at this time" + de_DE: "Aufrufen von Aktionen auf anderen Paketen wird derzeit nicht unterstützt" + es_ES: "llamar acciones en otros paquetes no está soportado en este momento" + fr_FR: "l'appel d'actions sur d'autres paquets n'est pas pris en charge pour le moment" + pl_PL: "wywoływanie akcji na innych pakietach nie jest obecnie obsługiwane" + +service.effects.action.input-not-matches-requires-input: + en_US: "input-not-matches trigger requires input to be specified" + de_DE: "input-not-matches Trigger erfordert Eingabe" + es_ES: "el disparador input-not-matches requiere que se especifique la entrada" + fr_FR: "le déclencheur input-not-matches nécessite une entrée spécifiée" + pl_PL: "wyzwalacz input-not-matches wymaga określenia wejścia" + +service.effects.action.action-has-no-input: + en_US: "action %{action_id} of %{package_id} has no input" + de_DE: "Aktion %{action_id} von %{package_id} hat keine Eingabe" + es_ES: "la acción %{action_id} de %{package_id} no tiene entrada" + fr_FR: "l'action %{action_id} de %{package_id} n'a pas d'entrée" + pl_PL: "akcja %{action_id} pakietu %{package_id} nie ma wejścia" + +# service/effects/dependency.rs +service.effects.dependency.unknown-dependency-kind: + en_US: "unknown dependency kind %{kind}" + de_DE: "unbekannter Abhängigkeitstyp %{kind}" + es_ES: "tipo de dependencia desconocido %{kind}" + fr_FR: "type de dépendance inconnu %{kind}" + pl_PL: "nieznany rodzaj zależności %{kind}" + +# service/service_actor.rs +service.service-actor.error-synchronizing-state: + en_US: "error synchronizing state of service: %{error}" + de_DE: "Fehler beim Synchronisieren des Dienststatus: %{error}" + es_ES: "error al sincronizar el estado del servicio: %{error}" + fr_FR: "erreur lors de la synchronisation de l'état du service : %{error}" + pl_PL: "błąd synchronizacji stanu usługi: %{error}" + +service.service-actor.retrying-in-seconds: + en_US: "Retrying in %{seconds}s..." + de_DE: "Wiederholung in %{seconds}s..." + es_ES: "Reintentando en %{seconds}s..." + fr_FR: "Nouvelle tentative dans %{seconds}s..." + pl_PL: "Ponowna próba za %{seconds}s..." + +# service/persistent_container.rs +service.persistent-container.container-destroyed: + en_US: "PersistentContainer has been destroyed" + de_DE: "PersistentContainer wurde zerstört" + es_ES: "PersistentContainer ha sido destruido" + fr_FR: "PersistentContainer a été détruit" + pl_PL: "PersistentContainer został zniszczony" + +service.persistent-container.error-on-unix-socket: + en_US: "error on unix socket %{path}: %{error}" + de_DE: "Fehler auf Unix-Socket %{path}: %{error}" + es_ES: "error en socket unix %{path}: %{error}" + fr_FR: "erreur sur le socket unix %{path} : %{error}" + pl_PL: "błąd na gnieździe unix %{path}: %{error}" + +service.persistent-container.unix-socket-server-panicked: + en_US: "unix socket server thread panicked" + de_DE: "Unix-Socket-Server-Thread ist abgestürzt" + es_ES: "el hilo del servidor de socket unix entró en pánico" + fr_FR: "le thread du serveur de socket unix a paniqué" + pl_PL: "wątek serwera gniazda unix spanikował" + +service.persistent-container.already-initialized: + en_US: "PersistentContainer already initialized" + de_DE: "PersistentContainer bereits initialisiert" + es_ES: "PersistentContainer ya inicializado" + fr_FR: "PersistentContainer déjà initialisé" + pl_PL: "PersistentContainer już zainicjalizowany" + +service.persistent-container.service-exited: + en_US: "Service for %{id} exited" + de_DE: "Dienst für %{id} beendet" + es_ES: "El servicio para %{id} terminó" + fr_FR: "Le service pour %{id} s'est terminé" + pl_PL: "Usługa dla %{id} zakończyła działanie" + +# service/uninstall.rs +service.uninstall.invalid-package-state-for-cleanup: + en_US: "Invalid package state for cleanup: %{state}" + de_DE: "Ungültiger Paketstatus für Bereinigung: %{state}" + es_ES: "Estado de paquete inválido para limpieza: %{state}" + fr_FR: "État du paquet invalide pour le nettoyage : %{state}" + pl_PL: "Nieprawidłowy stan pakietu do czyszczenia: %{state}" + +# service/transition/backup.rs +service.transition.backup.no-backup-to-resume: + en_US: "No backup to resume" + de_DE: "Keine Sicherung zum Fortsetzen" + es_ES: "No hay respaldo para reanudar" + fr_FR: "Aucune sauvegarde à reprendre" + pl_PL: "Brak kopii zapasowej do wznowienia" + +# context/setup.rs +context.setup.couldnt-generate-ec-key: + en_US: "Couldn't generate ec key" + de_DE: "EC-Schlüssel konnte nicht generiert werden" + es_ES: "No se pudo generar la clave ec" + fr_FR: "Impossible de générer la clé ec" + pl_PL: "Nie można wygenerować klucza ec" + +context.setup.setup-complete: + en_US: "Setup complete!" + de_DE: "Einrichtung abgeschlossen!" + es_ES: "¡Configuración completada!" + fr_FR: "Configuration terminée !" + pl_PL: "Konfiguracja zakończona!" + +context.setup.setup-failed: + en_US: "Setup failed: %{error}" + de_DE: "Einrichtung fehlgeschlagen: %{error}" + es_ES: "Configuración fallida: %{error}" + fr_FR: "Échec de la configuration : %{error}" + pl_PL: "Konfiguracja nie powiodła się: %{error}" + +context.setup.setup-already-complete: + en_US: "Setup already complete" + de_DE: "Einrichtung bereits abgeschlossen" + es_ES: "La configuración ya está completa" + fr_FR: "Configuration déjà terminée" + pl_PL: "Konfiguracja już zakończona" + +context.setup.setup-already-in-progress: + en_US: "Setup already in progress" + de_DE: "Einrichtung bereits im Gange" + es_ES: "La configuración ya está en progreso" + fr_FR: "Configuration déjà en cours" + pl_PL: "Konfiguracja już w toku" + +context.setup.error-in-setup-progress-websocket: + en_US: "Error in setup progress websocket: %{error}" + de_DE: "Fehler im Setup-Fortschritts-WebSocket: %{error}" + es_ES: "Error en el websocket de progreso de configuración: %{error}" + fr_FR: "Erreur dans le websocket de progression de la configuration : %{error}" + pl_PL: "Błąd w websocket postępu konfiguracji: %{error}" + +# context/diagnostic.rs +context.diagnostic.starting-diagnostic-ui: + en_US: "Error: %{error}: Starting diagnostic UI" + de_DE: "Fehler: %{error}: Starte Diagnose-UI" + es_ES: "Error: %{error}: Iniciando interfaz de diagnóstico" + fr_FR: "Erreur : %{error} : Démarrage de l'interface de diagnostic" + pl_PL: "Błąd: %{error}: Uruchamianie interfejsu diagnostycznego" + +# context/cli.rs +context.cli.pkcs8-key-incorrect-length: + en_US: "pkcs8 key is of incorrect length" + de_DE: "PKCS8-Schlüssel hat falsche Länge" + es_ES: "la clave pkcs8 tiene una longitud incorrecta" + fr_FR: "la clé pkcs8 a une longueur incorrecte" + pl_PL: "klucz pkcs8 ma nieprawidłową długość" + +context.cli.developer-key-does-not-exist: + en_US: "Developer Key does not exist! Please run `start-cli init-key` before running this command." + de_DE: "Entwicklerschlüssel existiert nicht! Bitte führen Sie `start-cli init-key` aus, bevor Sie diesen Befehl ausführen." + es_ES: "¡La clave de desarrollador no existe! Por favor ejecute `start-cli init-key` antes de ejecutar este comando." + fr_FR: "La clé développeur n'existe pas ! Veuillez exécuter `start-cli init-key` avant d'exécuter cette commande." + pl_PL: "Klucz programisty nie istnieje! Proszę uruchomić `start-cli init-key` przed wykonaniem tego polecenia." + +context.cli.cannot-parse-scheme-from-base-url: + en_US: "Cannot parse scheme from base URL" + de_DE: "Schema kann nicht aus Basis-URL geparst werden" + es_ES: "No se puede analizar el esquema de la URL base" + fr_FR: "Impossible d'analyser le schéma de l'URL de base" + pl_PL: "Nie można przeanalizować schematu z podstawowego URL" + +context.cli.cannot-set-url-scheme: + en_US: "Cannot set URL scheme" + de_DE: "URL-Schema kann nicht gesetzt werden" + es_ES: "No se puede establecer el esquema de URL" + fr_FR: "Impossible de définir le schéma d'URL" + pl_PL: "Nie można ustawić schematu URL" + +# context/rpc.rs +context.rpc.rpc-context-dropped: + en_US: "RpcContext is dropped" + de_DE: "RpcContext wurde verworfen" + es_ES: "RpcContext ha sido descartado" + fr_FR: "RpcContext a été abandonné" + pl_PL: "RpcContext został porzucony" + +context.rpc.opened-patchdb: + en_US: "Opened PatchDB" + de_DE: "PatchDB geöffnet" + es_ES: "PatchDB abierto" + fr_FR: "PatchDB ouvert" + pl_PL: "Otwarto PatchDB" + +context.rpc.initialized-net-controller: + en_US: "Initialized Net Controller" + de_DE: "Net Controller initialisiert" + es_ES: "Controlador de red inicializado" + fr_FR: "Contrôleur réseau initialisé" + pl_PL: "Zainicjalizowano kontroler sieci" + +context.rpc.nvidia-smi-error: + en_US: "nvidia-smi: %{error}" + de_DE: "nvidia-smi: %{error}" + es_ES: "nvidia-smi: %{error}" + fr_FR: "nvidia-smi : %{error}" + pl_PL: "nvidia-smi: %{error}" + +context.rpc.nvidia-warning-can-be-ignored: + en_US: "The above warning can be ignored if no NVIDIA card is present" + de_DE: "Die obige Warnung kann ignoriert werden, wenn keine NVIDIA-Karte vorhanden ist" + es_ES: "La advertencia anterior se puede ignorar si no hay tarjeta NVIDIA presente" + fr_FR: "L'avertissement ci-dessus peut être ignoré si aucune carte NVIDIA n'est présente" + pl_PL: "Powyższe ostrzeżenie można zignorować, jeśli nie ma karty NVIDIA" + +context.rpc.os-partition-info-missing: + en_US: "OS Partition Information Missing" + de_DE: "OS-Partitionsinformationen fehlen" + es_ES: "Falta información de la partición del SO" + fr_FR: "Informations de partition du système d'exploitation manquantes" + pl_PL: "Brak informacji o partycji systemu operacyjnego" + +context.rpc.couldnt-generate-ec-key: + en_US: "Couldn't generate ec key" + de_DE: "EC-Schlüssel konnte nicht generiert werden" + es_ES: "No se pudo generar la clave ec" + fr_FR: "Impossible de générer la clé ec" + pl_PL: "Nie można wygenerować klucza ec" + +context.rpc.cleaned-up-transient-states: + en_US: "Cleaned up transient states" + de_DE: "Transiente Zustände bereinigt" + es_ES: "Estados transitorios limpiados" + fr_FR: "États transitoires nettoyés" + pl_PL: "Wyczyszczono stany przejściowe" + +context.rpc.completed-migrations: + en_US: "Completed migrations" + de_DE: "Migrationen abgeschlossen" + es_ES: "Migraciones completadas" + fr_FR: "Migrations terminées" + pl_PL: "Migracje zakończone" + +context.rpc.rpc-context-shutdown: + en_US: "RpcContext is shutdown" + de_DE: "RpcContext ist heruntergefahren" + es_ES: "RpcContext está apagado" + fr_FR: "RpcContext est arrêté" + pl_PL: "RpcContext jest wyłączony" + +context.rpc.error-in-session-cleanup-cron: + en_US: "Error in session cleanup cron: %{error}" + de_DE: "Fehler im Sitzungsbereinigung-Cron: %{error}" + es_ES: "Error en el cron de limpieza de sesión: %{error}" + fr_FR: "Erreur dans le cron de nettoyage de session : %{error}" + pl_PL: "Błąd w cronie czyszczenia sesji: %{error}" + +# middleware/auth/local.rs +middleware.auth.unauthorized: + en_US: "UNAUTHORIZED" + de_DE: "NICHT AUTORISIERT" + es_ES: "NO AUTORIZADO" + fr_FR: "NON AUTORISÉ" + pl_PL: "BRAK AUTORYZACJI" + +# middleware/auth/session.rs +middleware.auth.rate-limited-login: + en_US: "Please limit login attempts to 3 per 20 seconds." + de_DE: "Bitte beschränken Sie Anmeldeversuche auf 3 pro 20 Sekunden." + es_ES: "Por favor limite los intentos de inicio de sesión a 3 por cada 20 segundos." + fr_FR: "Veuillez limiter les tentatives de connexion à 3 par 20 secondes." + pl_PL: "Proszę ograniczyć próby logowania do 3 na 20 sekund." + +# middleware/auth/signature.rs +middleware.auth.key-not-authorized: + en_US: "Key is not authorized" + de_DE: "Schlüssel ist nicht autorisiert" + es_ES: "La clave no está autorizada" + fr_FR: "La clé n'est pas autorisée" + pl_PL: "Klucz nie jest autoryzowany" + +middleware.auth.replay-attack-detected: + en_US: "replay attack detected" + de_DE: "Replay-Angriff erkannt" + es_ES: "ataque de repetición detectado" + fr_FR: "attaque par rejeu détectée" + pl_PL: "wykryto atak powtórzeniowy" + +middleware.auth.no-valid-sig-context: + en_US: "no valid signature context available to verify" + de_DE: "kein gültiger Signaturkontext zur Verifizierung verfügbar" + es_ES: "no hay contexto de firma válido disponible para verificar" + fr_FR: "aucun contexte de signature valide disponible pour vérifier" + pl_PL: "brak prawidłowego kontekstu podpisu do weryfikacji" + +middleware.auth.timestamp-not-within-30s: + en_US: "timestamp not within 30s of now" + de_DE: "Zeitstempel nicht innerhalb von 30s der aktuellen Zeit" + es_ES: "la marca de tiempo no está dentro de 30s del momento actual" + fr_FR: "l'horodatage n'est pas dans les 30s de maintenant" + pl_PL: "znacznik czasu nie mieści się w 30s od teraz" + +middleware.auth.unknown-content-type: + en_US: "unknown content type" + de_DE: "unbekannter Inhaltstyp" + es_ES: "tipo de contenido desconocido" + fr_FR: "type de contenu inconnu" + pl_PL: "nieznany typ zawartości" + +# middleware/db.rs +middleware.db.error-writing-patch-sequence-header: + en_US: "error writing X-Patch-Sequence header: %{error}" + de_DE: "Fehler beim Schreiben des X-Patch-Sequence Headers: %{error}" + es_ES: "error al escribir el encabezado X-Patch-Sequence: %{error}" + fr_FR: "erreur lors de l'écriture de l'en-tête X-Patch-Sequence : %{error}" + pl_PL: "błąd zapisu nagłówka X-Patch-Sequence: %{error}" + +# ssh.rs +ssh.key-not-found: + en_US: "SSH Key Not Found" + de_DE: "SSH-Schlüssel nicht gefunden" + es_ES: "Clave SSH no encontrada" + fr_FR: "Clé SSH non trouvée" + pl_PL: "Nie znaleziono klucza SSH" + +# logs.rs +logs.no-stdout-available: + en_US: "No stdout available" + de_DE: "Keine Standardausgabe verfügbar" + es_ES: "No hay stdout disponible" + fr_FR: "Pas de stdout disponible" + pl_PL: "Brak dostępnego stdout" + +logs.error-in-log-stream: + en_US: "Error in log stream: %{error}" + de_DE: "Fehler im Log-Stream: %{error}" + es_ES: "Error en el flujo de logs: %{error}" + fr_FR: "Erreur dans le flux de logs : %{error}" + pl_PL: "Błąd w strumieniu logów: %{error}" + +# notifications.rs +notifications.invalid-level: + en_US: "Invalid Notification Level: %{level}" + de_DE: "Ungültige Benachrichtigungsstufe: %{level}" + es_ES: "Nivel de notificación inválido: %{level}" + fr_FR: "Niveau de notification invalide : %{level}" + pl_PL: "Nieprawidłowy poziom powiadomienia: %{level}" + +# update/mod.rs +update.already-updated-restart-required: + en_US: "Server was already updated. Please restart your device before attempting to update again." + de_DE: "Server wurde bereits aktualisiert. Bitte starten Sie Ihr Gerät neu, bevor Sie erneut versuchen zu aktualisieren." + es_ES: "El servidor ya fue actualizado. Por favor reinicie su dispositivo antes de intentar actualizar nuevamente." + fr_FR: "Le serveur a déjà été mis à jour. Veuillez redémarrer votre appareil avant de tenter une nouvelle mise à jour." + pl_PL: "Serwer został już zaktualizowany. Proszę zrestartować urządzenie przed ponowną próbą aktualizacji." + +update.no-updates-available: + en_US: "No updates available" + de_DE: "Keine Updates verfügbar" + es_ES: "No hay actualizaciones disponibles" + fr_FR: "Aucune mise à jour disponible" + pl_PL: "Brak dostępnych aktualizacji" + +update.updating-to-version: + en_US: "Updating to v%{version}..." + de_DE: "Aktualisiere auf v%{version}..." + es_ES: "Actualizando a v%{version}..." + fr_FR: "Mise à jour vers v%{version}..." + pl_PL: "Aktualizacja do v%{version}..." + +update.complete-restart-to-apply: + en_US: "Update complete. Restart your server to apply the update." + de_DE: "Update abgeschlossen. Starten Sie Ihren Server neu, um das Update anzuwenden." + es_ES: "Actualización completada. Reinicie su servidor para aplicar la actualización." + fr_FR: "Mise à jour terminée. Redémarrez votre serveur pour appliquer la mise à jour." + pl_PL: "Aktualizacja zakończona. Zrestartuj serwer, aby zastosować aktualizację." + +update.already-updating: + en_US: "Server is already updating!" + de_DE: "Server wird bereits aktualisiert!" + es_ES: "¡El servidor ya se está actualizando!" + fr_FR: "Le serveur est déjà en cours de mise à jour !" + pl_PL: "Serwer jest już w trakcie aktualizacji!" + +update.error-returning-progress: + en_US: "Error returning progress of update: %{error}" + de_DE: "Fehler beim Zurückgeben des Update-Fortschritts: %{error}" + es_ES: "Error al devolver el progreso de la actualización: %{error}" + fr_FR: "Erreur lors du retour de la progression de la mise à jour : %{error}" + pl_PL: "Błąd zwracania postępu aktualizacji: %{error}" + +update.not-successful: + en_US: "Update was not successful because of %{error}" + de_DE: "Update war nicht erfolgreich wegen %{error}" + es_ES: "La actualización no fue exitosa debido a %{error}" + fr_FR: "La mise à jour n'a pas réussi à cause de %{error}" + pl_PL: "Aktualizacja nie powiodła się z powodu %{error}" + +update.failed-title: + en_US: "StartOS Update Failed" + de_DE: "StartOS-Update fehlgeschlagen" + es_ES: "Actualización de StartOS fallida" + fr_FR: "Échec de la mise à jour de StartOS" + pl_PL: "Aktualizacja StartOS nie powiodła się" + +# shutdown.rs +shutdown.beginning-restart: + en_US: "Beginning server restart" + de_DE: "Server-Neustart wird begonnen" + es_ES: "Iniciando reinicio del servidor" + fr_FR: "Début du redémarrage du serveur" + pl_PL: "Rozpoczynanie restartu serwera" + +shutdown.beginning-shutdown: + en_US: "Beginning server shutdown" + de_DE: "Server-Herunterfahren wird begonnen" + es_ES: "Iniciando apagado del servidor" + fr_FR: "Début de l'arrêt du serveur" + pl_PL: "Rozpoczynanie zamykania serwera" + +shutdown.error-stopping-journald: + en_US: "Error Stopping Journald: %{error}" + de_DE: "Fehler beim Stoppen von Journald: %{error}" + es_ES: "Error al detener Journald: %{error}" + fr_FR: "Erreur lors de l'arrêt de Journald : %{error}" + pl_PL: "Błąd zatrzymywania Journald: %{error}" + +shutdown.error-exporting-volume-group: + en_US: "Error Exporting Volume Group: %{error}" + de_DE: "Fehler beim Exportieren der Volume-Gruppe: %{error}" + es_ES: "Error al exportar el grupo de volúmenes: %{error}" + fr_FR: "Erreur lors de l'exportation du groupe de volumes : %{error}" + pl_PL: "Błąd eksportowania grupy woluminów: %{error}" + +shutdown.error-playing-shutdown-song: + en_US: "Error Playing Shutdown Song: %{error}" + de_DE: "Fehler beim Abspielen der Herunterfahren-Melodie: %{error}" + es_ES: "Error al reproducir la canción de apagado: %{error}" + fr_FR: "Erreur lors de la lecture de la mélodie d'arrêt : %{error}" + pl_PL: "Błąd odtwarzania melodii zamykania: %{error}" + +# auth.rs +auth.malformed-etc-shadow: + en_US: "malformed /etc/shadow" + de_DE: "fehlerhafte /etc/shadow" + es_ES: "/etc/shadow mal formado" + fr_FR: "/etc/shadow mal formé" + pl_PL: "zniekształcony /etc/shadow" + +auth.couldnt-decode-password: + en_US: "Couldn't decode password" + de_DE: "Passwort konnte nicht dekodiert werden" + es_ES: "No se pudo decodificar la contraseña" + fr_FR: "Impossible de décoder le mot de passe" + pl_PL: "Nie można zdekodować hasła" + +auth.password-incorrect: + en_US: "Password Incorrect" + de_DE: "Passwort falsch" + es_ES: "Contraseña incorrecta" + fr_FR: "Mot de passe incorrect" + pl_PL: "Nieprawidłowe hasło" + +auth.prompt-current-password: + en_US: "Current Password: " + de_DE: "Aktuelles Passwort: " + es_ES: "Contraseña actual: " + fr_FR: "Mot de passe actuel : " + pl_PL: "Obecne hasło: " + +auth.prompt-new-password: + en_US: "New Password: " + de_DE: "Neues Passwort: " + es_ES: "Nueva contraseña: " + fr_FR: "Nouveau mot de passe : " + pl_PL: "Nowe hasło: " + +auth.prompt-confirm: + en_US: "Confirm: " + de_DE: "Bestätigen: " + es_ES: "Confirmar: " + fr_FR: "Confirmer : " + pl_PL: "Potwierdź: " + +auth.passwords-do-not-match: + en_US: "Passwords do not match" + de_DE: "Passwörter stimmen nicht überein" + es_ES: "Las contraseñas no coinciden" + fr_FR: "Les mots de passe ne correspondent pas" + pl_PL: "Hasła nie pasują do siebie" + +# util/tui.rs +util.tui.terminal-must-be-interactive: + en_US: "Terminal must be in interactive mode for this wizard" + de_DE: "Terminal muss für diesen Assistenten im interaktiven Modus sein" + es_ES: "El terminal debe estar en modo interactivo para este asistente" + fr_FR: "Le terminal doit être en mode interactif pour cet assistant" + pl_PL: "Terminal musi być w trybie interaktywnym dla tego kreatora" + +util.tui.enter-valid-value: + en_US: "Please enter a valid %{what}." + de_DE: "Bitte geben Sie einen gültigen %{what} ein." + es_ES: "Por favor ingrese un %{what} válido." + fr_FR: "Veuillez entrer un %{what} valide." + pl_PL: "Proszę wprowadzić prawidłowe %{what}." + +util.tui.aborted: + en_US: "Aborted" + de_DE: "Abgebrochen" + es_ES: "Abortado" + fr_FR: "Abandonné" + pl_PL: "Przerwano" + +util.tui.selected-choice-not-in-input: + en_US: "selected choice does not appear in input" + de_DE: "ausgewählte Option erscheint nicht in der Eingabe" + es_ES: "la opción seleccionada no aparece en la entrada" + fr_FR: "le choix sélectionné n'apparaît pas dans l'entrée" + pl_PL: "wybrany wybór nie pojawia się w danych wejściowych" + +# util/serde.rs +util.serde.must-specify-units-for-duration: + en_US: "Must specify units for duration" + de_DE: "Einheiten für die Dauer müssen angegeben werden" + es_ES: "Debe especificar unidades para la duración" + fr_FR: "Doit spécifier les unités pour la durée" + pl_PL: "Należy określić jednostki dla czasu trwania" + +util.serde.invalid-units-for-duration: + en_US: "Invalid units for duration" + de_DE: "Ungültige Einheiten für die Dauer" + es_ES: "Unidades inválidas para la duración" + fr_FR: "Unités invalides pour la durée" + pl_PL: "Nieprawidłowe jednostki dla czasu trwania" + +util.serde.failed-to-create-from-buffer: + en_US: "failed to create from buffer" + de_DE: "Erstellung aus Puffer fehlgeschlagen" + es_ES: "error al crear desde el búfer" + fr_FR: "échec de la création à partir du tampon" + pl_PL: "nie udało się utworzyć z bufora" + +util.serde.failed-to-parse-expression: + en_US: "Failed to parse expression: %{errors}" + de_DE: "Ausdruck konnte nicht geparst werden: %{errors}" + es_ES: "Error al analizar la expresión: %{errors}" + fr_FR: "Échec de l'analyse de l'expression : %{errors}" + pl_PL: "Nie udało się przeanalizować wyrażenia: %{errors}" + +util.serde.failed-to-compile-expression: + en_US: "Failed to compile expression: %{errors}" + de_DE: "Ausdruck konnte nicht kompiliert werden: %{errors}" + es_ES: "Error al compilar la expresión: %{errors}" + fr_FR: "Échec de la compilation de l'expression : %{errors}" + pl_PL: "Nie udało się skompilować wyrażenia: %{errors}" + +util.serde.expr-returned-no-results: + en_US: "expr returned no results" + de_DE: "Ausdruck hat keine Ergebnisse zurückgegeben" + es_ES: "la expresión no devolvió resultados" + fr_FR: "l'expression n'a retourné aucun résultat" + pl_PL: "wyrażenie nie zwróciło wyników" + +util.serde.expr-returned-too-many-results: + en_US: "expr returned too many results" + de_DE: "Ausdruck hat zu viele Ergebnisse zurückgegeben" + es_ES: "la expresión devolvió demasiados resultados" + fr_FR: "l'expression a retourné trop de résultats" + pl_PL: "wyrażenie zwróciło zbyt wiele wyników" + +# util/cpupower.rs +util.cpupower.governors-listed-before-cpu: + en_US: "governors listed before cpu" + de_DE: "Governors vor CPU aufgelistet" + es_ES: "governors listados antes de cpu" + fr_FR: "gouverneurs listés avant cpu" + pl_PL: "regulatory wymienione przed cpu" + +util.cpupower.failed-to-parse-output: + en_US: "Failed to parse cpupower output:\n%{output}" + de_DE: "Fehler beim Parsen der cpupower-Ausgabe:\n%{output}" + es_ES: "Error al analizar la salida de cpupower:\n%{output}" + fr_FR: "Échec de l'analyse de la sortie de cpupower :\n%{output}" + pl_PL: "Nie udało się przeanalizować wyjścia cpupower:\n%{output}" + +# util/rpc.rs +util.rpc.unknown-scheme: + en_US: "unknown scheme: %{scheme}" + de_DE: "unbekanntes Schema: %{scheme}" + es_ES: "esquema desconocido: %{scheme}" + fr_FR: "schéma inconnu : %{scheme}" + pl_PL: "nieznany schemat: %{scheme}" + +# util/net.rs +util.net.websocket-ping-timeout: + en_US: "Timeout: WebSocket did not respond to ping within %{timeout}" + de_DE: "Zeitüberschreitung: WebSocket hat nicht innerhalb von %{timeout} auf Ping geantwortet" + es_ES: "Tiempo de espera: WebSocket no respondió al ping dentro de %{timeout}" + fr_FR: "Délai d'attente : le WebSocket n'a pas répondu au ping dans les %{timeout}" + pl_PL: "Przekroczono limit czasu: WebSocket nie odpowiedział na ping w ciągu %{timeout}" + +# CLI argument help text (help.arg.*) +help.arg.acme-contact: + en_US: "Contact email for ACME certificate authority" + de_DE: "Kontakt-E-Mail für ACME-Zertifizierungsstelle" + es_ES: "Correo electrónico de contacto para la autoridad de certificación ACME" + fr_FR: "Email de contact pour l'autorité de certification ACME" + pl_PL: "Adres e-mail kontaktowy dla urzędu certyfikacji ACME" + +help.arg.acme-provider: + en_US: "ACME provider identifier or url" + de_DE: "ACME-Anbieter-Kennung oder URL" + es_ES: "Identificador o URL del proveedor ACME" + fr_FR: "Identifiant ou URL du fournisseur ACME" + pl_PL: "Identyfikator lub URL dostawcy ACME" + +help.arg.add-signer-contact: + en_US: "Add contact information to signer" + de_DE: "Kontaktinformationen zum Unterzeichner hinzufügen" + es_ES: "Agregar información de contacto al firmante" + fr_FR: "Ajouter des informations de contact au signataire" + pl_PL: "Dodaj informacje kontaktowe do sygnatariusza" + +help.arg.add-signer-key: + en_US: "Add a public key to signer" + de_DE: "Öffentlichen Schlüssel zum Unterzeichner hinzufügen" + es_ES: "Agregar una clave pública al firmante" + fr_FR: "Ajouter une clé publique au signataire" + pl_PL: "Dodaj klucz publiczny do sygnatariusza" + +help.arg.allow-model-mismatch: + en_US: "Allow database model mismatch" + de_DE: "Datenbankmodell-Abweichung erlauben" + es_ES: "Permitir discrepancia en el modelo de base de datos" + fr_FR: "Autoriser la non-concordance du modèle de base de données" + pl_PL: "Zezwól na niezgodność modelu bazy danych" + +help.arg.allow-partial-backup: + en_US: "Leave media mounted even if backupfs fails to mount" + de_DE: "Medium eingehängt lassen, auch wenn backupfs nicht eingehängt werden kann" + es_ES: "Dejar el medio montado incluso si backupfs falla al montar" + fr_FR: "Laisser le média monté même si backupfs échoue à monter" + pl_PL: "Pozostaw nośnik zamontowany nawet jeśli backupfs nie może się zamontować" + +help.arg.architecture-mask: + en_US: "Filter by CPU architecture" + de_DE: "Nach CPU-Architektur filtern" + es_ES: "Filtrar por arquitectura de CPU" + fr_FR: "Filtrer par architecture CPU" + pl_PL: "Filtruj według architektury CPU" + +help.arg.assets-path: + en_US: "Path to assets directory" + de_DE: "Pfad zum Assets-Verzeichnis" + es_ES: "Ruta al directorio de recursos" + fr_FR: "Chemin vers le répertoire des ressources" + pl_PL: "Ścieżka do katalogu zasobów" + +help.arg.binding-enabled: + en_US: "Enable or disable this binding" + de_DE: "Diese Bindung aktivieren oder deaktivieren" + es_ES: "Habilitar o deshabilitar este enlace" + fr_FR: "Activer ou désactiver cette liaison" + pl_PL: "Włącz lub wyłącz to powiązanie" + +help.arg.cert-subject-alt-names: + en_US: "Subject Alternative Name(s) for certificate" + de_DE: "Alternative Subjektnamen für das Zertifikat" + es_ES: "Nombre(s) alternativo(s) del sujeto para el certificado" + fr_FR: "Nom(s) alternatif(s) du sujet pour le certificat" + pl_PL: "Alternatywna nazwa podmiotu dla certyfikatu" + +help.arg.command-to-execute: + en_US: "Command to execute in the container" + de_DE: "Befehl, der im Container ausgeführt werden soll" + es_ES: "Comando a ejecutar en el contenedor" + fr_FR: "Commande à exécuter dans le conteneur" + pl_PL: "Polecenie do wykonania w kontenerze" + +help.arg.config-file-path: + en_US: "Path to configuration file" + de_DE: "Pfad zur Konfigurationsdatei" + es_ES: "Ruta al archivo de configuración" + fr_FR: "Chemin vers le fichier de configuration" + pl_PL: "Ścieżka do pliku konfiguracyjnego" + +help.arg.container-name: + en_US: "Name of the container" + de_DE: "Name des Containers" + es_ES: "Nombre del contenedor" + fr_FR: "Nom du conteneur" + pl_PL: "Nazwa kontenera" + +help.arg.cookie-path: + en_US: "Path to cookie file" + de_DE: "Pfad zur Cookie-Datei" + es_ES: "Ruta al archivo de cookies" + fr_FR: "Chemin vers le fichier de cookies" + pl_PL: "Ścieżka do pliku cookie" + +help.arg.data-directory: + en_US: "Path to data directory" + de_DE: "Pfad zum Datenverzeichnis" + es_ES: "Ruta al directorio de datos" + fr_FR: "Chemin vers le répertoire de données" + pl_PL: "Ścieżka do katalogu danych" + +help.arg.data-drive-path: + en_US: "Path to the data drive" + de_DE: "Pfad zum Datenlaufwerk" + es_ES: "Ruta a la unidad de datos" + fr_FR: "Chemin vers le disque de données" + pl_PL: "Ścieżka do dysku danych" + +help.arg.db-pointer: + en_US: "JSON pointer to object in database to dump" + de_DE: "JSON-Zeiger auf Objekt in der Datenbank zum Ausgeben" + es_ES: "Puntero JSON al objeto en la base de datos a exportar" + fr_FR: "Pointeur JSON vers l'objet dans la base de données à exporter" + pl_PL: "Wskaźnik JSON do obiektu w bazie danych do wyeksportowania" + +help.arg.destination-path: + en_US: "Destination path for download" + de_DE: "Zielpfad für den Download" + es_ES: "Ruta de destino para la descarga" + fr_FR: "Chemin de destination pour le téléchargement" + pl_PL: "Ścieżka docelowa do pobrania" + +help.arg.developer-key-path: + en_US: "Path to developer signing key" + de_DE: "Pfad zum Entwickler-Signaturschlüssel" + es_ES: "Ruta a la clave de firma del desarrollador" + fr_FR: "Chemin vers la clé de signature du développeur" + pl_PL: "Ścieżka do klucza podpisu dewelopera" + +help.arg.disable-encryption: + en_US: "Disable trivial LUKS encryption" + de_DE: "Triviale LUKS-Verschlüsselung deaktivieren" + es_ES: "Deshabilitar cifrado LUKS trivial" + fr_FR: "Désactiver le chiffrement LUKS trivial" + pl_PL: "Wyłącz trywialne szyfrowanie LUKS" + +help.arg.docker-build: + en_US: "Build Docker image from Dockerfile" + de_DE: "Docker-Image aus Dockerfile erstellen" + es_ES: "Construir imagen Docker desde Dockerfile" + fr_FR: "Construire l'image Docker à partir du Dockerfile" + pl_PL: "Zbuduj obraz Docker z pliku Dockerfile" + +help.arg.docker-tag: + en_US: "Docker image tag to use" + de_DE: "Zu verwendender Docker-Image-Tag" + es_ES: "Etiqueta de imagen Docker a usar" + fr_FR: "Tag d'image Docker à utiliser" + pl_PL: "Tag obrazu Docker do użycia" + +help.arg.dockerfile-path: + en_US: "Path to Dockerfile" + de_DE: "Pfad zum Dockerfile" + es_ES: "Ruta al Dockerfile" + fr_FR: "Chemin vers le Dockerfile" + pl_PL: "Ścieżka do pliku Dockerfile" + +help.arg.download-directory: + en_US: "Directory path to download to" + de_DE: "Verzeichnispfad für den Download" + es_ES: "Ruta del directorio de descarga" + fr_FR: "Chemin du répertoire de téléchargement" + pl_PL: "Ścieżka katalogu do pobrania" + +help.arg.emulate-missing-arch: + en_US: "Emulate missing architecture using this one" + de_DE: "Fehlende Architektur mit dieser emulieren" + es_ES: "Emular arquitectura faltante usando esta" + fr_FR: "Émuler l'architecture manquante avec celle-ci" + pl_PL: "Emuluj brakującą architekturę używając tej" + +help.arg.env-file-path: + en_US: "Path to environment file" + de_DE: "Pfad zur Umgebungsdatei" + es_ES: "Ruta al archivo de entorno" + fr_FR: "Chemin vers le fichier d'environnement" + pl_PL: "Ścieżka do pliku środowiskowego" + +help.arg.env-variable: + en_US: "Environment variable (KEY=VALUE)" + de_DE: "Umgebungsvariable (SCHLÜSSEL=WERT)" + es_ES: "Variable de entorno (CLAVE=VALOR)" + fr_FR: "Variable d'environnement (CLÉ=VALEUR)" + pl_PL: "Zmienna środowiskowa (KLUCZ=WARTOŚĆ)" + +help.arg.event-id: + en_US: "Unique event identifier" + de_DE: "Eindeutige Ereignis-Kennung" + es_ES: "Identificador único de evento" + fr_FR: "Identifiant unique de l'événement" + pl_PL: "Unikalny identyfikator zdarzenia" + +help.arg.except-actions: + en_US: "Action IDs to exclude" + de_DE: "Auszuschließende Aktions-IDs" + es_ES: "IDs de acciones a excluir" + fr_FR: "IDs d'actions à exclure" + pl_PL: "Identyfikatory akcji do wykluczenia" + +help.arg.except-callbacks: + en_US: "Callback IDs to exclude" + de_DE: "Auszuschließende Callback-IDs" + es_ES: "IDs de callbacks a excluir" + fr_FR: "IDs de callbacks à exclure" + pl_PL: "Identyfikatory callbacków do wykluczenia" + +help.arg.except-tasks: + en_US: "Task IDs to exclude" + de_DE: "Auszuschließende Aufgaben-IDs" + es_ES: "IDs de tareas a excluir" + fr_FR: "IDs de tâches à exclure" + pl_PL: "Identyfikatory zadań do wykluczenia" + +help.arg.force-clear-task: + en_US: "Force clear the task even if running" + de_DE: "Aufgabe erzwungen löschen, auch wenn sie läuft" + es_ES: "Forzar la limpieza de la tarea aunque esté ejecutándose" + fr_FR: "Forcer la suppression de la tâche même si elle est en cours" + pl_PL: "Wymuś wyczyszczenie zadania nawet jeśli jest uruchomione" + +help.arg.force-stderr-tty: + en_US: "Force stderr to be treated as a TTY" + de_DE: "stderr als TTY behandeln erzwingen" + es_ES: "Forzar que stderr sea tratado como TTY" + fr_FR: "Forcer stderr à être traité comme un TTY" + pl_PL: "Wymuś traktowanie stderr jako TTY" + +help.arg.force-tty: + en_US: "Force TTY mode for I/O" + de_DE: "TTY-Modus für E/A erzwingen" + es_ES: "Forzar modo TTY para E/S" + fr_FR: "Forcer le mode TTY pour les E/S" + pl_PL: "Wymuś tryb TTY dla I/O" + +help.arg.force-uninstall: + en_US: "Ignore errors in service uninit script" + de_DE: "Fehler im Service-Uninit-Skript ignorieren" + es_ES: "Ignorar errores en el script de desinicialización del servicio" + fr_FR: "Ignorer les erreurs dans le script de désinitialisation du service" + pl_PL: "Ignoruj błędy w skrypcie deinicjalizacji usługi" + +help.arg.host-url: + en_US: "URL of the StartOS server" + de_DE: "URL des StartOS-Servers" + es_ES: "URL del servidor StartOS" + fr_FR: "URL du serveur StartOS" + pl_PL: "URL serwera StartOS" + +help.arg.icon-path: + en_US: "Path to service icon file" + de_DE: "Pfad zur Service-Icon-Datei" + es_ES: "Ruta al archivo de icono del servicio" + fr_FR: "Chemin vers le fichier d'icône du service" + pl_PL: "Ścieżka do pliku ikony usługi" + +help.arg.image-id: + en_US: "Docker image identifier" + de_DE: "Docker-Image-Kennung" + es_ES: "Identificador de imagen Docker" + fr_FR: "Identifiant de l'image Docker" + pl_PL: "Identyfikator obrazu Docker" + +help.arg.include-private-data: + en_US: "Include private data in output" + de_DE: "Private Daten in der Ausgabe einschließen" + es_ES: "Incluir datos privados en la salida" + fr_FR: "Inclure les données privées dans la sortie" + pl_PL: "Uwzględnij dane prywatne w wynikach" + +help.arg.javascript-path: + en_US: "Path to JavaScript file" + de_DE: "Pfad zur JavaScript-Datei" + es_ES: "Ruta al archivo JavaScript" + fr_FR: "Chemin vers le fichier JavaScript" + pl_PL: "Ścieżka do pliku JavaScript" + +help.arg.json-pointer: + en_US: "JSON pointer to specific value" + de_DE: "JSON-Zeiger auf spezifischen Wert" + es_ES: "Puntero JSON a un valor específico" + fr_FR: "Pointeur JSON vers une valeur spécifique" + pl_PL: "Wskaźnik JSON do konkretnej wartości" + +help.arg.keyboard-option: + en_US: "Additional keyboard option" + de_DE: "Zusätzliche Tastaturoption" + es_ES: "Opción adicional de teclado" + fr_FR: "Option de clavier supplémentaire" + pl_PL: "Dodatkowa opcja klawiatury" + +help.arg.license-path: + en_US: "Path to license file" + de_DE: "Pfad zur Lizenzdatei" + es_ES: "Ruta al archivo de licencia" + fr_FR: "Chemin vers le fichier de licence" + pl_PL: "Ścieżka do pliku licencji" + +help.arg.log-before: + en_US: "Show logs before the cursor position" + de_DE: "Logs vor der Cursorposition anzeigen" + es_ES: "Mostrar registros antes de la posición del cursor" + fr_FR: "Afficher les journaux avant la position du curseur" + pl_PL: "Pokaż logi przed pozycją kursora" + +help.arg.log-boot: + en_US: "Filter logs by boot ID" + de_DE: "Logs nach Boot-ID filtern" + es_ES: "Filtrar registros por ID de arranque" + fr_FR: "Filtrer les journaux par ID de démarrage" + pl_PL: "Filtruj logi według ID rozruchu" + +help.arg.log-cursor: + en_US: "Start from this cursor position" + de_DE: "Von dieser Cursorposition starten" + es_ES: "Comenzar desde esta posición del cursor" + fr_FR: "Commencer à partir de cette position du curseur" + pl_PL: "Rozpocznij od tej pozycji kursora" + +help.arg.log-follow: + en_US: "Follow log output in real-time" + de_DE: "Log-Ausgabe in Echtzeit verfolgen" + es_ES: "Seguir la salida del registro en tiempo real" + fr_FR: "Suivre la sortie des journaux en temps réel" + pl_PL: "Śledź wyjście logów w czasie rzeczywistym" + +help.arg.log-limit: + en_US: "Maximum number of log entries" + de_DE: "Maximale Anzahl von Log-Einträgen" + es_ES: "Número máximo de entradas de registro" + fr_FR: "Nombre maximum d'entrées de journal" + pl_PL: "Maksymalna liczba wpisów logu" + +help.arg.mirror-url: + en_US: "URL of the mirror" + de_DE: "URL des Spiegels" + es_ES: "URL del espejo" + fr_FR: "URL du miroir" + pl_PL: "URL serwera lustrzanego" + +help.arg.multi-arch-s9pks: + en_US: "Enable multi-architecture s9pk support" + de_DE: "Multi-Architektur-s9pk-Unterstützung aktivieren" + es_ES: "Habilitar soporte s9pk multi-arquitectura" + fr_FR: "Activer le support s9pk multi-architecture" + pl_PL: "Włącz obsługę s9pk wieloarchitekturowego" + +help.arg.no-assets: + en_US: "Build without assets directory" + de_DE: "Ohne Assets-Verzeichnis erstellen" + es_ES: "Construir sin directorio de recursos" + fr_FR: "Construire sans répertoire de ressources" + pl_PL: "Zbuduj bez katalogu zasobów" + +help.arg.no-mmap: + en_US: "Disable memory-mapped file I/O" + de_DE: "Memory-Mapped-Datei-E/A deaktivieren" + es_ES: "Deshabilitar E/S de archivos mapeados en memoria" + fr_FR: "Désactiver les E/S de fichiers mappés en mémoire" + pl_PL: "Wyłącz operacje I/O plików mapowanych w pamięci" + +help.arg.no-progress: + en_US: "Disable progress display" + de_DE: "Fortschrittsanzeige deaktivieren" + es_ES: "Deshabilitar visualización de progreso" + fr_FR: "Désactiver l'affichage de la progression" + pl_PL: "Wyłącz wyświetlanie postępu" + +help.arg.no-verify: + en_US: "Skip signature verification" + de_DE: "Signaturprüfung überspringen" + es_ES: "Omitir verificación de firma" + fr_FR: "Ignorer la vérification de signature" + pl_PL: "Pomiń weryfikację podpisu" + +help.arg.nvidia-container: + en_US: "Enable NVIDIA container support" + de_DE: "NVIDIA-Container-Unterstützung aktivieren" + es_ES: "Habilitar soporte de contenedor NVIDIA" + fr_FR: "Activer le support des conteneurs NVIDIA" + pl_PL: "Włącz obsługę kontenerów NVIDIA" + +help.arg.old-backup-password: + en_US: "Previous backup password" + de_DE: "Vorheriges Backup-Passwort" + es_ES: "Contraseña de respaldo anterior" + fr_FR: "Ancien mot de passe de sauvegarde" + pl_PL: "Poprzednie hasło kopii zapasowej" + +help.arg.only-callbacks: + en_US: "Only clear these callback IDs" + de_DE: "Nur diese Callback-IDs löschen" + es_ES: "Solo borrar estos IDs de callback" + fr_FR: "Ne supprimer que ces IDs de callback" + pl_PL: "Wyczyść tylko te identyfikatory callback" + +help.arg.only-tasks: + en_US: "Only clear these task IDs" + de_DE: "Nur diese Aufgaben-IDs löschen" + es_ES: "Solo borrar estos IDs de tarea" + fr_FR: "Ne supprimer que ces IDs de tâche" + pl_PL: "Wyczyść tylko te identyfikatory zadań" + +help.arg.os-version: + en_US: "StartOS version number" + de_DE: "StartOS-Versionsnummer" + es_ES: "Número de versión de StartOS" + fr_FR: "Numéro de version de StartOS" + pl_PL: "Numer wersji StartOS" + +help.arg.other-versions-detail: + en_US: "Detail level for other versions" + de_DE: "Detailgrad für andere Versionen" + es_ES: "Nivel de detalle para otras versiones" + fr_FR: "Niveau de détail pour les autres versions" + pl_PL: "Poziom szczegółowości dla innych wersji" + +help.arg.output-path: + en_US: "Output file path" + de_DE: "Ausgabedateipfad" + es_ES: "Ruta del archivo de salida" + fr_FR: "Chemin du fichier de sortie" + pl_PL: "Ścieżka pliku wyjściowego" + +help.arg.package-ids-to-backup: + en_US: "Package IDs to include in backup" + de_DE: "Paket-IDs für das Backup einschließen" + es_ES: "IDs de paquetes a incluir en el respaldo" + fr_FR: "IDs de paquets à inclure dans la sauvegarde" + pl_PL: "Identyfikatory pakietów do uwzględnienia w kopii zapasowej" + +help.arg.package-url: + en_US: "URL of the package" + de_DE: "URL des Pakets" + es_ES: "URL del paquete" + fr_FR: "URL du paquet" + pl_PL: "URL pakietu" + +help.arg.platform: + en_US: "Target platform identifier" + de_DE: "Zielplattform-Kennung" + es_ES: "Identificador de plataforma objetivo" + fr_FR: "Identifiant de la plateforme cible" + pl_PL: "Identyfikator platformy docelowej" + +help.arg.postgres-connection-url: + en_US: "PostgreSQL connection URL" + de_DE: "PostgreSQL-Verbindungs-URL" + es_ES: "URL de conexión PostgreSQL" + fr_FR: "URL de connexion PostgreSQL" + pl_PL: "URL połączenia PostgreSQL" + +help.arg.proxy-url: + en_US: "HTTP/SOCKS proxy URL" + de_DE: "HTTP/SOCKS-Proxy-URL" + es_ES: "URL del proxy HTTP/SOCKS" + fr_FR: "URL du proxy HTTP/SOCKS" + pl_PL: "URL proxy HTTP/SOCKS" + +help.arg.pty-size: + en_US: "PTY terminal size (:[::])" + de_DE: "PTY-Terminalgröße (:[::])" + es_ES: "Tamaño del terminal PTY (:[::])" + fr_FR: "Taille du terminal PTY (:[::])" + pl_PL: "Rozmiar terminala PTY (:[::])" + +help.arg.registry-hostname: + en_US: "Registry server hostname" + de_DE: "Registry-Server-Hostname" + es_ES: "Nombre de host del servidor de registro" + fr_FR: "Nom d'hôte du serveur de registre" + pl_PL: "Nazwa hosta serwera rejestru" + +help.arg.registry-listen-address: + en_US: "Address for registry to listen on" + de_DE: "Adresse, auf der das Registry lauscht" + es_ES: "Dirección en la que escucha el registro" + fr_FR: "Adresse d'écoute du registre" + pl_PL: "Adres nasłuchiwania rejestru" + +help.arg.registry-name: + en_US: "Name of the registry" + de_DE: "Name des Registrys" + es_ES: "Nombre del registro" + fr_FR: "Nom du registre" + pl_PL: "Nazwa rejestru" + +help.arg.registry-url: + en_US: "URL of the registry" + de_DE: "URL des Registrys" + es_ES: "URL del registro" + fr_FR: "URL du registre" + pl_PL: "URL rejestru" + +help.arg.remove-signer-contact: + en_US: "Remove contact from signer" + de_DE: "Kontakt vom Unterzeichner entfernen" + es_ES: "Eliminar contacto del firmante" + fr_FR: "Supprimer le contact du signataire" + pl_PL: "Usuń kontakt z sygnatariusza" + +help.arg.remove-signer-key: + en_US: "Remove public key from signer" + de_DE: "Öffentlichen Schlüssel vom Unterzeichner entfernen" + es_ES: "Eliminar clave pública del firmante" + fr_FR: "Supprimer la clé publique du signataire" + pl_PL: "Usuń klucz publiczny z sygnatariusza" + +help.arg.reverify-hash: + en_US: "Verify hash after download completes" + de_DE: "Hash nach Abschluss des Downloads überprüfen" + es_ES: "Verificar hash después de completar la descarga" + fr_FR: "Vérifier le hash après le téléchargement" + pl_PL: "Zweryfikuj hash po zakończeniu pobierania" + +help.arg.revision-cache-size: + en_US: "Maximum number of cached revisions" + de_DE: "Maximale Anzahl zwischengespeicherter Revisionen" + es_ES: "Número máximo de revisiones en caché" + fr_FR: "Nombre maximum de révisions en cache" + pl_PL: "Maksymalna liczba rewizji w pamięci podręcznej" + +help.arg.server-id: + en_US: "Unique server identifier" + de_DE: "Eindeutige Server-Kennung" + es_ES: "Identificador único del servidor" + fr_FR: "Identifiant unique du serveur" + pl_PL: "Unikalny identyfikator serwera" + +help.arg.set-signer-name: + en_US: "Set the signer name" + de_DE: "Unterzeichnernamen festlegen" + es_ES: "Establecer el nombre del firmante" + fr_FR: "Définir le nom du signataire" + pl_PL: "Ustaw nazwę sygnatariusza" + +help.arg.signature-hash: + en_US: "Hash for signature verification" + de_DE: "Hash für Signaturüberprüfung" + es_ES: "Hash para verificación de firma" + fr_FR: "Hash pour la vérification de signature" + pl_PL: "Hash do weryfikacji podpisu" + +help.arg.signer-contact: + en_US: "Contact information for signer" + de_DE: "Kontaktinformationen für Unterzeichner" + es_ES: "Información de contacto del firmante" + fr_FR: "Informations de contact du signataire" + pl_PL: "Informacje kontaktowe sygnatariusza" + +help.arg.signer-key: + en_US: "Public key for signer" + de_DE: "Öffentlicher Schlüssel für Unterzeichner" + es_ES: "Clave pública del firmante" + fr_FR: "Clé publique du signataire" + pl_PL: "Klucz publiczny sygnatariusza" + +help.arg.signer-name: + en_US: "Name of the signer" + de_DE: "Name des Unterzeichners" + es_ES: "Nombre del firmante" + fr_FR: "Nom du signataire" + pl_PL: "Nazwa sygnatariusza" + +help.arg.smtp-from: + en_US: "Email sender address" + de_DE: "E-Mail-Absenderadresse" + es_ES: "Dirección del remitente de correo" + fr_FR: "Adresse de l'expéditeur" + pl_PL: "Adres nadawcy e-mail" + +help.arg.smtp-login: + en_US: "SMTP authentication username" + de_DE: "SMTP-Authentifizierungsbenutzername" + es_ES: "Nombre de usuario de autenticación SMTP" + fr_FR: "Nom d'utilisateur d'authentification SMTP" + pl_PL: "Nazwa użytkownika uwierzytelniania SMTP" + +help.arg.smtp-password: + en_US: "SMTP authentication password" + de_DE: "SMTP-Authentifizierungspasswort" + es_ES: "Contraseña de autenticación SMTP" + fr_FR: "Mot de passe d'authentification SMTP" + pl_PL: "Hasło uwierzytelniania SMTP" + +help.arg.smtp-port: + en_US: "SMTP server port" + de_DE: "SMTP-Server-Port" + es_ES: "Puerto del servidor SMTP" + fr_FR: "Port du serveur SMTP" + pl_PL: "Port serwera SMTP" + +help.arg.smtp-server: + en_US: "SMTP server hostname" + de_DE: "SMTP-Server-Hostname" + es_ES: "Nombre de host del servidor SMTP" + fr_FR: "Nom d'hôte du serveur SMTP" + pl_PL: "Nazwa hosta serwera SMTP" + +help.arg.smtp-to: + en_US: "Email recipient address" + de_DE: "E-Mail-Empfängeradresse" + es_ES: "Dirección del destinatario de correo" + fr_FR: "Adresse du destinataire" + pl_PL: "Adres odbiorcy e-mail" + +help.arg.socket-path: + en_US: "Path to Unix socket" + de_DE: "Pfad zum Unix-Socket" + es_ES: "Ruta al socket Unix" + fr_FR: "Chemin vers le socket Unix" + pl_PL: "Ścieżka do gniazda Unix" + +help.arg.socks-listen-address: + en_US: "SOCKS proxy listen address" + de_DE: "SOCKS-Proxy-Lausch-Adresse" + es_ES: "Dirección de escucha del proxy SOCKS" + fr_FR: "Adresse d'écoute du proxy SOCKS" + pl_PL: "Adres nasłuchiwania proxy SOCKS" + +help.arg.soft-uninstall: + en_US: "Uninstall without deleting service data" + de_DE: "Deinstallieren ohne Service-Daten zu löschen" + es_ES: "Desinstalar sin eliminar datos del servicio" + fr_FR: "Désinstaller sans supprimer les données du service" + pl_PL: "Odinstaluj bez usuwania danych usługi" + +help.arg.source-version: + en_US: "Source version to upgrade from" + de_DE: "Quellversion für das Upgrade" + es_ES: "Versión de origen para actualizar" + fr_FR: "Version source pour la mise à niveau" + pl_PL: "Wersja źródłowa do aktualizacji" + +help.arg.squashfs-image-path: + en_US: "Path to squashfs image file" + de_DE: "Pfad zur SquashFS-Image-Datei" + es_ES: "Ruta al archivo de imagen squashfs" + fr_FR: "Chemin vers le fichier image squashfs" + pl_PL: "Ścieżka do pliku obrazu squashfs" + +help.arg.subcontainer-name: + en_US: "Name of the subcontainer" + de_DE: "Name des Subcontainers" + es_ES: "Nombre del subcontenedor" + fr_FR: "Nom du sous-conteneur" + pl_PL: "Nazwa podkontenera" + +help.arg.target-disk: + en_US: "Target disk for installation" + de_DE: "Zieldatenträger für die Installation" + es_ES: "Disco de destino para la instalación" + fr_FR: "Disque cible pour l'installation" + pl_PL: "Dysk docelowy do instalacji" + +help.arg.target-version-range: + en_US: "Target version range constraint" + de_DE: "Ziel-Versionsbereichs-Einschränkung" + es_ES: "Restricción de rango de versión objetivo" + fr_FR: "Contrainte de plage de version cible" + pl_PL: "Ograniczenie zakresu wersji docelowej" + +help.arg.tor-proxy-url: + en_US: "Tor SOCKS proxy URL" + de_DE: "Tor-SOCKS-Proxy-URL" + es_ES: "URL del proxy SOCKS de Tor" + fr_FR: "URL du proxy SOCKS Tor" + pl_PL: "URL proxy SOCKS Tor" + +help.arg.tunnel-address: + en_US: "Tunnel server address" + de_DE: "Tunnel-Server-Adresse" + es_ES: "Dirección del servidor de túnel" + fr_FR: "Adresse du serveur tunnel" + pl_PL: "Adres serwera tunelu" + +help.arg.tunnel-listen-address: + en_US: "Address for tunnel to listen on" + de_DE: "Adresse, auf der der Tunnel lauscht" + es_ES: "Dirección en la que escucha el túnel" + fr_FR: "Adresse d'écoute du tunnel" + pl_PL: "Adres nasłuchiwania tunelu" + +help.arg.update-target-version: + en_US: "Target version for update" + de_DE: "Zielversion für das Update" + es_ES: "Versión objetivo para la actualización" + fr_FR: "Version cible pour la mise à jour" + pl_PL: "Wersja docelowa aktualizacji" + +help.arg.use-efi-boot: + en_US: "Use EFI boot mode" + de_DE: "EFI-Boot-Modus verwenden" + es_ES: "Usar modo de arranque EFI" + fr_FR: "Utiliser le mode de démarrage EFI" + pl_PL: "Użyj trybu rozruchu EFI" + +help.arg.user-name: + en_US: "User name to run as" + de_DE: "Benutzername für die Ausführung" + es_ES: "Nombre de usuario para ejecutar" + fr_FR: "Nom d'utilisateur pour l'exécution" + pl_PL: "Nazwa użytkownika do uruchomienia" + +help.arg.version-range: + en_US: "Version range constraint" + de_DE: "Versionsbereichs-Einschränkung" + es_ES: "Restricción de rango de versión" + fr_FR: "Contrainte de plage de version" + pl_PL: "Ograniczenie zakresu wersji" + +help.arg.wifi-country-code: + en_US: "ISO 3166-1 alpha-2 WiFi country code" + de_DE: "ISO 3166-1 Alpha-2 WLAN-Ländercode" + es_ES: "Código de país WiFi ISO 3166-1 alpha-2" + fr_FR: "Code pays WiFi ISO 3166-1 alpha-2" + pl_PL: "Kod kraju WiFi ISO 3166-1 alpha-2" + +help.arg.wipe-drive: + en_US: "Wipe the drive before use" + de_DE: "Laufwerk vor Verwendung löschen" + es_ES: "Borrar la unidad antes de usar" + fr_FR: "Effacer le disque avant utilisation" + pl_PL: "Wyczyść dysk przed użyciem" + +help.arg.wipe-tor-state: + en_US: "Wipe Tor state" + de_DE: "Tor-Zustand löschen" + es_ES: "Borrar estado de Tor" + fr_FR: "Effacer l'état de Tor" + pl_PL: "Wyczyść stan Tora" + +help.arg.workdir-path: + en_US: "Working directory path" + de_DE: "Arbeitsverzeichnispfad" + es_ES: "Ruta del directorio de trabajo" + fr_FR: "Chemin du répertoire de travail" + pl_PL: "Ścieżka katalogu roboczego" + +help.arg.action-id: + en_US: "Action identifier" + de_DE: "Aktions-Kennung" + es_ES: "Identificador de acción" + fr_FR: "Identifiant de l'action" + pl_PL: "Identyfikator akcji" + +help.arg.action-input: + en_US: "Input data for the action" + de_DE: "Eingabedaten für die Aktion" + es_ES: "Datos de entrada para la acción" + fr_FR: "Données d'entrée pour l'action" + pl_PL: "Dane wejściowe dla akcji" + +help.arg.asset-file-path: + en_US: "Path to the asset file" + de_DE: "Pfad zur Asset-Datei" + es_ES: "Ruta al archivo de recursos" + fr_FR: "Chemin vers le fichier de ressources" + pl_PL: "Ścieżka do pliku zasobów" + +help.arg.asset-url: + en_US: "URL of the asset" + de_DE: "URL des Assets" + es_ES: "URL del recurso" + fr_FR: "URL de la ressource" + pl_PL: "URL zasobu" + +help.arg.backup-password: + en_US: "Password for backup encryption" + de_DE: "Passwort für Backup-Verschlüsselung" + es_ES: "Contraseña para cifrado de copia de seguridad" + fr_FR: "Mot de passe pour le chiffrement de la sauvegarde" + pl_PL: "Hasło do szyfrowania kopii zapasowej" + +help.arg.backup-target-id: + en_US: "Backup target identifier" + de_DE: "Backup-Ziel-Kennung" + es_ES: "Identificador de destino de copia de seguridad" + fr_FR: "Identifiant de la cible de sauvegarde" + pl_PL: "Identyfikator celu kopii zapasowej" + +help.arg.chroot-path: + en_US: "Path for chroot environment" + de_DE: "Pfad für Chroot-Umgebung" + es_ES: "Ruta para el entorno chroot" + fr_FR: "Chemin pour l'environnement chroot" + pl_PL: "Ścieżka środowiska chroot" + +help.arg.cifs-hostname: + en_US: "CIFS server hostname" + de_DE: "CIFS-Server-Hostname" + es_ES: "Nombre de host del servidor CIFS" + fr_FR: "Nom d'hôte du serveur CIFS" + pl_PL: "Nazwa hosta serwera CIFS" + +help.arg.cifs-password: + en_US: "CIFS authentication password" + de_DE: "CIFS-Authentifizierungspasswort" + es_ES: "Contraseña de autenticación CIFS" + fr_FR: "Mot de passe d'authentification CIFS" + pl_PL: "Hasło uwierzytelniania CIFS" + +help.arg.cifs-path: + en_US: "Path on the CIFS share" + de_DE: "Pfad auf der CIFS-Freigabe" + es_ES: "Ruta en el recurso compartido CIFS" + fr_FR: "Chemin sur le partage CIFS" + pl_PL: "Ścieżka na udziale CIFS" + +help.arg.cifs-username: + en_US: "CIFS authentication username" + de_DE: "CIFS-Authentifizierungsbenutzername" + es_ES: "Nombre de usuario de autenticación CIFS" + fr_FR: "Nom d'utilisateur d'authentification CIFS" + pl_PL: "Nazwa użytkownika uwierzytelniania CIFS" + +help.arg.database-path: + en_US: "Path to the database file" + de_DE: "Pfad zur Datenbankdatei" + es_ES: "Ruta al archivo de base de datos" + fr_FR: "Chemin vers le fichier de base de données" + pl_PL: "Ścieżka do pliku bazy danych" + +help.arg.db-apply-expr: + en_US: "Database patch expression to apply" + de_DE: "Datenbank-Patch-Ausdruck zum Anwenden" + es_ES: "Expresión de parche de base de datos a aplicar" + fr_FR: "Expression de patch de base de données à appliquer" + pl_PL: "Wyrażenie łatki bazy danych do zastosowania" + +help.arg.db-path: + en_US: "Path to the database" + de_DE: "Pfad zur Datenbank" + es_ES: "Ruta a la base de datos" + fr_FR: "Chemin vers la base de données" + pl_PL: "Ścieżka do bazy danych" + +help.arg.dns-servers: + en_US: "DNS servers to use" + de_DE: "Zu verwendende DNS-Server" + es_ES: "Servidores DNS a utilizar" + fr_FR: "Serveurs DNS à utiliser" + pl_PL: "Serwery DNS do użycia" + +help.arg.echo-message: + en_US: "Message to echo back" + de_DE: "Nachricht zum Zurücksenden" + es_ES: "Mensaje para devolver" + fr_FR: "Message à renvoyer" + pl_PL: "Wiadomość do odesłania" + +help.arg.enable-zram: + en_US: "Enable zram" + de_DE: "Zram aktivieren" + es_ES: "Habilitar zram" + fr_FR: "Activer zram" + pl_PL: "Włącz zram" + +help.arg.file-path: + en_US: "Path to the file" + de_DE: "Pfad zur Datei" + es_ES: "Ruta al archivo" + fr_FR: "Chemin vers le fichier" + pl_PL: "Ścieżka do pliku" + +help.arg.fqdn: + en_US: "Fully qualified domain name" + de_DE: "Vollständig qualifizierter Domainname" + es_ES: "Nombre de dominio completo" + fr_FR: "Nom de domaine entièrement qualifié" + pl_PL: "W pełni kwalifikowana nazwa domeny" + +help.arg.gateway-id: + en_US: "Gateway identifier" + de_DE: "Gateway-Kennung" + es_ES: "Identificador de puerta de enlace" + fr_FR: "Identifiant de la passerelle" + pl_PL: "Identyfikator bramy" + +help.arg.gateway-name: + en_US: "Name of the gateway" + de_DE: "Name des Gateways" + es_ES: "Nombre de la puerta de enlace" + fr_FR: "Nom de la passerelle" + pl_PL: "Nazwa bramy" + +help.arg.governor-name: + en_US: "CPU governor name" + de_DE: "CPU-Governor-Name" + es_ES: "Nombre del gobernador de CPU" + fr_FR: "Nom du gouverneur CPU" + pl_PL: "Nazwa regulatora CPU" + +help.arg.host-id: + en_US: "Host identifier" + de_DE: "Host-Kennung" + es_ES: "Identificador de host" + fr_FR: "Identifiant de l'hôte" + pl_PL: "Identyfikator hosta" + +help.arg.input-path: + en_US: "Path to input file or directory" + de_DE: "Pfad zur Eingabedatei oder zum Verzeichnis" + es_ES: "Ruta al archivo o directorio de entrada" + fr_FR: "Chemin vers le fichier ou répertoire d'entrée" + pl_PL: "Ścieżka do pliku lub katalogu wejściowego" + +help.arg.internal-port: + en_US: "Internal port number" + de_DE: "Interne Portnummer" + es_ES: "Número de puerto interno" + fr_FR: "Numéro de port interne" + pl_PL: "Numer portu wewnętrznego" + +help.arg.is-public: + en_US: "Whether the interface is publicly addressable" + de_DE: "Ob die Schnittstelle öffentlich erreichbar ist" + es_ES: "Si la interfaz es accesible públicamente" + fr_FR: "Si l'interface est accessible publiquement" + pl_PL: "Czy interfejs jest publicznie adresowalny" + +help.arg.json-value: + en_US: "JSON value to set" + de_DE: "JSON-Wert zum Setzen" + es_ES: "Valor JSON a establecer" + fr_FR: "Valeur JSON à définir" + pl_PL: "Wartość JSON do ustawienia" + +help.arg.keyboard-layout: + en_US: "Keyboard layout code" + de_DE: "Tastaturbelegungscode" + es_ES: "Código de distribución de teclado" + fr_FR: "Code de disposition du clavier" + pl_PL: "Kod układu klawiatury" + +help.arg.keyboard-model: + en_US: "Keyboard model" + de_DE: "Tastaturmodell" + es_ES: "Modelo de teclado" + fr_FR: "Modèle de clavier" + pl_PL: "Model klawiatury" + +help.arg.keyboard-variant: + en_US: "Keyboard layout variant" + de_DE: "Tastaturbelegungsvariante" + es_ES: "Variante de distribución de teclado" + fr_FR: "Variante de disposition du clavier" + pl_PL: "Wariant układu klawiatury" + +help.arg.keyboard-keymap: + en_US: "Console keymap for vconsole" + de_DE: "Konsolen-Tastaturbelegung für vconsole" + es_ES: "Mapa de teclado de consola para vconsole" + fr_FR: "Disposition clavier de console pour vconsole" + pl_PL: "Mapa klawiszy konsoli dla vconsole" + +help.arg.language-code: + en_US: "Language code" + de_DE: "Sprachcode" + es_ES: "Código de idioma" + fr_FR: "Code de langue" + pl_PL: "Kod języka" + +help.arg.listen-address: + en_US: "Address to listen on" + de_DE: "Adresse zum Lauschen" + es_ES: "Dirección para escuchar" + fr_FR: "Adresse d'écoute" + pl_PL: "Adres nasłuchiwania" + +help.arg.new-password: + en_US: "New password" + de_DE: "Neues Passwort" + es_ES: "Nueva contraseña" + fr_FR: "Nouveau mot de passe" + pl_PL: "Nowe hasło" + +help.arg.notification-before-id: + en_US: "Get notifications before this ID" + de_DE: "Benachrichtigungen vor dieser ID abrufen" + es_ES: "Obtener notificaciones antes de este ID" + fr_FR: "Obtenir les notifications avant cet ID" + pl_PL: "Pobierz powiadomienia przed tym ID" + +help.arg.notification-ids: + en_US: "Notification IDs" + de_DE: "Benachrichtigungs-IDs" + es_ES: "IDs de notificación" + fr_FR: "IDs de notification" + pl_PL: "ID powiadomień" + +help.arg.notification-level: + en_US: "Notification severity level" + de_DE: "Benachrichtigungsschweregrad" + es_ES: "Nivel de gravedad de notificación" + fr_FR: "Niveau de gravité de la notification" + pl_PL: "Poziom ważności powiadomienia" + +help.arg.notification-limit: + en_US: "Maximum number of notifications to return" + de_DE: "Maximale Anzahl zurückzugebender Benachrichtigungen" + es_ES: "Número máximo de notificaciones a devolver" + fr_FR: "Nombre maximum de notifications à retourner" + pl_PL: "Maksymalna liczba powiadomień do zwrócenia" + +help.arg.notification-message: + en_US: "Notification message content" + de_DE: "Inhalt der Benachrichtigung" + es_ES: "Contenido del mensaje de notificación" + fr_FR: "Contenu du message de notification" + pl_PL: "Treść powiadomienia" + +help.arg.notification-title: + en_US: "Notification title" + de_DE: "Benachrichtigungstitel" + es_ES: "Título de notificación" + fr_FR: "Titre de la notification" + pl_PL: "Tytuł powiadomienia" + +help.arg.old-password: + en_US: "Current password" + de_DE: "Aktuelles Passwort" + es_ES: "Contraseña actual" + fr_FR: "Mot de passe actuel" + pl_PL: "Aktualne hasło" + +help.arg.onion-address: + en_US: "Tor onion address" + de_DE: "Tor-Onion-Adresse" + es_ES: "Dirección onion de Tor" + fr_FR: "Adresse onion Tor" + pl_PL: "Adres onion Tor" + +help.arg.onion-secret-key: + en_US: "Tor onion secret key" + de_DE: "Geheimer Tor-Onion-Schlüssel" + es_ES: "Clave secreta onion de Tor" + fr_FR: "Clé secrète onion Tor" + pl_PL: "Tajny klucz onion Tor" + +help.arg.os-drive-path: + en_US: "Path to OS drive" + de_DE: "Pfad zum Betriebssystemlaufwerk" + es_ES: "Ruta a la unidad del sistema operativo" + fr_FR: "Chemin vers le disque du système d'exploitation" + pl_PL: "Ścieżka do dysku systemu operacyjnego" + +help.arg.package-id: + en_US: "Package identifier" + de_DE: "Paket-Kennung" + es_ES: "Identificador de paquete" + fr_FR: "Identifiant du paquet" + pl_PL: "Identyfikator pakietu" + +help.arg.package-ids: + en_US: "Package identifiers" + de_DE: "Paket-Kennungen" + es_ES: "Identificadores de paquete" + fr_FR: "Identifiants de paquets" + pl_PL: "Identyfikatory pakietów" + +help.arg.package-version: + en_US: "Package version" + de_DE: "Paketversion" + es_ES: "Versión del paquete" + fr_FR: "Version du paquet" + pl_PL: "Wersja pakietu" + +help.arg.release-notes: + en_US: "Release notes for this version" + de_DE: "Versionshinweise für diese Version" + es_ES: "Notas de lanzamiento para esta versión" + fr_FR: "Notes de version pour cette version" + pl_PL: "Informacje o wydaniu dla tej wersji" + +help.arg.replay-id: + en_US: "Replay identifier for task" + de_DE: "Wiederholungskennung für Aufgabe" + es_ES: "Identificador de repetición para tarea" + fr_FR: "Identifiant de relecture pour la tâche" + pl_PL: "Identyfikator powtórzenia dla zadania" + +help.arg.reset-reason: + en_US: "Reason for the reset" + de_DE: "Grund für das Zurücksetzen" + es_ES: "Razón del restablecimiento" + fr_FR: "Raison de la réinitialisation" + pl_PL: "Powód resetowania" + +help.arg.s9pk-file-path: + en_US: "Path to s9pk package file" + de_DE: "Pfad zur s9pk-Paketdatei" + es_ES: "Ruta al archivo de paquete s9pk" + fr_FR: "Chemin vers le fichier de paquet s9pk" + pl_PL: "Ścieżka do pliku pakietu s9pk" + +help.arg.session-ids: + en_US: "Session identifiers" + de_DE: "Sitzungskennungen" + es_ES: "Identificadores de sesión" + fr_FR: "Identifiants de session" + pl_PL: "Identyfikatory sesji" + +help.arg.signer-id: + en_US: "Signer identifier" + de_DE: "Unterzeichner-Kennung" + es_ES: "Identificador de firmante" + fr_FR: "Identifiant du signataire" + pl_PL: "Identyfikator sygnatariusza" + +help.arg.source-version-range: + en_US: "Source version range for migration" + de_DE: "Quellversionsbereich für Migration" + es_ES: "Rango de versión de origen para migración" + fr_FR: "Plage de version source pour la migration" + pl_PL: "Zakres wersji źródłowej dla migracji" + +help.arg.ssh-fingerprint: + en_US: "SSH key fingerprint" + de_DE: "SSH-Schlüsselfingerabdruck" + es_ES: "Huella digital de clave SSH" + fr_FR: "Empreinte de clé SSH" + pl_PL: "Odcisk palca klucza SSH" + +help.arg.ssh-public-key: + en_US: "SSH public key" + de_DE: "Öffentlicher SSH-Schlüssel" + es_ES: "Clave pública SSH" + fr_FR: "Clé publique SSH" + pl_PL: "Klucz publiczny SSH" + +help.arg.tunnel-ip-address: + en_US: "Tunnel server IP address" + de_DE: "Tunnel-Server-IP-Adresse" + es_ES: "Dirección IP del servidor de túnel" + fr_FR: "Adresse IP du serveur tunnel" + pl_PL: "Adres IP serwera tunelu" + +help.arg.tunnel-name: + en_US: "Tunnel name" + de_DE: "Tunnelname" + es_ES: "Nombre del túnel" + fr_FR: "Nom du tunnel" + pl_PL: "Nazwa tunelu" + +help.arg.tunnel-url: + en_US: "Tunnel server URL" + de_DE: "Tunnel-Server-URL" + es_ES: "URL del servidor de túnel" + fr_FR: "URL du serveur tunnel" + pl_PL: "URL serwera tunelu" + +help.arg.version-headline: + en_US: "Short headline for the version" + de_DE: "Kurze Überschrift für die Version" + es_ES: "Título corto para la versión" + fr_FR: "Titre court pour la version" + pl_PL: "Krótki nagłówek dla wersji" + +help.arg.wifi-enabled: + en_US: "Enable or disable WiFi" + de_DE: "WLAN aktivieren oder deaktivieren" + es_ES: "Habilitar o deshabilitar WiFi" + fr_FR: "Activer ou désactiver le WiFi" + pl_PL: "Włącz lub wyłącz WiFi" + +help.arg.wifi-password: + en_US: "WiFi network password" + de_DE: "WLAN-Netzwerk-Passwort" + es_ES: "Contraseña de red WiFi" + fr_FR: "Mot de passe du réseau WiFi" + pl_PL: "Hasło sieci WiFi" + +help.arg.wifi-ssid: + en_US: "WiFi network SSID" + de_DE: "WLAN-Netzwerk-SSID" + es_ES: "SSID de red WiFi" + fr_FR: "SSID du réseau WiFi" + pl_PL: "SSID sieci WiFi" + +help.arg.wireguard-config: + en_US: "WireGuard configuration" + de_DE: "WireGuard-Konfiguration" + es_ES: "Configuración de WireGuard" + fr_FR: "Configuration WireGuard" + pl_PL: "Konfiguracja WireGuard" + +# CLI command descriptions (about.*) +about.add-address-to-host: + en_US: "Add an address to this host" + de_DE: "Eine Adresse zu diesem Host hinzufügen" + es_ES: "Agregar una dirección a este host" + fr_FR: "Ajouter une adresse à cet hôte" + pl_PL: "Dodaj adres do tego hosta" + +about.add-admin-signer: + en_US: "Add admin signer" + de_DE: "Admin-Unterzeichner hinzufügen" + es_ES: "Agregar firmante administrador" + fr_FR: "Ajouter un signataire administrateur" + pl_PL: "Dodaj sygnatariusza administratora" + +about.add-asset-registry: + en_US: "Add asset to registry" + de_DE: "Asset zum Registry hinzufügen" + es_ES: "Agregar activo al registro" + fr_FR: "Ajouter un actif au registre" + pl_PL: "Dodaj zasób do rejestru" + +about.add-category-registry: + en_US: "Add a category to the registry" + de_DE: "Eine Kategorie zum Registry hinzufügen" + es_ES: "Agregar una categoría al registro" + fr_FR: "Ajouter une catégorie au registre" + pl_PL: "Dodaj kategorię do rejestru" + +about.add-device-to-subnet: + en_US: "Add a device to a subnet" + de_DE: "Ein Gerät zu einem Subnetz hinzufügen" + es_ES: "Agregar un dispositivo a una subred" + fr_FR: "Ajouter un appareil à un sous-réseau" + pl_PL: "Dodaj urządzenie do podsieci" + +about.add-image-to-s9pk: + en_US: "Add image to s9pk" + de_DE: "Image zu s9pk hinzufügen" + es_ES: "Agregar imagen a s9pk" + fr_FR: "Ajouter une image au s9pk" + pl_PL: "Dodaj obraz do s9pk" + +about.add-mirror-s9pk: + en_US: "Add a mirror for an s9pk" + de_DE: "Einen Spiegel für ein s9pk hinzufügen" + es_ES: "Agregar un espejo para un s9pk" + fr_FR: "Ajouter un miroir pour un s9pk" + pl_PL: "Dodaj serwer lustrzany dla s9pk" + +about.add-new-authorized-key: + en_US: "Add a new authorized key" + de_DE: "Einen neuen autorisierten Schlüssel hinzufügen" + es_ES: "Agregar una nueva clave autorizada" + fr_FR: "Ajouter une nouvelle clé autorisée" + pl_PL: "Dodaj nowy autoryzowany klucz" + +about.add-new-backup-target: + en_US: "Add a new backup target" + de_DE: "Ein neues Backup-Ziel hinzufügen" + es_ES: "Agregar un nuevo destino de respaldo" + fr_FR: "Ajouter une nouvelle cible de sauvegarde" + pl_PL: "Dodaj nowy cel kopii zapasowej" + +about.add-new-port-forward: + en_US: "Add a new port forward" + de_DE: "Eine neue Portweiterleitung hinzufügen" + es_ES: "Agregar un nuevo reenvío de puerto" + fr_FR: "Ajouter une nouvelle redirection de port" + pl_PL: "Dodaj nowe przekierowanie portu" + +about.add-new-subnet: + en_US: "Add a new subnet" + de_DE: "Ein neues Subnetz hinzufügen" + es_ES: "Agregar una nueva subred" + fr_FR: "Ajouter un nouveau sous-réseau" + pl_PL: "Dodaj nową podsieć" + +about.add-new-tunnel: + en_US: "Add a new tunnel" + de_DE: "Einen neuen Tunnel hinzufügen" + es_ES: "Agregar un nuevo túnel" + fr_FR: "Ajouter un nouveau tunnel" + pl_PL: "Dodaj nowy tunel" + +about.add-onion-service-key-to-store: + en_US: "Add an onion service key to the key store" + de_DE: "Einen Onion-Service-Schlüssel zum Schlüsselspeicher hinzufügen" + es_ES: "Agregar una clave de servicio onion al almacén de claves" + fr_FR: "Ajouter une clé de service onion au magasin de clés" + pl_PL: "Dodaj klucz usługi onion do magazynu kluczy" + +about.add-or-remove-authorized-clients: + en_US: "Add or remove authorized clients" + de_DE: "Autorisierte Clients hinzufügen oder entfernen" + es_ES: "Agregar o eliminar clientes autorizados" + fr_FR: "Ajouter ou supprimer des clients autorisés" + pl_PL: "Dodaj lub usuń autoryzowanych klientów" + +about.add-os-version: + en_US: "Add OS version" + de_DE: "Betriebssystemversion hinzufügen" + es_ES: "Agregar versión del SO" + fr_FR: "Ajouter une version du système d'exploitation" + pl_PL: "Dodaj wersję systemu operacyjnego" + +about.add-package-category: + en_US: "Add a package to a category" + de_DE: "Ein Paket zu einer Kategorie hinzufügen" + es_ES: "Agregar un paquete a una categoría" + fr_FR: "Ajouter un paquet à une catégorie" + pl_PL: "Dodaj pakiet do kategorii" + +about.add-package-registry: + en_US: "Add package to registry index" + de_DE: "Paket zum Registry-Index hinzufügen" + es_ES: "Agregar paquete al índice del registro" + fr_FR: "Ajouter un paquet à l'index du registre" + pl_PL: "Dodaj pakiet do indeksu rejestru" + +about.add-package-signer: + en_US: "Add package signer" + de_DE: "Paket-Unterzeichner hinzufügen" + es_ES: "Agregar firmante de paquete" + fr_FR: "Ajouter un signataire de paquet" + pl_PL: "Dodaj sygnatariusza pakietu" + +about.add-path-value-db: + en_US: "Add path and value to db" + de_DE: "Pfad und Wert zur Datenbank hinzufügen" + es_ES: "Agregar ruta y valor a la base de datos" + fr_FR: "Ajouter un chemin et une valeur à la base de données" + pl_PL: "Dodaj ścieżkę i wartość do bazy danych" + +about.add-private-domain-to-host: + en_US: "Add a private domain to this host" + de_DE: "Eine private Domain zu diesem Host hinzufügen" + es_ES: "Agregar un dominio privado a este host" + fr_FR: "Ajouter un domaine privé à cet hôte" + pl_PL: "Dodaj prywatną domenę do tego hosta" + +about.add-public-domain-to-host: + en_US: "Add a public domain to this host" + de_DE: "Eine öffentliche Domain zu diesem Host hinzufügen" + es_ES: "Agregar un dominio público a este host" + fr_FR: "Ajouter un domaine public à cet hôte" + pl_PL: "Dodaj publiczną domenę do tego hosta" + +about.add-remove-list-package-signers: + en_US: "Add, remove, and list package signers" + de_DE: "Paket-Unterzeichner hinzufügen, entfernen und auflisten" + es_ES: "Agregar, eliminar y listar firmantes de paquetes" + fr_FR: "Ajouter, supprimer et lister les signataires de paquets" + pl_PL: "Dodaj, usuń i wyświetl sygnatariuszy pakietów" + +about.add-remove-list-version-signers: + en_US: "Add, remove, and list version signers" + de_DE: "Versions-Unterzeichner hinzufügen, entfernen und auflisten" + es_ES: "Agregar, eliminar y listar firmantes de versiones" + fr_FR: "Ajouter, supprimer et lister les signataires de versions" + pl_PL: "Dodaj, usuń i wyświetl sygnatariuszy wersji" + +about.add-remove-or-list-devices-in-subnets: + en_US: "Add, remove, or list devices in subnets" + de_DE: "Geräte in Subnetzen hinzufügen, entfernen oder auflisten" + es_ES: "Agregar, eliminar o listar dispositivos en subredes" + fr_FR: "Ajouter, supprimer ou lister les appareils dans les sous-réseaux" + pl_PL: "Dodaj, usuń lub wyświetl urządzenia w podsieciach" + +about.add-remove-or-modify-subnets: + en_US: "Add, remove, or modify subnets" + de_DE: "Subnetze hinzufügen, entfernen oder ändern" + es_ES: "Agregar, eliminar o modificar subredes" + fr_FR: "Ajouter, supprimer ou modifier des sous-réseaux" + pl_PL: "Dodaj, usuń lub zmodyfikuj podsieci" + +about.add-remove-update-backup-target: + en_US: "Add, remove, or update a backup target" + de_DE: "Backup-Ziel hinzufügen, entfernen oder aktualisieren" + es_ES: "Agregar, eliminar o actualizar un destino de respaldo" + fr_FR: "Ajouter, supprimer ou mettre à jour une cible de sauvegarde" + pl_PL: "Dodaj, usuń lub zaktualizuj cel kopii zapasowej" + +about.add-signer: + en_US: "Add signer" + de_DE: "Unterzeichner hinzufügen" + es_ES: "Agregar firmante" + fr_FR: "Ajouter un signataire" + pl_PL: "Dodaj sygnatariusza" + +about.add-ssh-key: + en_US: "Add ssh key" + de_DE: "SSH-Schlüssel hinzufügen" + es_ES: "Agregar clave ssh" + fr_FR: "Ajouter une clé ssh" + pl_PL: "Dodaj klucz ssh" + +about.add-version-signer: + en_US: "Add version signer" + de_DE: "Versions-Unterzeichner hinzufügen" + es_ES: "Agregar firmante de versión" + fr_FR: "Ajouter un signataire de version" + pl_PL: "Dodaj sygnatariusza wersji" + +about.add-wifi-ssid-password: + en_US: "Add wifi ssid and password" + de_DE: "WLAN-SSID und Passwort hinzufügen" + es_ES: "Agregar ssid y contraseña de wifi" + fr_FR: "Ajouter un ssid et mot de passe wifi" + pl_PL: "Dodaj ssid i hasło wifi" + +about.allow-gateway-infer-inbound-access-from-wan: + en_US: "Allow this gateway to infer whether it has inbound access from the WAN based on its IPv4 address" + de_DE: "Diesem Gateway erlauben, anhand seiner IPv4-Adresse zu ermitteln, ob es eingehenden Zugriff vom WAN hat" + es_ES: "Permitir que este gateway deduzca si tiene acceso entrante desde WAN basado en su dirección IPv4" + fr_FR: "Permettre à cette passerelle de déduire si elle a un accès entrant depuis le WAN en fonction de son adresse IPv4" + pl_PL: "Pozwól tej bramce wywnioskować, czy ma dostęp przychodzący z WAN na podstawie adresu IPv4" + +about.calculate-blake3-hash-for-file: + en_US: "Calculate blake3 hash for a file" + de_DE: "Blake3-Hash für eine Datei berechnen" + es_ES: "Calcular hash blake3 para un archivo" + fr_FR: "Calculer le hachage blake3 d'un fichier" + pl_PL: "Oblicz hash blake3 dla pliku" + +about.cancel-install-package: + en_US: "Cancel an install of a package" + de_DE: "Eine Paketinstallation abbrechen" + es_ES: "Cancelar la instalación de un paquete" + fr_FR: "Annuler l'installation d'un paquet" + pl_PL: "Anuluj instalację pakietu" + +about.check-update-startos: + en_US: "Check a given registry for StartOS updates and update if available" + de_DE: "Ein bestimmtes Registry auf StartOS-Updates prüfen und bei Verfügbarkeit aktualisieren" + es_ES: "Verificar un registro dado para actualizaciones de StartOS y actualizar si está disponible" + fr_FR: "Vérifier un registre donné pour les mises à jour StartOS et mettre à jour si disponible" + pl_PL: "Sprawdź dany rejestr pod kątem aktualizacji StartOS i zaktualizuj, jeśli dostępne" + +about.clear-service-task: + en_US: "Clear a service task" + de_DE: "Eine Dienstaufgabe löschen" + es_ES: "Borrar una tarea de servicio" + fr_FR: "Effacer une tâche de service" + pl_PL: "Wyczyść zadanie usługi" + +about.clear-smtp: + en_US: "Remove system smtp server and credentials" + de_DE: "System-SMTP-Server und Anmeldedaten entfernen" + es_ES: "Eliminar servidor smtp del sistema y credenciales" + fr_FR: "Supprimer le serveur smtp système et les identifiants" + pl_PL: "Usuń systemowy serwer smtp i dane uwierzytelniające" + +about.command-add-ui-record-db: + en_US: "Command for adding UI record to db" + de_DE: "Befehl zum Hinzufügen eines UI-Eintrags zur Datenbank" + es_ES: "Comando para agregar registro de UI a la base de datos" + fr_FR: "Commande pour ajouter un enregistrement UI à la base de données" + pl_PL: "Polecenie dodania rekordu UI do bazy danych" + +about.command-blake3-hash: + en_US: "Command for calculating the blake3 hash of a file" + de_DE: "Befehl zur Berechnung des Blake3-Hashs einer Datei" + es_ES: "Comando para calcular el hash blake3 de un archivo" + fr_FR: "Commande pour calculer le hachage blake3 d'un fichier" + pl_PL: "Polecenie obliczenia hashu blake3 pliku" + +about.command-list-available-wifi: + en_US: "Command to list available wifi networks" + de_DE: "Befehl zum Auflisten verfügbarer WLAN-Netzwerke" + es_ES: "Comando para listar redes wifi disponibles" + fr_FR: "Commande pour lister les réseaux wifi disponibles" + pl_PL: "Polecenie wyświetlenia dostępnych sieci wifi" + +about.command-remove-disk-filesystem: + en_US: "Command to remove disk from filesystem" + de_DE: "Befehl zum Entfernen der Festplatte aus dem Dateisystem" + es_ES: "Comando para eliminar disco del sistema de archivos" + fr_FR: "Commande pour retirer le disque du système de fichiers" + pl_PL: "Polecenie usunięcia dysku z systemu plików" + +about.commands-action: + en_US: "Commands to get action input or run an action" + de_DE: "Befehle zum Abrufen von Aktionseingaben oder Ausführen einer Aktion" + es_ES: "Comandos para obtener entrada de acción o ejecutar una acción" + fr_FR: "Commandes pour obtenir une entrée d'action ou exécuter une action" + pl_PL: "Polecenia do uzyskania wejścia akcji lub uruchomienia akcji" + +about.commands-add-image-or-edit-manifest: + en_US: "Commands to add an image to an s9pk or edit the manifest" + de_DE: "Befehle zum Hinzufügen eines Images zu einem s9pk oder Bearbeiten des Manifests" + es_ES: "Comandos para agregar una imagen a un s9pk o editar el manifiesto" + fr_FR: "Commandes pour ajouter une image à un s9pk ou éditer le manifeste" + pl_PL: "Polecenia dodania obrazu do s9pk lub edycji manifestu" + +about.commands-add-list-admins-signers: + en_US: "Commands to add or list admins or signers" + de_DE: "Befehle zum Hinzufügen oder Auflisten von Admins oder Unterzeichnern" + es_ES: "Comandos para agregar o listar administradores o firmantes" + fr_FR: "Commandes pour ajouter ou lister les administrateurs ou signataires" + pl_PL: "Polecenia dodania lub wyświetlenia administratorów lub sygnatariuszy" + +about.commands-add-list-signers: + en_US: "Commands to add or list signers" + de_DE: "Befehle zum Hinzufügen oder Auflisten von Unterzeichnern" + es_ES: "Comandos para agregar o listar firmantes" + fr_FR: "Commandes pour ajouter ou lister les signataires" + pl_PL: "Polecenia dodania lub wyświetlenia sygnatariuszy" + +about.commands-add-remove-list-versions: + en_US: "Commands to add, remove, or list versions or version signers" + de_DE: "Befehle zum Hinzufügen, Entfernen oder Auflisten von Versionen oder Versions-Unterzeichnern" + es_ES: "Comandos para agregar, eliminar o listar versiones o firmantes de versiones" + fr_FR: "Commandes pour ajouter, supprimer ou lister les versions ou signataires de versions" + pl_PL: "Polecenia dodania, usunięcia lub wyświetlenia wersji lub sygnatariuszy wersji" + +about.commands-add-sign-get-assets: + en_US: "Commands to add, sign, or get registry assets" + de_DE: "Befehle zum Hinzufügen, Signieren oder Abrufen von Registry-Assets" + es_ES: "Comandos para agregar, firmar u obtener activos del registro" + fr_FR: "Commandes pour ajouter, signer ou obtenir des actifs du registre" + pl_PL: "Polecenia dodania, podpisania lub pobrania zasobów rejestru" + +about.commands-authentication: + en_US: "Commands related to Authentication i.e. login, logout" + de_DE: "Befehle zur Authentifizierung, z.B. Anmelden, Abmelden" + es_ES: "Comandos relacionados con la autenticación, como iniciar sesión, cerrar sesión" + fr_FR: "Commandes liées à l'authentification, comme connexion, déconnexion" + pl_PL: "Polecenia związane z uwierzytelnianiem, np. logowanie, wylogowanie" + +about.commands-backup: + en_US: "Commands related to backup creation and backup targets" + de_DE: "Befehle zur Backup-Erstellung und Backup-Zielen" + es_ES: "Comandos relacionados con la creación de respaldos y destinos de respaldo" + fr_FR: "Commandes liées à la création de sauvegardes et aux cibles de sauvegarde" + pl_PL: "Polecenia związane z tworzeniem kopii zapasowych i celami kopii zapasowych" + +about.commands-backup-target: + en_US: "Commands related to a backup target" + de_DE: "Befehle zu einem Backup-Ziel" + es_ES: "Comandos relacionados con un destino de respaldo" + fr_FR: "Commandes liées à une cible de sauvegarde" + pl_PL: "Polecenia związane z celem kopii zapasowej" + +about.commands-db: + en_US: "Commands to interact with the db i.e. dump, put, apply" + de_DE: "Befehle zur Interaktion mit der Datenbank, z.B. dump, put, apply" + es_ES: "Comandos para interactuar con la base de datos, como dump, put, apply" + fr_FR: "Commandes pour interagir avec la base de données, comme dump, put, apply" + pl_PL: "Polecenia interakcji z bazą danych, np. dump, put, apply" + +about.commands-diagnostic: + en_US: "Commands to display logs, restart the server, etc" + de_DE: "Befehle zum Anzeigen von Logs, Neustart des Servers, usw." + es_ES: "Comandos para mostrar logs, reiniciar el servidor, etc." + fr_FR: "Commandes pour afficher les logs, redémarrer le serveur, etc." + pl_PL: "Polecenia wyświetlania logów, restartu serwera itp." + +about.commands-disk: + en_US: "Commands for listing disk info and repairing" + de_DE: "Befehle zum Auflisten von Festplatteninfo und Reparieren" + es_ES: "Comandos para listar información de disco y reparar" + fr_FR: "Commandes pour lister les informations de disque et réparer" + pl_PL: "Polecenia wyświetlania informacji o dysku i naprawy" + +about.commands-display-file-paths-contents-manifest: + en_US: "Commands to display file paths, file contents, or manifest" + de_DE: "Befehle zum Anzeigen von Dateipfaden, Dateiinhalten oder Manifest" + es_ES: "Comandos para mostrar rutas de archivos, contenidos de archivos o manifiesto" + fr_FR: "Commandes pour afficher les chemins de fichiers, contenus de fichiers ou manifeste" + pl_PL: "Polecenia wyświetlania ścieżek plików, zawartości plików lub manifestu" + +about.commands-download-assets: + en_US: "Commands to download image, iso, or squashfs files" + de_DE: "Befehle zum Herunterladen von Image-, ISO- oder Squashfs-Dateien" + es_ES: "Comandos para descargar archivos de imagen, iso o squashfs" + fr_FR: "Commandes pour télécharger des fichiers image, iso ou squashfs" + pl_PL: "Polecenia pobierania plików obrazu, iso lub squashfs" + +about.command-set-country: + en_US: "Command to set country" + de_DE: "Befehl zum Festlegen des Landes" + es_ES: "Comando para establecer el país" + fr_FR: "Commande pour définir le pays" + pl_PL: "Polecenie ustawienia kraju" + +about.commands-experimental: + en_US: "Commands related to configuring experimental options such as zram and cpu governor" + de_DE: "Befehle zur Konfiguration experimenteller Optionen wie zram und CPU-Governor" + es_ES: "Comandos relacionados con la configuración de opciones experimentales como zram y gobernador de CPU" + fr_FR: "Commandes liées à la configuration d'options expérimentales comme zram et le gouverneur CPU" + pl_PL: "Polecenia konfiguracji opcji eksperymentalnych jak zram i regulator CPU" + +about.commands-host-system-ui: + en_US: "Commands for modifying the host for the system ui" + de_DE: "Befehle zum Ändern des Hosts für die System-UI" + es_ES: "Comandos para modificar el host para la interfaz del sistema" + fr_FR: "Commandes pour modifier l'hôte pour l'interface système" + pl_PL: "Polecenia modyfikacji hosta dla interfejsu systemowego" + +about.commands-init: + en_US: "Commands for initialization" + de_DE: "Befehle zur Initialisierung" + es_ES: "Comandos de inicialización" + fr_FR: "Commandes d'initialisation" + pl_PL: "Polecenia inicjalizacji" + +about.commands-index-add-get-packages: + en_US: "Commands to index, add, or get packages" + de_DE: "Befehle zum Indizieren, Hinzufügen oder Abrufen von Paketen" + es_ES: "Comandos para indexar, agregar u obtener paquetes" + fr_FR: "Commandes pour indexer, ajouter ou obtenir des paquets" + pl_PL: "Polecenia indeksowania, dodawania lub pobierania pakietów" + +about.commands-interact-with-db-dump-apply: + en_US: "Commands to interact with the db i.e. dump and apply" + de_DE: "Befehle zur Interaktion mit der Datenbank, z.B. dump und apply" + es_ES: "Comandos para interactuar con la base de datos, como dump y apply" + fr_FR: "Commandes pour interagir avec la base de données, comme dump et apply" + pl_PL: "Polecenia interakcji z bazą danych, np. dump i apply" + +about.commands-kiosk: + en_US: "Commands for kiosk mode" + de_DE: "Befehle für den Kioskmodus" + es_ES: "Comandos para el modo kiosco" + fr_FR: "Commandes pour le mode kiosque" + pl_PL: "Polecenia trybu kiosku" + +about.commands-notifications: + en_US: "Create, delete, or list notifications" + de_DE: "Benachrichtigungen erstellen, löschen oder auflisten" + es_ES: "Crear, eliminar o listar notificaciones" + fr_FR: "Créer, supprimer ou lister les notifications" + pl_PL: "Twórz, usuwaj lub wyświetlaj powiadomienia" + +about.commands-os-assets-versions: + en_US: "Commands related to OS assets and versions" + de_DE: "Befehle zu Betriebssystem-Assets und Versionen" + es_ES: "Comandos relacionados con activos y versiones del SO" + fr_FR: "Commandes liées aux actifs et versions du système d'exploitation" + pl_PL: "Polecenia związane z zasobami i wersjami systemu operacyjnego" + +about.commands-packages: + en_US: "Commands related to packages" + de_DE: "Befehle zu Paketen" + es_ES: "Comandos relacionados con paquetes" + fr_FR: "Commandes liées aux paquets" + pl_PL: "Polecenia związane z pakietami" + +about.commands-registry: + en_US: "Commands related to the registry" + de_DE: "Befehle zum Registry" + es_ES: "Comandos relacionados con el registro" + fr_FR: "Commandes liées au registre" + pl_PL: "Polecenia związane z rejestrem" + +about.commands-registry-db: + en_US: "Commands to interact with the db i.e. dump and apply" + de_DE: "Befehle zur Interaktion mit der Datenbank, z.B. dump und apply" + es_ES: "Comandos para interactuar con la base de datos, como dump y apply" + fr_FR: "Commandes pour interagir avec la base de données, comme dump et apply" + pl_PL: "Polecenia interakcji z bazą danych, np. dump i apply" + +about.commands-restore-backup: + en_US: "Commands for restoring package(s) from backup" + de_DE: "Befehle zum Wiederherstellen von Paketen aus dem Backup" + es_ES: "Comandos para restaurar paquete(s) desde respaldo" + fr_FR: "Commandes pour restaurer des paquets depuis une sauvegarde" + pl_PL: "Polecenia przywracania pakietów z kopii zapasowej" + +about.commands-s9pk: + en_US: "Commands for interacting with s9pk files" + de_DE: "Befehle zur Interaktion mit s9pk-Dateien" + es_ES: "Comandos para interactuar con archivos s9pk" + fr_FR: "Commandes pour interagir avec les fichiers s9pk" + pl_PL: "Polecenia interakcji z plikami s9pk" + +about.commands-server: + en_US: "Commands related to the server i.e. restart, update, and shutdown" + de_DE: "Befehle zum Server, z.B. Neustart, Update und Herunterfahren" + es_ES: "Comandos relacionados con el servidor, como reiniciar, actualizar y apagar" + fr_FR: "Commandes liées au serveur, comme redémarrer, mettre à jour et arrêter" + pl_PL: "Polecenia związane z serwerem, np. restart, aktualizacja i zamknięcie" + +about.commands-setup: + en_US: "Commands related to the initial setup" + de_DE: "Befehle zur Ersteinrichtung" + es_ES: "Comandos relacionados con la configuración inicial" + fr_FR: "Commandes liées à la configuration initiale" + pl_PL: "Polecenia związane z początkową konfiguracją" + +about.commands-ssh-keys: + en_US: "Commands for interacting with ssh keys i.e. add, delete, list" + de_DE: "Befehle zur Interaktion mit SSH-Schlüsseln, z.B. hinzufügen, löschen, auflisten" + es_ES: "Comandos para interactuar con claves ssh, como agregar, eliminar, listar" + fr_FR: "Commandes pour interagir avec les clés ssh, comme ajouter, supprimer, lister" + pl_PL: "Polecenia interakcji z kluczami ssh, np. dodaj, usuń, wyświetl" + +about.commands-tunnel: + en_US: "Commands related to StartTunnel" + de_DE: "Befehle zu StartTunnel" + es_ES: "Comandos relacionados con StartTunnel" + fr_FR: "Commandes liées à StartTunnel" + pl_PL: "Polecenia związane z StartTunnel" + +about.commands-wifi: + en_US: "Commands related to wifi networks i.e. add, connect, delete" + de_DE: "Befehle zu WLAN-Netzwerken, z.B. hinzufügen, verbinden, löschen" + es_ES: "Comandos relacionados con redes wifi, como agregar, conectar, eliminar" + fr_FR: "Commandes liées aux réseaux wifi, comme ajouter, connecter, supprimer" + pl_PL: "Polecenia związane z sieciami wifi, np. dodaj, połącz, usuń" + +about.connect-wifi-network: + en_US: "Connect to a wifi network" + de_DE: "Mit einem WLAN-Netzwerk verbinden" + es_ES: "Conectar a una red wifi" + fr_FR: "Se connecter à un réseau wifi" + pl_PL: "Połącz z siecią wifi" + +about.convert-s9pk-v1-to-v2: + en_US: "Convert an s9pk from v1 to v2" + de_DE: "Konvertiere ein s9pk von v1 zu v2" + es_ES: "Convertir un s9pk de v1 a v2" + fr_FR: "Convertir un s9pk de v1 à v2" + pl_PL: "Konwertuj s9pk z v1 do v2" + +about.create-backup-all-packages: + en_US: "Create a backup for all packages" + de_DE: "Erstelle ein Backup für alle Pakete" + es_ES: "Crear una copia de seguridad de todos los paquetes" + fr_FR: "Créer une sauvegarde de tous les packages" + pl_PL: "Utwórz kopię zapasową wszystkich pakietów" + +about.create-developer-key: + en_US: "Create a new developer key" + de_DE: "Erstelle einen neuen Entwicklerschlüssel" + es_ES: "Crear una nueva clave de desarrollador" + fr_FR: "Créer une nouvelle clé de développeur" + pl_PL: "Utwórz nowy klucz deweloperski" + +about.disable-kiosk-mode: + en_US: "Disable kiosk mode" + de_DE: "Kioskmodus deaktivieren" + es_ES: "Desactivar el modo kiosco" + fr_FR: "Désactiver le mode kiosque" + pl_PL: "Wyłącz tryb kiosku" + +about.disable-webserver: + en_US: "Disable the webserver" + de_DE: "Webserver deaktivieren" + es_ES: "Desactivar el servidor web" + fr_FR: "Désactiver le serveur web" + pl_PL: "Wyłącz serwer internetowy" + +about.display-all-auth-sessions: + en_US: "Display all auth sessions" + de_DE: "Alle Authentifizierungssitzungen anzeigen" + es_ES: "Mostrar todas las sesiones de autenticación" + fr_FR: "Afficher toutes les sessions d'authentification" + pl_PL: "Wyświetl wszystkie sesje uwierzytelniania" + +about.display-current-api: + en_US: "Display the current api specification" + de_DE: "Aktuelle API-Spezifikation anzeigen" + es_ES: "Mostrar la especificación de la API actual" + fr_FR: "Afficher la spécification de l'API actuelle" + pl_PL: "Wyświetl bieżącą specyfikację API" + +about.display-diagnostic-error: + en_US: "Display diagnostic error" + de_DE: "Diagnosefehler anzeigen" + es_ES: "Mostrar error de diagnóstico" + fr_FR: "Afficher l'erreur de diagnostic" + pl_PL: "Wyświetl błąd diagnostyczny" + +about.display-file-contents: + en_US: "Display file contents from s9pk" + de_DE: "Dateiinhalte aus s9pk anzeigen" + es_ES: "Mostrar contenido del archivo desde s9pk" + fr_FR: "Afficher le contenu du fichier depuis s9pk" + pl_PL: "Wyświetl zawartość pliku z s9pk" + +about.display-githash: + en_US: "Display the git hash of this build" + de_DE: "Git-Hash dieses Builds anzeigen" + es_ES: "Mostrar el hash de git de esta compilación" + fr_FR: "Afficher le hash git de cette version" + pl_PL: "Wyświetl hash git tej kompilacji" + +about.display-installed-version: + en_US: "Display the installed version of a package" + de_DE: "Installierte Version eines Pakets anzeigen" + es_ES: "Mostrar la versión instalada de un paquete" + fr_FR: "Afficher la version installée d'un package" + pl_PL: "Wyświetl zainstalowaną wersję pakietu" + +about.display-kernel-logs: + en_US: "Display kernel logs" + de_DE: "Kernel-Logs anzeigen" + es_ES: "Mostrar registros del kernel" + fr_FR: "Afficher les journaux du noyau" + pl_PL: "Wyświetl logi jądra" + +about.display-list-of-paths: + en_US: "Display list of paths in s9pk" + de_DE: "Liste der Pfade in s9pk anzeigen" + es_ES: "Mostrar lista de rutas en s9pk" + fr_FR: "Afficher la liste des chemins dans s9pk" + pl_PL: "Wyświetl listę ścieżek w s9pk" + +about.display-os-logs: + en_US: "Display OS logs" + de_DE: "Betriebssystem-Logs anzeigen" + es_ES: "Mostrar registros del sistema operativo" + fr_FR: "Afficher les journaux du système d'exploitation" + pl_PL: "Wyświetl logi systemu operacyjnego" + +about.display-package-backup-information: + en_US: "Display backup information for a package" + de_DE: "Backup-Informationen für ein Paket anzeigen" + es_ES: "Mostrar información de copia de seguridad de un paquete" + fr_FR: "Afficher les informations de sauvegarde d'un package" + pl_PL: "Wyświetl informacje o kopii zapasowej pakietu" + +about.display-package-logs: + en_US: "Display logs for a package" + de_DE: "Logs für ein Paket anzeigen" + es_ES: "Mostrar registros de un paquete" + fr_FR: "Afficher les journaux d'un package" + pl_PL: "Wyświetl logi pakietu" + +about.display-registry-info: + en_US: "Display registry info" + de_DE: "Registry-Informationen anzeigen" + es_ES: "Mostrar información del registro" + fr_FR: "Afficher les informations du registre" + pl_PL: "Wyświetl informacje o rejestrze" + +about.display-s9pk-manifest: + en_US: "Display the s9pk manifest" + de_DE: "Das s9pk-Manifest anzeigen" + es_ES: "Mostrar el manifiesto s9pk" + fr_FR: "Afficher le manifeste s9pk" + pl_PL: "Wyświetl manifest s9pk" + +about.display-server-metrics: + en_US: "Display server metrics" + de_DE: "Server-Metriken anzeigen" + es_ES: "Mostrar métricas del servidor" + fr_FR: "Afficher les métriques du serveur" + pl_PL: "Wyświetl metryki serwera" + +about.display-time-uptime: + en_US: "Display server time and uptime" + de_DE: "Serverzeit und Betriebszeit anzeigen" + es_ES: "Mostrar tiempo del servidor y tiempo de actividad" + fr_FR: "Afficher l'heure et le temps de fonctionnement du serveur" + pl_PL: "Wyświetl czas serwera i czas działania" + +about.display-tor-logs: + en_US: "Display Tor logs" + de_DE: "Tor-Logs anzeigen" + es_ES: "Mostrar registros de Tor" + fr_FR: "Afficher les journaux Tor" + pl_PL: "Wyświetl logi Tora" + +about.display-tor-v3-onion-addresses: + en_US: "Display Tor v3 onion addresses" + de_DE: "Tor v3 Onion-Adressen anzeigen" + es_ES: "Mostrar direcciones onion Tor v3" + fr_FR: "Afficher les adresses onion Tor v3" + pl_PL: "Wyświetl adresy onion Tor v3" + +about.download-img: + en_US: "Download IMG file" + de_DE: "IMG-Datei herunterladen" + es_ES: "Descargar archivo IMG" + fr_FR: "Télécharger le fichier IMG" + pl_PL: "Pobierz plik IMG" + +about.download-iso: + en_US: "Download ISO file" + de_DE: "ISO-Datei herunterladen" + es_ES: "Descargar archivo ISO" + fr_FR: "Télécharger le fichier ISO" + pl_PL: "Pobierz plik ISO" + +about.download-s9pk: + en_US: "Download s9pk package" + de_DE: "s9pk-Paket herunterladen" + es_ES: "Descargar paquete s9pk" + fr_FR: "Télécharger le package s9pk" + pl_PL: "Pobierz pakiet s9pk" + +about.download-squashfs: + en_US: "Download squashfs file" + de_DE: "Squashfs-Datei herunterladen" + es_ES: "Descargar archivo squashfs" + fr_FR: "Télécharger le fichier squashfs" + pl_PL: "Pobierz plik squashfs" + +about.dump-address-resolution-table: + en_US: "Dump address resolution table" + de_DE: "Adressauflösungstabelle ausgeben" + es_ES: "Volcar tabla de resolución de direcciones" + fr_FR: "Exporter la table de résolution d'adresses" + pl_PL: "Zrzuć tabelę rozpoznawania adresów" + +about.echo-message: + en_US: "Echo a message back" + de_DE: "Eine Nachricht zurückgeben" + es_ES: "Devolver un mensaje" + fr_FR: "Renvoyer un message" + pl_PL: "Odzew wiadomości" + +about.edit-s9pk-manifest: + en_US: "Edit s9pk manifest" + de_DE: "s9pk-Manifest bearbeiten" + es_ES: "Editar manifiesto s9pk" + fr_FR: "Modifier le manifeste s9pk" + pl_PL: "Edytuj manifest s9pk" + +about.edit-signer: + en_US: "Edit signer information" + de_DE: "Unterzeichner-Informationen bearbeiten" + es_ES: "Editar información del firmante" + fr_FR: "Modifier les informations du signataire" + pl_PL: "Edytuj informacje o podpisującym" + +about.enable-disable-wifi: + en_US: "Enable or disable wifi" + de_DE: "WLAN aktivieren oder deaktivieren" + es_ES: "Activar o desactivar wifi" + fr_FR: "Activer ou désactiver le wifi" + pl_PL: "Włącz lub wyłącz wifi" + +about.enable-kiosk-mode: + en_US: "Enable kiosk mode" + de_DE: "Kioskmodus aktivieren" + es_ES: "Activar el modo kiosco" + fr_FR: "Activer le mode kiosque" + pl_PL: "Włącz tryb kiosku" + +about.enable-webserver: + en_US: "Enable the webserver" + de_DE: "Webserver aktivieren" + es_ES: "Activar el servidor web" + fr_FR: "Activer le serveur web" + pl_PL: "Włącz serwer internetowy" + +about.enable-zram: + en_US: "Enable ZRAM" + de_DE: "ZRAM aktivieren" + es_ES: "Activar ZRAM" + fr_FR: "Activer ZRAM" + pl_PL: "Włącz ZRAM" + +about.execute-commands-container: + en_US: "Execute commands in container" + de_DE: "Befehle im Container ausführen" + es_ES: "Ejecutar comandos en el contenedor" + fr_FR: "Exécuter des commandes dans le conteneur" + pl_PL: "Wykonaj polecenia w kontenerze" + +about.filter-query-db: + en_US: "Filter and query the database" + de_DE: "Datenbank filtern und abfragen" + es_ES: "Filtrar y consultar la base de datos" + fr_FR: "Filtrer et interroger la base de données" + pl_PL: "Filtruj i odpytuj bazę danych" + +about.filter-query-db-display-tables-records: + en_US: "Filter and query the database, display tables and records" + de_DE: "Datenbank filtern und abfragen, Tabellen und Einträge anzeigen" + es_ES: "Filtrar y consultar la base de datos, mostrar tablas y registros" + fr_FR: "Filtrer et interroger la base de données, afficher les tables et les enregistrements" + pl_PL: "Filtruj i odpytuj bazę danych, wyświetlaj tabele i rekordy" + +about.flash-startos: + en_US: "Flash StartOS to a drive" + de_DE: "StartOS auf ein Laufwerk flashen" + es_ES: "Flashear StartOS a una unidad" + fr_FR: "Flasher StartOS sur un lecteur" + pl_PL: "Flashuj StartOS na dysk" + +about.forget-disconnected-gateway: + en_US: "Forget a disconnected gateway" + de_DE: "Getrenntes Gateway vergessen" + es_ES: "Olvidar un gateway desconectado" + fr_FR: "Oublier une passerelle déconnectée" + pl_PL: "Zapomnij odłączoną bramę" + +about.generate-certificate-for-webserver: + en_US: "Generate a certificate for the webserver" + de_DE: "Zertifikat für den Webserver generieren" + es_ES: "Generar un certificado para el servidor web" + fr_FR: "Générer un certificat pour le serveur web" + pl_PL: "Wygeneruj certyfikat dla serwera internetowego" + +about.generate-onion-service-key-add-to-store: + en_US: "Generate an onion service key and add to store" + de_DE: "Onion-Service-Schlüssel generieren und zum Speicher hinzufügen" + es_ES: "Generar una clave de servicio onion y agregar al almacén" + fr_FR: "Générer une clé de service onion et ajouter au stockage" + pl_PL: "Wygeneruj klucz usługi onion i dodaj do magazynu" + +about.get-action-input-spec: + en_US: "Get action input specification" + de_DE: "Eingabespezifikation der Aktion abrufen" + es_ES: "Obtener especificación de entrada de la acción" + fr_FR: "Obtenir la spécification d'entrée de l'action" + pl_PL: "Pobierz specyfikację wejścia akcji" + +about.get-available-ip-addresses-to-bind: + en_US: "Get available IP addresses to bind" + de_DE: "Verfügbare IP-Adressen zum Binden abrufen" + es_ES: "Obtener direcciones IP disponibles para vincular" + fr_FR: "Obtenir les adresses IP disponibles à lier" + pl_PL: "Pobierz dostępne adresy IP do powiązania" + +about.get-certificate-for-webserver: + en_US: "Get the certificate for the webserver" + de_DE: "Zertifikat für den Webserver abrufen" + es_ES: "Obtener el certificado del servidor web" + fr_FR: "Obtenir le certificat du serveur web" + pl_PL: "Pobierz certyfikat dla serwera internetowego" + +about.get-developer-pubkey: + en_US: "Get the developer public key" + de_DE: "Öffentlichen Entwicklerschlüssel abrufen" + es_ES: "Obtener la clave pública del desarrollador" + fr_FR: "Obtenir la clé publique du développeur" + pl_PL: "Pobierz klucz publiczny dewelopera" + +about.get-initialization-progress: + en_US: "Get initialization progress" + de_DE: "Initialisierungsfortschritt abrufen" + es_ES: "Obtener progreso de inicialización" + fr_FR: "Obtenir la progression de l'initialisation" + pl_PL: "Pobierz postęp inicjalizacji" + +about.get-listen-address-for-webserver: + en_US: "Get the listen address for the webserver" + de_DE: "Adresse abrufen, auf der der Webserver lauscht" + es_ES: "Obtener la dirección de escucha del servidor web" + fr_FR: "Obtenir l'adresse d'écoute du serveur web" + pl_PL: "Pobierz adres nasłuchiwania serwera internetowego" + +about.get-os-versions-info: + en_US: "Get OS versions info" + de_DE: "Informationen zu Betriebssystemversionen abrufen" + es_ES: "Obtener información de versiones del sistema operativo" + fr_FR: "Obtenir les informations sur les versions du système d'exploitation" + pl_PL: "Pobierz informacje o wersjach systemu operacyjnego" + +about.get-pubkey-from-server: + en_US: "Get the public key from the server" + de_DE: "Öffentlichen Schlüssel vom Server abrufen" + es_ES: "Obtener la clave pública del servidor" + fr_FR: "Obtenir la clé publique du serveur" + pl_PL: "Pobierz klucz publiczny z serwera" + +about.import-certificate-for-webserver: + en_US: "Import a certificate for the webserver" + de_DE: "Zertifikat für den Webserver importieren" + es_ES: "Importar un certificado para el servidor web" + fr_FR: "Importer un certificat pour le serveur web" + pl_PL: "Importuj certyfikat dla serwera internetowego" + +about.indicate-gateway-inbound-access-from-wan: + en_US: "Indicate gateway inbound access from WAN" + de_DE: "Eingehenden Zugriff vom WAN zum Gateway anzeigen" + es_ES: "Indicar acceso entrante del gateway desde WAN" + fr_FR: "Indiquer l'accès entrant de la passerelle depuis le WAN" + pl_PL: "Wskaż dostęp przychodzący bramy z WAN" + +about.initialize-webserver: + en_US: "Initialize the webserver" + de_DE: "Webserver initialisieren" + es_ES: "Inicializar el servidor web" + fr_FR: "Initialiser le serveur web" + pl_PL: "Zainicjuj serwer internetowy" + +about.install-package: + en_US: "Install a package" + de_DE: "Ein Paket installieren" + es_ES: "Instalar un paquete" + fr_FR: "Installer un package" + pl_PL: "Zainstaluj pakiet" + +about.list-addresses-for-host: + en_US: "List addresses for a host" + de_DE: "Adressen für einen Host auflisten" + es_ES: "Listar direcciones de un host" + fr_FR: "Lister les adresses d'un hôte" + pl_PL: "Wyświetl adresy dla hosta" + +about.list-admin-signers: + en_US: "List admin signers" + de_DE: "Admin-Unterzeichner auflisten" + es_ES: "Listar firmantes de administrador" + fr_FR: "Lister les signataires administrateurs" + pl_PL: "Wyświetl sygnatariuszy administratorów" + +about.list-authorized-keys: + en_US: "List authorized keys" + de_DE: "Autorisierte Schlüssel auflisten" + es_ES: "Listar claves autorizadas" + fr_FR: "Lister les clés autorisées" + pl_PL: "Wyświetl autoryzowane klucze" + +about.list-available-wifi-networks: + en_US: "List available wifi networks" + de_DE: "Verfügbare WLAN-Netzwerke auflisten" + es_ES: "Listar redes wifi disponibles" + fr_FR: "Lister les réseaux wifi disponibles" + pl_PL: "Wyświetl dostępne sieci wifi" + +about.list-bindings-for-host: + en_US: "List bindings for a host" + de_DE: "Bindungen für einen Host auflisten" + es_ES: "Listar enlaces de un host" + fr_FR: "Lister les liaisons d'un hôte" + pl_PL: "Wyświetl powiązania dla hosta" + +about.list-devices-in-subnet: + en_US: "List devices in a subnet" + de_DE: "Geräte in einem Subnetz auflisten" + es_ES: "Listar dispositivos en una subred" + fr_FR: "Lister les appareils dans un sous-réseau" + pl_PL: "Wyświetl urządzenia w podsieci" + +about.list-disk-info: + en_US: "List disk information" + de_DE: "Festplatteninformationen auflisten" + es_ES: "Listar información del disco" + fr_FR: "Lister les informations du disque" + pl_PL: "Wyświetl informacje o dysku" + +about.list-existing-backup-targets: + en_US: "List existing backup targets" + de_DE: "Vorhandene Backup-Ziele auflisten" + es_ES: "Listar objetivos de copia de seguridad existentes" + fr_FR: "Lister les cibles de sauvegarde existantes" + pl_PL: "Wyświetl istniejące cele kopii zapasowych" + +about.list-host-ids-for-service: + en_US: "List host IDs for a service" + de_DE: "Host-IDs für einen Dienst auflisten" + es_ES: "Listar IDs de host para un servicio" + fr_FR: "Lister les IDs d'hôte pour un service" + pl_PL: "Wyświetl identyfikatory hostów dla usługi" + +about.list-installation-candidates: + en_US: "List installation candidates" + de_DE: "Installationskandidaten auflisten" + es_ES: "Listar candidatos de instalación" + fr_FR: "Lister les candidats à l'installation" + pl_PL: "Wyświetl kandydatów do instalacji" + +about.list-installed-packages: + en_US: "List installed packages" + de_DE: "Installierte Pakete auflisten" + es_ES: "Listar paquetes instalados" + fr_FR: "Lister les packages installés" + pl_PL: "Wyświetl zainstalowane pakiety" + +about.list-lxc-container-info: + en_US: "List LXC container information" + de_DE: "LXC-Container-Informationen auflisten" + es_ES: "Listar información del contenedor LXC" + fr_FR: "Lister les informations du conteneur LXC" + pl_PL: "Wyświetl informacje o kontenerze LXC" + +about.list-notifications: + en_US: "List notifications" + de_DE: "Benachrichtigungen auflisten" + es_ES: "Listar notificaciones" + fr_FR: "Lister les notifications" + pl_PL: "Wyświetl powiadomienia" + +about.list-onion-services-with-keys-in-store: + en_US: "List onion services with keys in store" + de_DE: "Onion-Dienste mit Schlüsseln im Speicher auflisten" + es_ES: "Listar servicios onion con claves en el almacén" + fr_FR: "Lister les services onion avec des clés en stockage" + pl_PL: "Wyświetl usługi onion z kluczami w magazynie" + +about.list-or-kill-auth-sessions: + en_US: "List or kill auth sessions" + de_DE: "Authentifizierungssitzungen auflisten oder beenden" + es_ES: "Listar o terminar sesiones de autenticación" + fr_FR: "Lister ou terminer les sessions d'authentification" + pl_PL: "Wyświetl lub zakończ sesje uwierzytelniania" + +about.list-os-versions-index: + en_US: "List OS versions index" + de_DE: "Index der Betriebssystemversionen auflisten" + es_ES: "Listar índice de versiones del sistema operativo" + fr_FR: "Lister l'index des versions du système d'exploitation" + pl_PL: "Wyświetl indeks wersji systemu operacyjnego" + +about.list-packages-categories: + en_US: "List packages and categories" + de_DE: "Pakete und Kategorien auflisten" + es_ES: "Listar paquetes y categorías" + fr_FR: "Lister les packages et catégories" + pl_PL: "Wyświetl pakiety i kategorie" + +about.list-package-signers: + en_US: "List package signers" + de_DE: "Paket-Unterzeichner auflisten" + es_ES: "Listar firmantes de paquetes" + fr_FR: "Lister les signataires de packages" + pl_PL: "Wyświetl sygnatariuszy pakietów" + +about.list-paths-of-package-ingredients: + en_US: "List paths of package ingredients" + de_DE: "Pfade der Paketbestandteile auflisten" + es_ES: "Listar rutas de los componentes del paquete" + fr_FR: "Lister les chemins des composants du package" + pl_PL: "Wyświetl ścieżki składników pakietu" + +about.list-registry-info-packages: + en_US: "List registry info and packages" + de_DE: "Registry-Informationen und Pakete auflisten" + es_ES: "Listar información del registro y paquetes" + fr_FR: "Lister les informations du registre et les packages" + pl_PL: "Wyświetl informacje o rejestrze i pakiety" + +about.list-signers: + en_US: "List signers" + de_DE: "Unterzeichner auflisten" + es_ES: "Listar firmantes" + fr_FR: "Lister les signataires" + pl_PL: "Wyświetl sygnatariuszy" + +about.list-ssh-keys: + en_US: "List SSH keys" + de_DE: "SSH-Schlüssel auflisten" + es_ES: "Listar claves SSH" + fr_FR: "Lister les clés SSH" + pl_PL: "Wyświetl klucze SSH" + +about.list-version-signers: + en_US: "List version signers" + de_DE: "Versions-Unterzeichner auflisten" + es_ES: "Listar firmantes de versiones" + fr_FR: "Lister les signataires de versions" + pl_PL: "Wyświetl sygnatariuszy wersji" + +about.list-wifi-info: + en_US: "List wifi information" + de_DE: "WLAN-Informationen auflisten" + es_ES: "Listar información wifi" + fr_FR: "Lister les informations wifi" + pl_PL: "Wyświetl informacje o wifi" + +about.login-new-auth-session: + en_US: "Login to a new auth session" + de_DE: "Bei einer neuen Authentifizierungssitzung anmelden" + es_ES: "Iniciar sesión en una nueva sesión de autenticación" + fr_FR: "Se connecter à une nouvelle session d'authentification" + pl_PL: "Zaloguj się do nowej sesji uwierzytelniania" + +about.logout-current-auth-session: + en_US: "Logout from current auth session" + de_DE: "Von aktueller Authentifizierungssitzung abmelden" + es_ES: "Cerrar sesión de la sesión de autenticación actual" + fr_FR: "Se déconnecter de la session d'authentification actuelle" + pl_PL: "Wyloguj się z bieżącej sesji uwierzytelniania" + +about.manage-network-hosts-package: + en_US: "Manage network hosts for a package" + de_DE: "Netzwerk-Hosts für ein Paket verwalten" + es_ES: "Gestionar hosts de red de un paquete" + fr_FR: "Gérer les hôtes réseau d'un package" + pl_PL: "Zarządzaj hostami sieciowymi dla pakietu" + +about.manage-onion-service-key-store: + en_US: "Manage onion service key store" + de_DE: "Onion-Service-Schlüsselspeicher verwalten" + es_ES: "Gestionar almacén de claves de servicio onion" + fr_FR: "Gérer le stockage des clés de service onion" + pl_PL: "Zarządzaj magazynem kluczy usługi onion" + +about.manage-port-forwards: + en_US: "Manage port forwards" + de_DE: "Portweiterleitungen verwalten" + es_ES: "Gestionar reenvíos de puertos" + fr_FR: "Gérer les redirections de ports" + pl_PL: "Zarządzaj przekierowaniami portów" + +about.manage-query-dns: + en_US: "Manage and query DNS" + de_DE: "DNS verwalten und abfragen" + es_ES: "Gestionar y consultar DNS" + fr_FR: "Gérer et interroger le DNS" + pl_PL: "Zarządzaj i odpytuj DNS" + +about.manage-ssl-vhost-proxy: + en_US: "Manage SSL vhost proxy" + de_DE: "SSL-vhost-Proxy verwalten" + es_ES: "Gestionar proxy SSL vhost" + fr_FR: "Gérer le proxy SSL vhost" + pl_PL: "Zarządzaj proxy SSL vhost" + +about.manage-tunnels: + en_US: "Manage tunnels" + de_DE: "Tunnel verwalten" + es_ES: "Gestionar túneles" + fr_FR: "Gérer les tunnels" + pl_PL: "Zarządzaj tunelami" + +about.mark-notifications-seen: + en_US: "Mark notifications as seen" + de_DE: "Benachrichtigungen als gelesen markieren" + es_ES: "Marcar notificaciones como vistas" + fr_FR: "Marquer les notifications comme vues" + pl_PL: "Oznacz powiadomienia jako przeczytane" + +about.mark-notifications-seen-before-id: + en_US: "Mark notifications as seen before a given ID" + de_DE: "Benachrichtigungen vor einer bestimmten ID als gelesen markieren" + es_ES: "Marcar notificaciones como vistas antes de un ID dado" + fr_FR: "Marquer les notifications comme vues avant un ID donné" + pl_PL: "Oznacz powiadomienia jako przeczytane przed danym ID" + +about.mark-notifications-unseen: + en_US: "Mark notifications as unseen" + de_DE: "Benachrichtigungen als ungelesen markieren" + es_ES: "Marcar notificaciones como no vistas" + fr_FR: "Marquer les notifications comme non vues" + pl_PL: "Oznacz powiadomienia jako nieprzeczytane" + +about.mount-backup-target: + en_US: "Mount a backup target" + de_DE: "Ein Backup-Ziel einbinden" + es_ES: "Montar un objetivo de copia de seguridad" + fr_FR: "Monter une cible de sauvegarde" + pl_PL: "Zamontuj cel kopii zapasowej" + +about.network-commands: + en_US: "Network commands" + de_DE: "Netzwerkbefehle" + es_ES: "Comandos de red" + fr_FR: "Commandes réseau" + pl_PL: "Polecenia sieciowe" + +about.package-s9pk-input-files-into-valid-s9pk: + en_US: "Package input files into a valid s9pk" + de_DE: "Eingabedateien in ein gültiges s9pk verpacken" + es_ES: "Empaquetar archivos de entrada en un s9pk válido" + fr_FR: "Emballer les fichiers d'entrée dans un s9pk valide" + pl_PL: "Spakuj pliki wejściowe do prawidłowego s9pk" + +about.persist-new-notification: + en_US: "Persist a new notification" + de_DE: "Neue Benachrichtigung speichern" + es_ES: "Persistir una nueva notificación" + fr_FR: "Persister une nouvelle notification" + pl_PL: "Utrwal nowe powiadomienie" + +about.rebuild-service-container: + en_US: "Rebuild service container" + de_DE: "Dienst-Container neu erstellen" + es_ES: "Reconstruir contenedor de servicio" + fr_FR: "Reconstruire le conteneur du service" + pl_PL: "Przebuduj kontener usługi" + +about.remove-acme-certificate-acquisition-configuration: + en_US: "Remove ACME certificate acquisition configuration" + de_DE: "ACME-Zertifikatsbeschaffungskonfiguration entfernen" + es_ES: "Eliminar configuración de adquisición de certificado ACME" + fr_FR: "Supprimer la configuration d'acquisition de certificat ACME" + pl_PL: "Usuń konfigurację pozyskiwania certyfikatu ACME" + +about.remove-address-from-host: + en_US: "Remove address from host" + de_DE: "Adresse vom Host entfernen" + es_ES: "Eliminar dirección del host" + fr_FR: "Supprimer l'adresse de l'hôte" + pl_PL: "Usuń adres z hosta" + +about.remove-admin-signer: + en_US: "Remove admin signer" + de_DE: "Admin-Unterzeichner entfernen" + es_ES: "Eliminar firmante de administrador" + fr_FR: "Supprimer le signataire administrateur" + pl_PL: "Usuń sygnatariusza administratora" + +about.remove-authorized-key: + en_US: "Remove authorized key" + de_DE: "Autorisierten Schlüssel entfernen" + es_ES: "Eliminar clave autorizada" + fr_FR: "Supprimer la clé autorisée" + pl_PL: "Usuń autoryzowany klucz" + +about.remove-category-registry: + en_US: "Remove category from registry" + de_DE: "Kategorie aus der Registry entfernen" + es_ES: "Eliminar categoría del registro" + fr_FR: "Supprimer la catégorie du registre" + pl_PL: "Usuń kategorię z rejestru" + +about.remove-device-from-subnet: + en_US: "Remove device from subnet" + de_DE: "Gerät aus dem Subnetz entfernen" + es_ES: "Eliminar dispositivo de la subred" + fr_FR: "Supprimer l'appareil du sous-réseau" + pl_PL: "Usuń urządzenie z podsieci" + +about.remove-disk-filesystem: + en_US: "Remove disk filesystem" + de_DE: "Festplatten-Dateisystem entfernen" + es_ES: "Eliminar sistema de archivos del disco" + fr_FR: "Supprimer le système de fichiers du disque" + pl_PL: "Usuń system plików dysku" + +about.remove-existing-backup-target: + en_US: "Remove existing backup target" + de_DE: "Vorhandenes Backup-Ziel entfernen" + es_ES: "Eliminar objetivo de copia de seguridad existente" + fr_FR: "Supprimer la cible de sauvegarde existante" + pl_PL: "Usuń istniejący cel kopii zapasowej" + +about.remove-mirror-package: + en_US: "Remove mirror package" + de_DE: "Mirror-Paket entfernen" + es_ES: "Eliminar paquete espejo" + fr_FR: "Supprimer le package miroir" + pl_PL: "Usuń pakiet lustrzany" + +about.remove-notification-for-ids: + en_US: "Remove notification for IDs" + de_DE: "Benachrichtigung für IDs entfernen" + es_ES: "Eliminar notificación por IDs" + fr_FR: "Supprimer la notification par IDs" + pl_PL: "Usuń powiadomienie dla identyfikatorów" + +about.remove-notifications-before-id: + en_US: "Remove notifications before a given ID" + de_DE: "Benachrichtigungen vor einer bestimmten ID entfernen" + es_ES: "Eliminar notificaciones antes de un ID dado" + fr_FR: "Supprimer les notifications avant un ID donné" + pl_PL: "Usuń powiadomienia przed danym ID" + +about.remove-os-version: + en_US: "Remove OS version" + de_DE: "Betriebssystemversion entfernen" + es_ES: "Eliminar versión del sistema operativo" + fr_FR: "Supprimer la version du système d'exploitation" + pl_PL: "Usuń wersję systemu operacyjnego" + +about.remove-package: + en_US: "Remove a package" + de_DE: "Ein Paket entfernen" + es_ES: "Eliminar un paquete" + fr_FR: "Supprimer un package" + pl_PL: "Usuń pakiet" + +about.remove-package-category: + en_US: "Remove package from category" + de_DE: "Paket aus der Kategorie entfernen" + es_ES: "Eliminar paquete de la categoría" + fr_FR: "Supprimer le package de la catégorie" + pl_PL: "Usuń pakiet z kategorii" + +about.remove-package-registry: + en_US: "Remove package from registry" + de_DE: "Paket aus der Registry entfernen" + es_ES: "Eliminar paquete del registro" + fr_FR: "Supprimer le package du registre" + pl_PL: "Usuń pakiet z rejestru" + +about.remove-package-signer: + en_US: "Remove package signer" + de_DE: "Paket-Unterzeichner entfernen" + es_ES: "Eliminar firmante de paquete" + fr_FR: "Supprimer le signataire du package" + pl_PL: "Usuń sygnatariusza pakietu" + +about.remove-port-forward: + en_US: "Remove port forward" + de_DE: "Portweiterleitung entfernen" + es_ES: "Eliminar reenvío de puerto" + fr_FR: "Supprimer la redirection de port" + pl_PL: "Usuń przekierowanie portu" + +about.remove-private-domain-from-host: + en_US: "Remove private domain from host" + de_DE: "Private Domain vom Host entfernen" + es_ES: "Eliminar dominio privado del host" + fr_FR: "Supprimer le domaine privé de l'hôte" + pl_PL: "Usuń prywatną domenę z hosta" + +about.remove-public-domain-from-host: + en_US: "Remove public domain from host" + de_DE: "Öffentliche Domain vom Host entfernen" + es_ES: "Eliminar dominio público del host" + fr_FR: "Supprimer le domaine public de l'hôte" + pl_PL: "Usuń publiczną domenę z hosta" + +about.remove-ssh-key: + en_US: "Remove an SSH key" + de_DE: "Einen SSH-Schlüssel entfernen" + es_ES: "Eliminar una clave SSH" + fr_FR: "Supprimer une clé SSH" + pl_PL: "Usuń klucz SSH" + +about.remove-subnet: + en_US: "Remove a subnet" + de_DE: "Ein Subnetz entfernen" + es_ES: "Eliminar una subred" + fr_FR: "Supprimer un sous-réseau" + pl_PL: "Usuń podsieć" + +about.remove-tunnel: + en_US: "Remove a tunnel" + de_DE: "Einen Tunnel entfernen" + es_ES: "Eliminar un túnel" + fr_FR: "Supprimer un tunnel" + pl_PL: "Usuń tunel" + +about.remove-version-signer: + en_US: "Remove version signer" + de_DE: "Versions-Unterzeichner entfernen" + es_ES: "Eliminar firmante de versión" + fr_FR: "Supprimer le signataire de version" + pl_PL: "Usuń sygnatariusza wersji" + +about.remove-wifi-network: + en_US: "Remove a wifi network" + de_DE: "Ein WLAN-Netzwerk entfernen" + es_ES: "Eliminar una red wifi" + fr_FR: "Supprimer un réseau wifi" + pl_PL: "Usuń sieć wifi" + +about.rename-gateway: + en_US: "Rename a gateway" + de_DE: "Ein Gateway umbenennen" + es_ES: "Renombrar un gateway" + fr_FR: "Renommer une passerelle" + pl_PL: "Zmień nazwę bramy" + +about.repair-disk-corruption: + en_US: "Repair disk corruption" + de_DE: "Festplattenfehler reparieren" + es_ES: "Reparar corrupción del disco" + fr_FR: "Réparer la corruption du disque" + pl_PL: "Napraw uszkodzenie dysku" + +about.reset-password: + en_US: "Reset the password" + de_DE: "Das Passwort zurücksetzen" + es_ES: "Restablecer la contraseña" + fr_FR: "Réinitialiser le mot de passe" + pl_PL: "Zresetuj hasło" + +about.reset-tor-daemon: + en_US: "Reset Tor daemon" + de_DE: "Tor-Daemon zurücksetzen" + es_ES: "Restablecer demonio Tor" + fr_FR: "Réinitialiser le démon Tor" + pl_PL: "Zresetuj demona Tora" + +about.reset-user-interface-password: + en_US: "Reset user interface password" + de_DE: "Passwort der Benutzeroberfläche zurücksetzen" + es_ES: "Restablecer contraseña de la interfaz de usuario" + fr_FR: "Réinitialiser le mot de passe de l'interface utilisateur" + pl_PL: "Zresetuj hasło interfejsu użytkownika" + +about.reset-webserver: + en_US: "Reset the webserver" + de_DE: "Den Webserver zurücksetzen" + es_ES: "Restablecer el servidor web" + fr_FR: "Réinitialiser le serveur web" + pl_PL: "Zresetuj serwer internetowy" + +about.restart-server: + en_US: "Restart the server" + de_DE: "Den Server neu starten" + es_ES: "Reiniciar el servidor" + fr_FR: "Redémarrer le serveur" + pl_PL: "Uruchom ponownie serwer" + +about.restart-service: + en_US: "Restart a service" + de_DE: "Einen Dienst neu starten" + es_ES: "Reiniciar un servicio" + fr_FR: "Redémarrer un service" + pl_PL: "Uruchom ponownie usługę" + +about.restore-packages-from-backup: + en_US: "Restore packages from backup" + de_DE: "Pakete aus Backup wiederherstellen" + es_ES: "Restaurar paquetes desde copia de seguridad" + fr_FR: "Restaurer les packages depuis la sauvegarde" + pl_PL: "Przywróć pakiety z kopii zapasowej" + +about.run-service-action: + en_US: "Run a service action" + de_DE: "Eine Dienstaktion ausführen" + es_ES: "Ejecutar una acción de servicio" + fr_FR: "Exécuter une action de service" + pl_PL: "Uruchom akcję usługi" + +about.set-country: + en_US: "Set the country" + de_DE: "Das Land festlegen" + es_ES: "Establecer el país" + fr_FR: "Définir le pays" + pl_PL: "Ustaw kraj" + +about.set-gateway-enabled-for-binding: + en_US: "Set gateway enabled for binding" + de_DE: "Gateway für Bindung aktivieren" + es_ES: "Establecer gateway habilitado para enlace" + fr_FR: "Définir la passerelle activée pour la liaison" + pl_PL: "Ustaw bramę jako włączoną dla powiązania" + +about.set-keyboard: + en_US: "Set the keyboard layout" + de_DE: "Das Tastaturlayout festlegen" + es_ES: "Establecer la distribución del teclado" + fr_FR: "Définir la disposition du clavier" + pl_PL: "Ustaw układ klawiatury" + +about.set-language: + en_US: "Set the system language" + de_DE: "Die Systemsprache festlegen" + es_ES: "Establecer el idioma del sistema" + fr_FR: "Définir la langue du système" + pl_PL: "Ustaw język systemu" + +about.set-listen-address-for-webserver: + en_US: "Set the listen address for the webserver" + de_DE: "Adresse festlegen, auf der der Webserver lauscht" + es_ES: "Establecer la dirección de escucha del servidor web" + fr_FR: "Définir l'adresse d'écoute du serveur web" + pl_PL: "Ustaw adres nasłuchiwania serwera internetowego" + +about.set-registry-icon: + en_US: "Set the registry icon" + de_DE: "Das Registry-Symbol festlegen" + es_ES: "Establecer el icono del registro" + fr_FR: "Définir l'icône du registre" + pl_PL: "Ustaw ikonę rejestru" + +about.set-registry-name: + en_US: "Set the registry name" + de_DE: "Den Registry-Namen festlegen" + es_ES: "Establecer el nombre del registro" + fr_FR: "Définir le nom du registre" + pl_PL: "Ustaw nazwę rejestru" + +about.set-smtp: + en_US: "Set SMTP configuration" + de_DE: "SMTP-Konfiguration festlegen" + es_ES: "Establecer configuración SMTP" + fr_FR: "Définir la configuration SMTP" + pl_PL: "Ustaw konfigurację SMTP" + +about.set-static-dns-servers: + en_US: "Set static DNS servers" + de_DE: "Statische DNS-Server festlegen" + es_ES: "Establecer servidores DNS estáticos" + fr_FR: "Définir les serveurs DNS statiques" + pl_PL: "Ustaw statyczne serwery DNS" + +about.set-user-interface-password: + en_US: "Set user interface password" + de_DE: "Passwort der Benutzeroberfläche festlegen" + es_ES: "Establecer contraseña de la interfaz de usuario" + fr_FR: "Définir le mot de passe de l'interface utilisateur" + pl_PL: "Ustaw hasło interfejsu użytkownika" + +about.setup-acme-certificate: + en_US: "Setup ACME certificate" + de_DE: "ACME-Zertifikat einrichten" + es_ES: "Configurar certificado ACME" + fr_FR: "Configurer le certificat ACME" + pl_PL: "Skonfiguruj certyfikat ACME" + +about.setup-acme-certificate-acquisition: + en_US: "Setup ACME certificate acquisition" + de_DE: "ACME-Zertifikatsbeschaffung einrichten" + es_ES: "Configurar adquisición de certificado ACME" + fr_FR: "Configurer l'acquisition de certificat ACME" + pl_PL: "Skonfiguruj pozyskiwanie certyfikatu ACME" + +about.show-cpu-governors: + en_US: "Show CPU governors" + de_DE: "CPU-Governors anzeigen" + es_ES: "Mostrar gobernadores de CPU" + fr_FR: "Afficher les gouverneurs CPU" + pl_PL: "Pokaż zarządców CPU" + +about.show-gateways-startos-can-listen-on: + en_US: "Show gateways StartOS can listen on" + de_DE: "Gateways anzeigen, auf denen StartOS lauschen kann" + es_ES: "Mostrar gateways en los que StartOS puede escuchar" + fr_FR: "Afficher les passerelles sur lesquelles StartOS peut écouter" + pl_PL: "Pokaż bramy, na których StartOS może nasłuchiwać" + +about.show-wireguard-configuration-for-device: + en_US: "Show WireGuard configuration for device" + de_DE: "WireGuard-Konfiguration für Gerät anzeigen" + es_ES: "Mostrar configuración de WireGuard para el dispositivo" + fr_FR: "Afficher la configuration WireGuard pour l'appareil" + pl_PL: "Pokaż konfigurację WireGuard dla urządzenia" + +about.shutdown-server: + en_US: "Shutdown the server" + de_DE: "Den Server herunterfahren" + es_ES: "Apagar el servidor" + fr_FR: "Arrêter le serveur" + pl_PL: "Wyłącz serwer" + +about.sign-file-add-registry: + en_US: "Sign file and add to registry" + de_DE: "Datei signieren und zur Registry hinzufügen" + es_ES: "Firmar archivo y agregar al registro" + fr_FR: "Signer le fichier et ajouter au registre" + pl_PL: "Podpisz plik i dodaj do rejestru" + +about.start-service: + en_US: "Start a service" + de_DE: "Einen Dienst starten" + es_ES: "Iniciar un servicio" + fr_FR: "Démarrer un service" + pl_PL: "Uruchom usługę" + +about.stop-service: + en_US: "Stop a service" + de_DE: "Einen Dienst stoppen" + es_ES: "Detener un servicio" + fr_FR: "Arrêter un service" + pl_PL: "Zatrzymaj usługę" + +about.teardown-rebuild-containers: + en_US: "Teardown and rebuild containers" + de_DE: "Container abbauen und neu erstellen" + es_ES: "Desmontar y reconstruir contenedores" + fr_FR: "Démonter et reconstruire les conteneurs" + pl_PL: "Rozmontuj i przebuduj kontenery" + +about.terminate-auth-sessions: + en_US: "Terminate auth sessions" + de_DE: "Authentifizierungssitzungen beenden" + es_ES: "Terminar sesiones de autenticación" + fr_FR: "Terminer les sessions d'authentification" + pl_PL: "Zakończ sesje uwierzytelniania" + +about.test-dns-configuration-for-domain: + en_US: "Test DNS configuration for a domain" + de_DE: "DNS-Konfiguration für eine Domain testen" + es_ES: "Probar configuración DNS para un dominio" + fr_FR: "Tester la configuration DNS pour un domaine" + pl_PL: "Przetestuj konfigurację DNS dla domeny" + +about.test-smtp: + en_US: "Test SMTP configuration" + de_DE: "SMTP-Konfiguration testen" + es_ES: "Probar configuración SMTP" + fr_FR: "Tester la configuration SMTP" + pl_PL: "Przetestuj konfigurację SMTP" + +about.tor-commands: + en_US: "Tor commands" + de_DE: "Tor-Befehle" + es_ES: "Comandos de Tor" + fr_FR: "Commandes Tor" + pl_PL: "Polecenia Tora" + +about.unmount-backup-target: + en_US: "Unmount a backup target" + de_DE: "Ein Backup-Ziel aushängen" + es_ES: "Desmontar un objetivo de copia de seguridad" + fr_FR: "Démonter une cible de sauvegarde" + pl_PL: "Odmontuj cel kopii zapasowej" + +about.update-categories-registry: + en_US: "Update categories in registry" + de_DE: "Kategorien in der Registry aktualisieren" + es_ES: "Actualizar categorías en el registro" + fr_FR: "Mettre à jour les catégories dans le registre" + pl_PL: "Zaktualizuj kategorie w rejestrze" + +about.update-db-record: + en_US: "Update a database record" + de_DE: "Einen Datenbankeintrag aktualisieren" + es_ES: "Actualizar un registro de la base de datos" + fr_FR: "Mettre à jour un enregistrement de la base de données" + pl_PL: "Zaktualizuj rekord bazy danych" + +about.update-existing-backup-target: + en_US: "Update an existing backup target" + de_DE: "Ein vorhandenes Backup-Ziel aktualisieren" + es_ES: "Actualizar un objetivo de copia de seguridad existente" + fr_FR: "Mettre à jour une cible de sauvegarde existante" + pl_PL: "Zaktualizuj istniejący cel kopii zapasowej" + +about.update-firmware: + en_US: "Update firmware" + de_DE: "Firmware aktualisieren" + es_ES: "Actualizar firmware" + fr_FR: "Mettre à jour le firmware" + pl_PL: "Zaktualizuj oprogramowanie układowe" + +about.view-edit-gateway-configs: + en_US: "View and edit gateway configurations" + de_DE: "Gateway-Konfigurationen anzeigen und bearbeiten" + es_ES: "Ver y editar configuraciones de gateway" + fr_FR: "Voir et modifier les configurations de passerelle" + pl_PL: "Wyświetl i edytuj konfiguracje bramy" diff --git a/core/src/action.rs b/core/src/action.rs index 50e610e7c..1bd1986a2 100644 --- a/core/src/action.rs +++ b/core/src/action.rs @@ -23,7 +23,7 @@ pub fn action_api() -> ParentHandler { "get-input", from_fn_async(get_action_input) .with_display_serializable() - .with_about("Get action input spec") + .with_about("about.get-action-input-spec") .with_call_remote::(), ) .subcommand( @@ -36,14 +36,14 @@ pub fn action_api() -> ParentHandler { } Ok(()) }) - .with_about("Run service action") + .with_about("about.run-service-action") .with_call_remote::(), ) .subcommand( "clear-task", from_fn_async(clear_task) .no_display() - .with_about("Clear a service task") + .with_about("about.clear-service-task") .with_call_remote::(), ) } @@ -63,7 +63,9 @@ pub struct ActionInput { #[derive(Deserialize, Serialize, TS, Parser)] #[serde(rename_all = "camelCase")] pub struct GetActionInputParams { + #[arg(help = "help.arg.package-id")] pub package_id: PackageId, + #[arg(help = "help.arg.action-id")] pub action_id: ActionId, } @@ -280,8 +282,11 @@ pub struct RunActionParams { #[derive(Parser)] struct CliRunActionParams { + #[arg(help = "help.arg.package-id")] pub package_id: PackageId, + #[arg(help = "help.arg.event-id")] pub event_id: Option, + #[arg(help = "help.arg.action-id")] pub action_id: ActionId, #[command(flatten)] pub input: StdinDeserializable>, @@ -360,9 +365,11 @@ pub async fn run_action( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ClearTaskParams { + #[arg(help = "help.arg.package-id")] pub package_id: PackageId, + #[arg(help = "help.arg.replay-id")] pub replay_id: ReplayId, - #[arg(long)] + #[arg(long, help = "help.arg.force-clear-task")] #[serde(default)] pub force: bool, } diff --git a/core/src/auth.rs b/core/src/auth.rs index 0f590bbe1..f145149a8 100644 --- a/core/src/auth.rs +++ b/core/src/auth.rs @@ -51,7 +51,10 @@ pub async fn write_shadow(password: &str) -> Result<(), Error> { match line.split_once(":") { Some((user, rest)) if user == "start9" || user == "kiosk" => { let (_, rest) = rest.split_once(":").ok_or_else(|| { - Error::new(eyre!("malformed /etc/shadow"), ErrorKind::ParseSysInfo) + Error::new( + eyre!("{}", t!("auth.malformed-etc-shadow")), + ErrorKind::ParseSysInfo, + ) })?; shadow_file .write_all(format!("{user}:{hash}:{rest}\n").as_bytes()) @@ -81,7 +84,7 @@ impl PasswordType { PasswordType::String(x) => Ok(x), PasswordType::EncryptedWire(x) => x.decrypt(current_secret).ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("Couldn't decode password"), + color_eyre::eyre::eyre!("{}", t!("auth.couldnt-decode-password")), crate::ErrorKind::Unknown, ) }), @@ -125,19 +128,19 @@ where "login", from_fn_async(cli_login::) .no_display() - .with_about("Log in a new auth session"), + .with_about("about.login-new-auth-session"), ) .subcommand( "logout", from_fn_async(logout::) .with_metadata("get_session", Value::Bool(true)) .no_display() - .with_about("Log out of current auth session") + .with_about("about.logout-current-auth-session") .with_call_remote::(), ) .subcommand( "session", - session::().with_about("List or kill auth sessions"), + session::().with_about("about.list-or-kill-auth-sessions"), ) .subcommand( "reset-password", @@ -147,14 +150,14 @@ where "reset-password", from_fn_async(cli_reset_password) .no_display() - .with_about("Reset password"), + .with_about("about.reset-password"), ) .subcommand( "get-pubkey", from_fn_async(get_pubkey) .with_metadata("authenticated", Value::Bool(false)) .no_display() - .with_about("Get public key derived from server private key") + .with_about("about.get-pubkey-from-server") .with_call_remote::(), ) } @@ -208,12 +211,12 @@ pub fn check_password(hash: &str, password: &str) -> Result<(), Error> { ensure_code!( argon2::verify_encoded(&hash, password.as_bytes()).map_err(|_| { Error::new( - eyre!("Password Incorrect"), + eyre!("{}", t!("auth.password-incorrect")), crate::ErrorKind::IncorrectPassword, ) })?, crate::ErrorKind::IncorrectPassword, - "Password Incorrect" + t!("auth.password-incorrect") ); Ok(()) } @@ -327,14 +330,14 @@ where .with_metadata("get_session", Value::Bool(true)) .with_display_serializable() .with_custom_display_fn(|handle, result| display_sessions(handle.params, result)) - .with_about("Display all auth sessions") + .with_about("about.display-all-auth-sessions") .with_call_remote::(), ) .subcommand( "kill", from_fn_async(kill::) .no_display() - .with_about("Terminate existing auth session(s)") + .with_about("about.terminate-auth-sessions") .with_call_remote::(), ) } @@ -418,6 +421,7 @@ impl AsLogoutSessionId for KillSessionId { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct KillParams { + #[arg(help = "help.arg.session-ids")] ids: Vec, } @@ -434,7 +438,9 @@ pub async fn kill( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ResetPasswordParams { + #[arg(help = "help.arg.old-password")] old_password: Option, + #[arg(help = "help.arg.new-password")] new_password: Option, } @@ -447,13 +453,13 @@ async fn cli_reset_password( .. }: HandlerArgs, ) -> Result<(), RpcError> { - let old_password = rpassword::prompt_password("Current Password: ")?; + let old_password = rpassword::prompt_password(&t!("auth.prompt-current-password"))?; let new_password = { - let new_password = rpassword::prompt_password("New Password: ")?; - if new_password != rpassword::prompt_password("Confirm: ")? { + let new_password = rpassword::prompt_password(&t!("auth.prompt-new-password"))?; + if new_password != rpassword::prompt_password(&t!("auth.prompt-confirm"))? { return Err(Error::new( - eyre!("Passwords do not match"), + eyre!("{}", t!("auth.passwords-do-not-match")), crate::ErrorKind::IncorrectPassword, ) .into()); @@ -486,7 +492,7 @@ pub async fn reset_password_impl( .with_kind(crate::ErrorKind::IncorrectPassword)? { return Err(Error::new( - eyre!("Incorrect Password"), + eyre!("{}", t!("auth.password-incorrect")), crate::ErrorKind::IncorrectPassword, )); } diff --git a/core/src/backup/backup_bulk.rs b/core/src/backup/backup_bulk.rs index 0de39f34c..6a2d10dfd 100644 --- a/core/src/backup/backup_bulk.rs +++ b/core/src/backup/backup_bulk.rs @@ -33,11 +33,13 @@ use crate::version::VersionT; #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct BackupParams { + #[arg(help = "help.arg.backup-target-id")] target_id: BackupTargetId, - #[arg(long = "old-password")] + #[arg(long = "old-password", help = "help.arg.old-backup-password")] old_password: Option, - #[arg(long = "package-ids")] + #[arg(long = "package-ids", help = "help.arg.package-ids-to-backup")] package_ids: Option>, + #[arg(help = "help.arg.backup-password")] password: crate::auth::PasswordType, } @@ -69,8 +71,8 @@ impl BackupStatusGuard { db, None, NotificationLevel::Success, - "Backup Complete".to_owned(), - "Your backup has completed".to_owned(), + t!("backup.bulk.complete-title").to_string(), + t!("backup.bulk.complete-message").to_string(), BackupReport { server: ServerBackupReport { attempted: true, @@ -88,9 +90,8 @@ impl BackupStatusGuard { db, None, NotificationLevel::Warning, - "Backup Complete".to_owned(), - "Your backup has completed, but some package(s) failed to backup" - .to_owned(), + t!("backup.bulk.complete-title").to_string(), + t!("backup.bulk.complete-with-failures").to_string(), BackupReport { server: ServerBackupReport { attempted: true, @@ -103,7 +104,7 @@ impl BackupStatusGuard { .await } Err(e) => { - tracing::error!("Backup Failed: {}", e); + tracing::error!("{}", t!("backup.bulk.failed-error", error = e)); tracing::debug!("{:?}", e); let err_string = e.to_string(); db.mutate(|db| { @@ -111,8 +112,8 @@ impl BackupStatusGuard { db, None, NotificationLevel::Error, - "Backup Failed".to_owned(), - "Your backup failed to complete.".to_owned(), + t!("backup.bulk.failed-title").to_string(), + t!("backup.bulk.failed-message").to_string(), BackupReport { server: ServerBackupReport { attempted: true, @@ -224,7 +225,7 @@ fn assure_backing_up<'a>( .as_backup_progress_mut(); if backing_up.transpose_ref().is_some() { return Err(Error::new( - eyre!("Server is already backing up!"), + eyre!("{}", t!("backup.bulk.already-backing-up")), ErrorKind::InvalidRequest, )); } @@ -303,7 +304,7 @@ async fn perform_backup( let mut backup_guard = Arc::try_unwrap(backup_guard).map_err(|_| { Error::new( - eyre!("leaked reference to BackupMountGuard"), + eyre!("{}", t!("backup.bulk.leaked-reference")), ErrorKind::Incoherent, ) })?; diff --git a/core/src/backup/mod.rs b/core/src/backup/mod.rs index a6e98b2b4..3e231afe2 100644 --- a/core/src/backup/mod.rs +++ b/core/src/backup/mod.rs @@ -37,12 +37,12 @@ pub fn backup() -> ParentHandler { "create", from_fn_async(backup_bulk::backup_all) .no_display() - .with_about("Create backup for all packages") + .with_about("about.create-backup-all-packages") .with_call_remote::(), ) .subcommand( "target", - target::target::().with_about("Commands related to a backup target"), + target::target::().with_about("about.commands-backup-target"), ) } @@ -51,7 +51,7 @@ pub fn package_backup() -> ParentHandler { "restore", from_fn_async(restore::restore_packages_rpc) .no_display() - .with_about("Restore package(s) from backup") + .with_about("about.restore-packages-from-backup") .with_call_remote::(), ) } diff --git a/core/src/backup/restore.rs b/core/src/backup/restore.rs index daddd2248..6f5d78eac 100644 --- a/core/src/backup/restore.rs +++ b/core/src/backup/restore.rs @@ -23,16 +23,19 @@ use crate::progress::ProgressUnits; use crate::s9pk::S9pk; use crate::service::service_map::DownloadInstallFuture; use crate::setup::SetupExecuteProgress; -use crate::system::sync_kiosk; -use crate::util::serde::IoFormat; +use crate::system::{save_language, sync_kiosk}; +use crate::util::serde::{IoFormat, Pem}; use crate::{PLATFORM, PackageId}; #[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct RestorePackageParams { + #[arg(help = "help.arg.package-ids")] pub ids: Vec, + #[arg(help = "help.arg.backup-target-id")] pub target_id: BackupTargetId, + #[arg(help = "help.arg.backup-password")] pub password: String, } @@ -63,7 +66,10 @@ pub async fn restore_packages_rpc( match async { res.await?.await }.await { Ok(_) => (), Err(err) => { - tracing::error!("Error restoring package {}: {}", id, err); + tracing::error!( + "{}", + t!("backup.restore.package-error", id = id, error = err) + ); tracing::debug!("{:?}", err); } } @@ -75,10 +81,10 @@ pub async fn restore_packages_rpc( } #[instrument(skip_all)] -pub async fn recover_full_embassy( +pub async fn recover_full_server( ctx: &SetupContext, - disk_guid: Arc, - start_os_password: String, + disk_guid: InternedString, + password: String, recovery_source: TmpMountGuard, server_id: &str, recovery_password: &str, @@ -102,7 +108,7 @@ pub async fn recover_full_embassy( )?; os_backup.account.password = argon2::hash_encoded( - start_os_password.as_bytes(), + password.as_bytes(), &rand::random::<[u8; 16]>()[..], &argon2::Config::rfc9106_low_mem(), ) @@ -111,16 +117,32 @@ pub async fn recover_full_embassy( let kiosk = Some(kiosk.unwrap_or(true)).filter(|_| &*PLATFORM != "raspberrypi"); sync_kiosk(kiosk).await?; + let language = ctx.language.peek(|a| a.clone()); + let keyboard = ctx.keyboard.peek(|a| a.clone()); + + if let Some(language) = &language { + save_language(&**language).await?; + } + + if let Some(keyboard) = &keyboard { + keyboard.save().await?; + } + let db = ctx.db().await?; - db.put(&ROOT, &Database::init(&os_backup.account, kiosk)?) - .await?; + db.put( + &ROOT, + &Database::init(&os_backup.account, kiosk, language, keyboard)?, + ) + .await?; drop(db); - let init_result = init(&ctx.webserver, &ctx.config, init_phases).await?; + let config = ctx.config.peek(|c| c.clone()); + + let init_result = init(&ctx.webserver, &config, init_phases).await?; let rpc_ctx = RpcContext::init( &ctx.webserver, - &ctx.config, + &config, disk_guid.clone(), Some(init_result), rpc_ctx_phases, @@ -145,7 +167,10 @@ pub async fn recover_full_embassy( match async { res.await?.await }.await { Ok(_) => (), Err(err) => { - tracing::error!("Error restoring package {}: {}", id, err); + tracing::error!( + "{}", + t!("backup.restore.package-error", id = id, error = err) + ); tracing::debug!("{:?}", err); } } @@ -155,7 +180,14 @@ pub async fn recover_full_embassy( .await; restore_phase.lock().await.complete(); - Ok(((&os_backup.account).try_into()?, rpc_ctx)) + Ok(( + SetupResult { + hostname: os_backup.account.hostname, + root_ca: Pem(os_backup.account.root_ca_cert), + needs_restart: ctx.install_rootfs.peek(|a| a.is_some()), + }, + rpc_ctx, + )) } #[instrument(skip(ctx, backup_guard))] diff --git a/core/src/backup/target/cifs.rs b/core/src/backup/target/cifs.rs index 350e53220..e19020c54 100644 --- a/core/src/backup/target/cifs.rs +++ b/core/src/backup/target/cifs.rs @@ -52,21 +52,21 @@ pub fn cifs() -> ParentHandler { "add", from_fn_async(add) .no_display() - .with_about("Add a new backup target") + .with_about("about.add-new-backup-target") .with_call_remote::(), ) .subcommand( "update", from_fn_async(update) .no_display() - .with_about("Update an existing backup target") + .with_about("about.update-existing-backup-target") .with_call_remote::(), ) .subcommand( "remove", from_fn_async(remove) .no_display() - .with_about("Remove an existing backup target") + .with_about("about.remove-existing-backup-target") .with_call_remote::(), ) } @@ -75,9 +75,13 @@ pub fn cifs() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct AddParams { + #[arg(help = "help.arg.cifs-hostname")] pub hostname: String, + #[arg(help = "help.arg.cifs-path")] pub path: PathBuf, + #[arg(help = "help.arg.cifs-username")] pub username: String, + #[arg(help = "help.arg.cifs-password")] pub password: Option, } @@ -130,10 +134,15 @@ pub async fn add( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct UpdateParams { + #[arg(help = "help.arg.backup-target-id")] pub id: BackupTargetId, + #[arg(help = "help.arg.cifs-hostname")] pub hostname: String, + #[arg(help = "help.arg.cifs-path")] pub path: PathBuf, + #[arg(help = "help.arg.cifs-username")] pub username: String, + #[arg(help = "help.arg.cifs-password")] pub password: Option, } @@ -151,7 +160,7 @@ pub async fn update( id } else { return Err(Error::new( - eyre!("Backup Target ID {} Not Found", id), + eyre!("{}", t!("backup.target.cifs.target-not-found", id = id)), ErrorKind::NotFound, )); }; @@ -171,7 +180,7 @@ pub async fn update( .as_idx_mut(&id) .ok_or_else(|| { Error::new( - eyre!("Backup Target ID {} Not Found", BackupTargetId::Cifs { id }), + eyre!("{}", t!("backup.target.cifs.target-not-found", id = BackupTargetId::Cifs { id })), ErrorKind::NotFound, ) })? @@ -195,6 +204,7 @@ pub async fn update( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct RemoveParams { + #[arg(help = "help.arg.backup-target-id")] pub id: BackupTargetId, } @@ -203,7 +213,7 @@ pub async fn remove(ctx: RpcContext, RemoveParams { id }: RemoveParams) -> Resul id } else { return Err(Error::new( - eyre!("Backup Target ID {} Not Found", id), + eyre!("{}", t!("backup.target.cifs.target-not-found", id = id)), ErrorKind::NotFound, )); }; @@ -220,7 +230,7 @@ pub fn load(db: &DatabaseModel, id: u32) -> Result { .as_idx(&id) .ok_or_else(|| { Error::new( - eyre!("Backup Target ID {} Not Found", id), + eyre!("{}", t!("backup.target.cifs.target-not-found-id", id = id)), ErrorKind::NotFound, ) })? diff --git a/core/src/backup/target/mod.rs b/core/src/backup/target/mod.rs index 616cffd3b..a5956eb65 100644 --- a/core/src/backup/target/mod.rs +++ b/core/src/backup/target/mod.rs @@ -143,13 +143,13 @@ pub fn target() -> ParentHandler { ParentHandler::new() .subcommand( "cifs", - cifs::cifs::().with_about("Add, remove, or update a backup target"), + cifs::cifs::().with_about("about.add-remove-update-backup-target"), ) .subcommand( "list", from_fn_async(list) .with_display_serializable() - .with_about("List existing backup targets") + .with_about("about.list-existing-backup-targets") .with_call_remote::(), ) .subcommand( @@ -159,20 +159,20 @@ pub fn target() -> ParentHandler { .with_custom_display_fn::(|params, info| { display_backup_info(params.params, info) }) - .with_about("Display package backup information") + .with_about("about.display-package-backup-information") .with_call_remote::(), ) .subcommand( "mount", from_fn_async(mount) - .with_about("Mount backup target") + .with_about("about.mount-backup-target") .with_call_remote::(), ) .subcommand( "umount", from_fn_async(umount) .no_display() - .with_about("Unmount backup target") + .with_about("about.unmount-backup-target") .with_call_remote::(), ) } @@ -268,8 +268,11 @@ fn display_backup_info(params: WithIoFormat, info: BackupInfo) -> Re #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct InfoParams { + #[arg(help = "help.arg.backup-target-id")] target_id: BackupTargetId, + #[arg(help = "help.arg.server-id")] server_id: String, + #[arg(help = "help.arg.backup-password")] password: String, } @@ -305,11 +308,13 @@ lazy_static::lazy_static! { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct MountParams { + #[arg(help = "help.arg.backup-target-id")] target_id: BackupTargetId, - #[arg(long)] + #[arg(long, help = "help.arg.server-id")] server_id: Option, + #[arg(help = "help.arg.backup-password")] password: String, // TODO: rpassword - #[arg(long)] + #[arg(long, help = "help.arg.allow-partial-backup")] allow_partial: bool, } @@ -385,6 +390,7 @@ pub async fn mount( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct UmountParams { + #[arg(help = "help.arg.backup-target-id")] target_id: Option, } diff --git a/core/src/bins/container_cli.rs b/core/src/bins/container_cli.rs index 118133f55..a03204107 100644 --- a/core/src/bins/container_cli.rs +++ b/core/src/bins/container_cli.rs @@ -17,6 +17,7 @@ pub fn main(args: impl IntoIterator) { |cfg: ContainerClientConfig| Ok(ContainerCliContext::init(cfg)), crate::service::effects::handler(), ) + .mutate_command(super::translate_cli) .run(args) { match e.data { diff --git a/core/src/bins/deprecated.rs b/core/src/bins/deprecated.rs index 13e0290db..77eb9e62e 100644 --- a/core/src/bins/deprecated.rs +++ b/core/src/bins/deprecated.rs @@ -1,9 +1,14 @@ +use rust_i18n::t; + pub fn renamed(old: &str, new: &str) -> ! { - eprintln!("{old} has been renamed to {new}"); + eprintln!( + "{}", + t!("bins.deprecated.renamed", old = old, new = new) + ); std::process::exit(1) } pub fn removed(name: &str) -> ! { - eprintln!("{name} has been removed"); + eprintln!("{}", t!("bins.deprecated.removed", name = name)); std::process::exit(1) } diff --git a/core/src/bins/mod.rs b/core/src/bins/mod.rs index de809e093..2b1959db7 100644 --- a/core/src/bins/mod.rs +++ b/core/src/bins/mod.rs @@ -2,6 +2,8 @@ use std::collections::{BTreeMap, VecDeque}; use std::ffi::OsString; use std::path::Path; +use rust_i18n::t; + pub mod container_cli; pub mod deprecated; pub mod registry; @@ -10,6 +12,85 @@ pub mod start_init; pub mod startd; pub mod tunnel; +pub fn set_locale_from_env() { + let lang = std::env::var("LANG").ok(); + let lang = lang + .as_deref() + .map_or("C", |l| l.strip_suffix(".UTF-8").unwrap_or(l)); + set_locale(lang) +} + +pub fn set_locale(lang: &str) { + let mut best = None; + let prefix = lang.split_inclusive("_").next().unwrap(); + for l in rust_i18n::available_locales!() { + if l == lang { + best = Some(l); + break; + } + if best.is_none() && l.starts_with(prefix) { + best = Some(l); + } + } + rust_i18n::set_locale(best.unwrap_or(lang)); +} + +pub fn translate_cli(mut cmd: clap::Command) -> clap::Command { + fn translate(s: impl std::fmt::Display) -> String { + t!(s.to_string()).into_owned() + } + if let Some(s) = cmd.get_about() { + let s = translate(s); + cmd = cmd.about(s); + } + if let Some(s) = cmd.get_long_about() { + let s = translate(s); + cmd = cmd.long_about(s); + } + if let Some(s) = cmd.get_before_help() { + let s = translate(s); + cmd = cmd.before_help(s); + } + if let Some(s) = cmd.get_before_long_help() { + let s = translate(s); + cmd = cmd.before_long_help(s); + } + if let Some(s) = cmd.get_after_help() { + let s = translate(s); + cmd = cmd.after_help(s); + } + if let Some(s) = cmd.get_after_long_help() { + let s = translate(s); + cmd = cmd.after_long_help(s); + } + + let arg_ids = cmd + .get_arguments() + .map(|a| a.get_id().clone()) + .collect::>(); + for id in arg_ids { + cmd = cmd.mut_arg(id, |arg| { + let arg = if let Some(s) = arg.get_help() { + let s = translate(s); + arg.help(s) + } else { + arg + }; + if let Some(s) = arg.get_long_help() { + let s = translate(s); + arg.long_help(s) + } else { + arg + } + }); + } + for cmd in cmd.get_subcommands_mut() { + *cmd = translate_cli(cmd.clone()); + } + + cmd +} + #[derive(Default)] pub struct MultiExecutable { default: Option<&'static str>, @@ -58,7 +139,7 @@ impl MultiExecutable { if let Some((name, _)) = self.bins.get_key_value(name) { self.default = Some(*name); } else { - panic!("{name} does not exist in MultiExecutable"); + panic!("{}", t!("bins.mod.does-not-exist", name = name)); } self } @@ -68,6 +149,8 @@ impl MultiExecutable { } pub fn execute(&self) { + set_locale_from_env(); + let mut popped = Vec::with_capacity(2); let mut args = std::env::args_os().collect::>(); @@ -96,11 +179,15 @@ impl MultiExecutable { } let args = std::env::args().collect::>(); eprintln!( - "unknown executable: {}", - args.get(1) - .or_else(|| args.get(0)) - .map(|s| s.as_str()) - .unwrap_or("N/A") + "{}", + t!( + "bins.mod.unknown-executable", + name = args + .get(1) + .or_else(|| args.get(0)) + .map(|s| s.as_str()) + .unwrap_or("N/A") + ) ); std::process::exit(1); } diff --git a/core/src/bins/registry.rs b/core/src/bins/registry.rs index cee20adc8..13d0c54c2 100644 --- a/core/src/bins/registry.rs +++ b/core/src/bins/registry.rs @@ -3,6 +3,7 @@ use std::ffi::OsString; use clap::Parser; use futures::FutureExt; use rpc_toolkit::CliApp; +use rust_i18n::t; use tokio::signal::unix::signal; use tracing::instrument; @@ -77,7 +78,7 @@ pub fn main(args: impl IntoIterator) { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() - .expect("failed to initialize runtime"); + .expect(&t!("bins.registry.failed-to-initialize-runtime")); rt.block_on(inner_main(&config)) }; @@ -99,6 +100,7 @@ pub fn cli(args: impl IntoIterator) { |cfg: ClientConfig| Ok(CliContext::init(cfg.load()?)?), crate::registry::registry_api(), ) + .mutate_command(super::translate_cli) .run(args) { match e.data { diff --git a/core/src/bins/start_cli.rs b/core/src/bins/start_cli.rs index 647e509d4..e1d737be4 100644 --- a/core/src/bins/start_cli.rs +++ b/core/src/bins/start_cli.rs @@ -19,6 +19,7 @@ pub fn main(args: impl IntoIterator) { |cfg: ClientConfig| Ok(CliContext::init(cfg.load()?)?), crate::main_api(), ) + .mutate_command(super::translate_cli) .run(args) { match e.data { diff --git a/core/src/bins/start_init.rs b/core/src/bins/start_init.rs index fedc9f623..48e65f5af 100644 --- a/core/src/bins/start_init.rs +++ b/core/src/bins/start_init.rs @@ -1,11 +1,9 @@ -use std::sync::Arc; - use tokio::process::Command; use tracing::instrument; use crate::context::config::ServerConfig; use crate::context::rpc::InitRpcContextPhases; -use crate::context::{DiagnosticContext, InitContext, InstallContext, RpcContext, SetupContext}; +use crate::context::{DiagnosticContext, InitContext, RpcContext, SetupContext}; use crate::disk::REPAIR_DISK_PATH; use crate::disk::fsck::RepairStrategy; use crate::disk::main::DEFAULT_PASSWORD; @@ -27,7 +25,13 @@ async fn setup_or_init( if let Some(firmware) = check_for_firmware_update() .await .map_err(|e| { - tracing::warn!("Error checking for firmware update: {e}"); + tracing::warn!( + "{}", + t!( + "bins.start-init.error-checking-firmware", + error = e.to_string() + ) + ); tracing::debug!("{e:?}"); }) .ok() @@ -35,14 +39,21 @@ async fn setup_or_init( { let init_ctx = InitContext::init(config).await?; let handle = &init_ctx.progress; - let mut update_phase = handle.add_phase("Updating Firmware".into(), Some(10)); - let mut reboot_phase = handle.add_phase("Rebooting".into(), Some(1)); + let mut update_phase = + handle.add_phase(t!("bins.start-init.updating-firmware").into(), Some(10)); + let mut reboot_phase = handle.add_phase(t!("bins.start-init.rebooting").into(), Some(1)); server.serve_ui_for(init_ctx); update_phase.start(); if let Err(e) = update_firmware(firmware).await { - tracing::warn!("Error performing firmware update: {e}"); + tracing::warn!( + "{}", + t!( + "bins.start-init.error-firmware-update", + error = e.to_string() + ) + ); tracing::debug!("{e:?}"); } else { update_phase.complete(); @@ -79,40 +90,11 @@ async fn setup_or_init( .invoke(crate::ErrorKind::OpenSsl) .await?; - if tokio::fs::metadata("/run/live/medium").await.is_ok() { - Command::new("sed") - .arg("-i") - .arg("s/PasswordAuthentication no/PasswordAuthentication yes/g") - .arg("/etc/ssh/sshd_config") - .invoke(crate::ErrorKind::Filesystem) - .await?; - Command::new("systemctl") - .arg("reload") - .arg("ssh") - .invoke(crate::ErrorKind::OpenSsh) - .await?; - - let ctx = InstallContext::init().await?; - - server.serve_ui_for(ctx.clone()); - - ctx.shutdown - .subscribe() - .recv() - .await - .expect("context dropped"); - - return Ok(Err(Shutdown { - disk_guid: None, - restart: true, - })); - } - if tokio::fs::metadata("/media/startos/config/disk.guid") .await .is_err() { - let ctx = SetupContext::init(server, config)?; + let ctx = SetupContext::init(server, config.clone())?; server.serve_ui_for(ctx.clone()); @@ -127,7 +109,13 @@ async fn setup_or_init( .invoke(ErrorKind::NotFound) .await { - tracing::error!("Failed to kill kiosk: {}", e); + tracing::error!( + "{}", + t!( + "bins.start-init.failed-to-kill-kiosk", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } @@ -136,7 +124,7 @@ async fn setup_or_init( Some(Err(e)) => return Err(e.clone_output()), None => { return Err(Error::new( - eyre!("Setup mode exited before setup completed"), + eyre!("{}", t!("bins.start-init.setup-mode-exited")), ErrorKind::Unknown, )); } @@ -146,7 +134,8 @@ async fn setup_or_init( let handle = init_ctx.progress.clone(); let err_channel = init_ctx.error.clone(); - let mut disk_phase = handle.add_phase("Opening data drive".into(), Some(10)); + let mut disk_phase = + handle.add_phase(t!("bins.start-init.opening-data-drive").into(), Some(10)); let init_phases = InitPhases::new(&handle); let rpc_ctx_phases = InitRpcContextPhases::new(&handle); @@ -156,9 +145,9 @@ async fn setup_or_init( disk_phase.start(); let guid_string = tokio::fs::read_to_string("/media/startos/config/disk.guid") // unique identifier for volume group - keeps track of the disk that goes with your embassy .await?; - let disk_guid = Arc::new(String::from(guid_string.trim())); + let disk_guid = InternedString::intern(guid_string.trim()); let requires_reboot = crate::disk::main::import( - &**disk_guid, + &*disk_guid, DATA_DIR, if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() { RepairStrategy::Aggressive @@ -178,11 +167,12 @@ async fn setup_or_init( .with_ctx(|_| (crate::ErrorKind::Filesystem, REPAIR_DISK_PATH))?; } disk_phase.complete(); - tracing::info!("Loaded Disk"); + tracing::info!("{}", t!("bins.start-init.loaded-disk")); if requires_reboot.0 { - tracing::info!("Rebooting..."); - let mut reboot_phase = handle.add_phase("Rebooting".into(), Some(1)); + tracing::info!("{}", t!("bins.start-init.rebooting")); + let mut reboot_phase = + handle.add_phase(t!("bins.start-init.rebooting").into(), Some(1)); reboot_phase.start(); return Ok(Err(Shutdown { disk_guid: Some(disk_guid), @@ -236,11 +226,10 @@ pub async fn main( .await .is_ok() { - Some(Arc::new( + Some(InternedString::intern( tokio::fs::read_to_string("/media/startos/config/disk.guid") // unique identifier for volume group - keeps track of the disk that goes with your embassy .await? - .trim() - .to_owned(), + .trim(), )) } else { None diff --git a/core/src/bins/startd.rs b/core/src/bins/startd.rs index 86c391508..9cc8b310e 100644 --- a/core/src/bins/startd.rs +++ b/core/src/bins/startd.rs @@ -1,10 +1,10 @@ use std::cmp::max; use std::ffi::OsString; -use std::sync::Arc; use std::time::Duration; use clap::Parser; use color_eyre::eyre::eyre; +use rust_i18n::t; use futures::{FutureExt, TryFutureExt}; use tokio::signal::unix::signal; use tracing::instrument; @@ -15,11 +15,11 @@ use crate::context::{DiagnosticContext, InitContext, RpcContext}; use crate::net::gateway::{BindTcp, SelfContainedNetworkInterfaceListener, UpgradableListener}; use crate::net::static_server::refresher; use crate::net::web_server::{Acceptor, WebServer}; +use crate::prelude::*; use crate::shutdown::Shutdown; use crate::system::launch_metrics_task; use crate::util::io::append_file; use crate::util::logger::LOGGER; -use crate::{Error, ErrorKind, ResultExt}; #[instrument(skip_all)] async fn inner_main( @@ -53,11 +53,10 @@ async fn inner_main( let ctx = RpcContext::init( &server.acceptor_setter(), config, - Arc::new( + InternedString::intern( tokio::fs::read_to_string("/media/startos/config/disk.guid") // unique identifier for volume group - keeps track of the disk that goes with your embassy .await? - .trim() - .to_owned(), + .trim(), ), None, rpc_ctx_phases, @@ -114,11 +113,11 @@ async fn inner_main( metrics_task .map_err(|e| { Error::new( - eyre!("{}", e).wrap_err("Metrics daemon panicked!"), + eyre!("{}", e).wrap_err(t!("bins.startd.metrics-daemon-panicked").to_string()), ErrorKind::Unknown, ) }) - .map_ok(|_| tracing::debug!("Metrics daemon Shutdown")) + .map_ok(|_| tracing::debug!("{}", t!("bins.startd.metrics-daemon-shutdown"))) .await?; let shutdown = shutdown_recv @@ -146,7 +145,7 @@ pub fn main(args: impl IntoIterator) { .worker_threads(max(1, num_cpus::get())) .enable_all() .build() - .expect("failed to initialize runtime"); + .expect(&t!("bins.startd.failed-to-initialize-runtime")); let res = rt.block_on(async { let mut server = WebServer::new( Acceptor::bind_upgradable(SelfContainedNetworkInterfaceListener::bind(BindTcp, 80)), @@ -167,11 +166,10 @@ pub fn main(args: impl IntoIterator) { .await .is_ok() { - Some(Arc::new( + Some(InternedString::intern( tokio::fs::read_to_string("/media/startos/config/disk.guid") // unique identifier for volume group - keeps track of the disk that goes with your embassy .await? - .trim() - .to_owned(), + .trim(), )) } else { None diff --git a/core/src/bins/tunnel.rs b/core/src/bins/tunnel.rs index 5c018f796..97fb818ea 100644 --- a/core/src/bins/tunnel.rs +++ b/core/src/bins/tunnel.rs @@ -6,6 +6,7 @@ use std::time::Duration; use clap::Parser; use futures::FutureExt; use rpc_toolkit::CliApp; +use rust_i18n::t; use tokio::signal::unix::signal; use tracing::instrument; use visit_rs::Visit; @@ -70,7 +71,7 @@ async fn inner_main(config: &TunnelConfig) -> Result<(), Error> { true } Err(e) => { - tracing::error!("error adding ssl listener: {e}"); + tracing::error!("{}", t!("bins.tunnel.error-adding-ssl-listener", error = e.to_string())); tracing::debug!("{e:?}"); false @@ -92,7 +93,7 @@ async fn inner_main(config: &TunnelConfig) -> Result<(), Error> { } .await { - tracing::error!("error updating webserver bind: {e}"); + tracing::error!("{}", t!("bins.tunnel.error-updating-webserver-bind", error = e.to_string())); tracing::debug!("{e:?}"); tokio::time::sleep(Duration::from_secs(5)).await; } @@ -157,7 +158,7 @@ pub fn main(args: impl IntoIterator) { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() - .expect("failed to initialize runtime"); + .expect(&t!("bins.tunnel.failed-to-initialize-runtime")); rt.block_on(inner_main(&config)) }; @@ -179,6 +180,7 @@ pub fn cli(args: impl IntoIterator) { |cfg: ClientConfig| Ok(CliContext::init(cfg.load()?)?), crate::tunnel::api::tunnel_api(), ) + .mutate_command(super::translate_cli) .run(args) { match e.data { diff --git a/core/src/context/cli.rs b/core/src/context/cli.rs index 5aeda762e..39d1c7890 100644 --- a/core/src/context/cli.rs +++ b/core/src/context/cli.rs @@ -23,7 +23,7 @@ use tracing::instrument; use super::setup::CURRENT_SECRET; use crate::context::config::{ClientConfig, local_config_path}; -use crate::context::{DiagnosticContext, InitContext, InstallContext, RpcContext, SetupContext}; +use crate::context::{DiagnosticContext, InitContext, RpcContext, SetupContext}; use crate::developer::{OS_DEVELOPER_KEY_PATH, default_developer_key_path}; use crate::middleware::auth::local::LocalAuthContext; use crate::prelude::*; @@ -166,14 +166,14 @@ impl CliContext { .with_kind(crate::ErrorKind::Pem)?; let secret = ed25519_dalek::SecretKey::try_from(&pair.secret_key[..]).map_err(|_| { Error::new( - eyre!("pkcs8 key is of incorrect length"), + eyre!("{}", t!("context.cli.pkcs8-key-incorrect-length")), ErrorKind::OpenSsl, ) })?; return Ok(secret.into()) } Err(Error::new( - eyre!("Developer Key does not exist! Please run `start-cli init-key` before running this command."), + eyre!("{}", t!("context.cli.developer-key-does-not-exist")), crate::ErrorKind::Uninitialized )) }) @@ -189,14 +189,14 @@ impl CliContext { "http" => "ws", _ => { return Err(Error::new( - eyre!("Cannot parse scheme from base URL"), + eyre!("{}", t!("context.cli.cannot-parse-scheme-from-base-url")), crate::ErrorKind::ParseUrl, ) .into()); } }; url.set_scheme(ws_scheme) - .map_err(|_| Error::new(eyre!("Cannot set URL scheme"), crate::ErrorKind::ParseUrl))?; + .map_err(|_| Error::new(eyre!("{}", t!("context.cli.cannot-set-url-scheme")), crate::ErrorKind::ParseUrl))?; url.path_segments_mut() .map_err(|_| eyre!("Url cannot be base")) .with_kind(crate::ErrorKind::ParseUrl)? @@ -394,22 +394,3 @@ impl CallRemote for CliContext { .await } } -impl CallRemote for CliContext { - async fn call_remote( - &self, - method: &str, - _: OrdMap<&'static str, Value>, - params: Value, - _: Empty, - ) -> Result { - crate::middleware::auth::signature::call_remote( - self, - self.rpc_url.clone(), - HeaderMap::new(), - self.rpc_url.host_str(), - method, - params, - ) - .await - } -} diff --git a/core/src/context/config.rs b/core/src/context/config.rs index 6f777670b..755837c42 100644 --- a/core/src/context/config.rs +++ b/core/src/context/config.rs @@ -58,27 +58,27 @@ pub trait ContextConfig: DeserializeOwned + Default { #[command(rename_all = "kebab-case")] #[command(version = crate::version::Current::default().semver().to_string())] pub struct ClientConfig { - #[arg(short = 'c', long)] + #[arg(short = 'c', long, help = "help.arg.config-file-path")] pub config: Option, - #[arg(short = 'H', long)] + #[arg(short = 'H', long, help = "help.arg.host-url")] pub host: Option, - #[arg(short = 'r', long)] + #[arg(short = 'r', long, help = "help.arg.registry-url")] pub registry: Option, - #[arg(long)] + #[arg(long, help = "help.arg.registry-hostname")] pub registry_hostname: Option>, #[arg(skip)] pub registry_listen: Option, - #[arg(short = 't', long)] + #[arg(short = 't', long, help = "help.arg.tunnel-address")] pub tunnel: Option, #[arg(skip)] pub tunnel_listen: Option, - #[arg(short = 'p', long)] + #[arg(short = 'p', long, help = "help.arg.proxy-url")] pub proxy: Option, #[arg(skip)] pub socks_listen: Option, - #[arg(long)] + #[arg(long, help = "help.arg.cookie-path")] pub cookie_path: Option, - #[arg(long)] + #[arg(long, help = "help.arg.developer-key-path")] pub developer_key_path: Option, } impl ContextConfig for ClientConfig { @@ -109,21 +109,19 @@ impl ClientConfig { #[serde(rename_all = "kebab-case")] #[command(rename_all = "kebab-case")] pub struct ServerConfig { - #[arg(short, long)] + #[arg(short, long, help = "help.arg.config-file-path")] pub config: Option, - #[arg(long)] - pub ethernet_interface: Option, #[arg(skip)] pub os_partitions: Option, - #[arg(long)] + #[arg(long, help = "help.arg.socks-listen-address")] pub socks_listen: Option, - #[arg(long)] + #[arg(long, help = "help.arg.revision-cache-size")] pub revision_cache_size: Option, - #[arg(long)] + #[arg(long, help = "help.arg.disable-encryption")] pub disable_encryption: Option, - #[arg(long)] + #[arg(long, help = "help.arg.multi-arch-s9pks")] pub multi_arch_s9pks: Option, - #[arg(long)] + #[arg(long, help = "help.arg.developer-key-path")] pub developer_key_path: Option, } impl ContextConfig for ServerConfig { @@ -131,7 +129,6 @@ impl ContextConfig for ServerConfig { self.config.take() } fn merge_with(&mut self, other: Self) { - self.ethernet_interface = self.ethernet_interface.take().or(other.ethernet_interface); self.os_partitions = self.os_partitions.take().or(other.os_partitions); self.socks_listen = self.socks_listen.take().or(other.socks_listen); self.revision_cache_size = self diff --git a/core/src/context/diagnostic.rs b/core/src/context/diagnostic.rs index e1bff2466..a0201163c 100644 --- a/core/src/context/diagnostic.rs +++ b/core/src/context/diagnostic.rs @@ -6,15 +6,15 @@ use rpc_toolkit::yajrc::RpcError; use tokio::sync::broadcast::Sender; use tracing::instrument; -use crate::Error; use crate::context::config::ServerConfig; +use crate::prelude::*; use crate::rpc_continuations::RpcContinuations; use crate::shutdown::Shutdown; pub struct DiagnosticContextSeed { pub shutdown: Sender, pub error: Arc, - pub disk_guid: Option>, + pub disk_guid: Option, pub rpc_continuations: RpcContinuations, } @@ -24,10 +24,10 @@ impl DiagnosticContext { #[instrument(skip_all)] pub fn init( _config: &ServerConfig, - disk_guid: Option>, + disk_guid: Option, error: Error, ) -> Result { - tracing::error!("Error: {}: Starting diagnostic UI", error); + tracing::error!("{}", t!("context.diagnostic.starting-diagnostic-ui", error = error)); tracing::debug!("{:?}", error); let (shutdown, _) = tokio::sync::broadcast::channel(1); diff --git a/core/src/context/install.rs b/core/src/context/install.rs deleted file mode 100644 index 04717cf93..000000000 --- a/core/src/context/install.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::ops::Deref; -use std::sync::Arc; - -use rpc_toolkit::Context; -use tokio::sync::broadcast::Sender; -use tracing::instrument; - -use crate::Error; -use crate::net::utils::find_eth_iface; -use crate::rpc_continuations::RpcContinuations; - -pub struct InstallContextSeed { - pub ethernet_interface: String, - pub shutdown: Sender<()>, - pub rpc_continuations: RpcContinuations, -} - -#[derive(Clone)] -pub struct InstallContext(Arc); -impl InstallContext { - #[instrument(skip_all)] - pub async fn init() -> Result { - let (shutdown, _) = tokio::sync::broadcast::channel(1); - Ok(Self(Arc::new(InstallContextSeed { - ethernet_interface: find_eth_iface().await?, - shutdown, - rpc_continuations: RpcContinuations::new(), - }))) - } -} - -impl AsRef for InstallContext { - fn as_ref(&self) -> &RpcContinuations { - &self.rpc_continuations - } -} - -impl Context for InstallContext {} -impl Deref for InstallContext { - type Target = InstallContextSeed; - fn deref(&self) -> &Self::Target { - &*self.0 - } -} diff --git a/core/src/context/mod.rs b/core/src/context/mod.rs index efe261b0c..3c6cff2ef 100644 --- a/core/src/context/mod.rs +++ b/core/src/context/mod.rs @@ -2,13 +2,11 @@ pub mod cli; pub mod config; pub mod diagnostic; pub mod init; -pub mod install; pub mod rpc; pub mod setup; pub use cli::CliContext; pub use diagnostic::DiagnosticContext; pub use init::InitContext; -pub use install::InstallContext; pub use rpc::RpcContext; pub use setup::SetupContext; diff --git a/core/src/context/rpc.rs b/core/src/context/rpc.rs index 6988e5b75..28635759e 100644 --- a/core/src/context/rpc.rs +++ b/core/src/context/rpc.rs @@ -60,7 +60,7 @@ pub struct RpcContextSeed { pub os_partitions: OsPartitionInfo, pub wifi_interface: Option, pub ethernet_interface: String, - pub disk_guid: Arc, + pub disk_guid: InternedString, pub ephemeral_sessions: SyncMutex, pub db: TypedPatchDb, pub sync_db: watch::Sender, @@ -84,7 +84,7 @@ pub struct RpcContextSeed { } impl Drop for RpcContextSeed { fn drop(&mut self) { - tracing::info!("RpcContext is dropped"); + tracing::info!("{}", t!("context.rpc.rpc-context-dropped")); } } @@ -134,7 +134,7 @@ impl RpcContext { pub async fn init( webserver: &WebServerAcceptorSetter, config: &ServerConfig, - disk_guid: Arc, + disk_guid: InternedString, init_result: Option, InitRpcContextPhases { mut load_db, @@ -155,7 +155,7 @@ impl RpcContext { let peek = db.peek().await; let account = AccountInfo::load(&peek)?; load_db.complete(); - tracing::info!("Opened PatchDB"); + tracing::info!("{}", t!("context.rpc.opened-patchdb")); init_net_ctrl.start(); let (net_controller, os_net_service) = if let Some(InitResult { @@ -172,15 +172,15 @@ impl RpcContext { (net_ctrl, os_net_service) }; init_net_ctrl.complete(); - tracing::info!("Initialized Net Controller"); + tracing::info!("{}", t!("context.rpc.initialized-net-controller")); if PLATFORM.ends_with("-nonfree") { if let Err(e) = Command::new("nvidia-smi") .invoke(ErrorKind::ParseSysInfo) .await { - tracing::warn!("nvidia-smi: {e}"); - tracing::info!("The above warning can be ignored if no NVIDIA card is present"); + tracing::warn!("{}", t!("context.rpc.nvidia-smi-error", error = e)); + tracing::info!("{}", t!("context.rpc.nvidia-warning-can-be-ignored")); } else { async { let version: InternedString = String::from_utf8( @@ -279,7 +279,7 @@ impl RpcContext { .arg("100000") .invoke(ErrorKind::Filesystem) .await?; - tmp.unmount_and_delete().await?; + // tmp.unmount_and_delete().await?; } BlockDev::new(&sqfs) .mount(NVIDIA_OVERLAY_PATH, ReadOnly) @@ -335,16 +335,12 @@ impl RpcContext { is_closed: AtomicBool::new(false), os_partitions: config.os_partitions.clone().ok_or_else(|| { Error::new( - eyre!("OS Partition Information Missing"), + eyre!("{}", t!("context.rpc.os-partition-info-missing")), ErrorKind::Filesystem, ) })?, wifi_interface: wifi_interface.clone(), - ethernet_interface: if let Some(eth) = config.ethernet_interface.clone() { - eth - } else { - find_eth_iface().await? - }, + ethernet_interface: find_eth_iface().await?, disk_guid, ephemeral_sessions: SyncMutex::new(Sessions::new()), sync_db: watch::Sender::new(db.sequence().await), @@ -369,9 +365,9 @@ impl RpcContext { current_secret: Arc::new( Jwk::generate_ec_key(josekit::jwk::alg::ec::EcCurve::P256).map_err(|e| { tracing::debug!("{:?}", e); - tracing::error!("Couldn't generate ec key"); + tracing::error!("{}", t!("context.rpc.couldnt-generate-ec-key")); Error::new( - color_eyre::eyre::eyre!("Couldn't generate ec key"), + color_eyre::eyre::eyre!("{}", t!("context.rpc.couldnt-generate-ec-key")), crate::ErrorKind::Unknown, ) })?, @@ -386,10 +382,10 @@ impl RpcContext { let res = Self(seed.clone()); res.cleanup_and_initialize(cleanup_init).await?; - tracing::info!("Cleaned up transient states"); + tracing::info!("{}", t!("context.rpc.cleaned-up-transient-states")); crate::version::post_init(&res, run_migrations).await?; - tracing::info!("Completed migrations"); + tracing::info!("{}", t!("context.rpc.completed-migrations")); Ok(res) } @@ -398,7 +394,7 @@ impl RpcContext { self.crons.mutate(|c| std::mem::take(c)); self.services.shutdown_all().await?; self.is_closed.store(true, Ordering::SeqCst); - tracing::info!("RpcContext is shutdown"); + tracing::info!("{}", t!("context.rpc.rpc-context-shutdown")); Ok(()) } @@ -467,7 +463,7 @@ impl RpcContext { .await .result { - tracing::error!("Error in session cleanup cron: {e}"); + tracing::error!("{}", t!("context.rpc.error-in-session-cleanup-cron", error = e)); tracing::debug!("{e:?}"); } } diff --git a/core/src/context/setup.rs b/core/src/context/setup.rs index ecef31f83..bbfee9862 100644 --- a/core/src/context/setup.rs +++ b/core/src/context/setup.rs @@ -6,6 +6,7 @@ use std::time::Duration; use futures::{Future, StreamExt}; use imbl_value::InternedString; use josekit::jwk::Jwk; +use openssl::x509::X509; use patch_db::PatchDb; use rpc_toolkit::Context; use serde::{Deserialize, Serialize}; @@ -15,10 +16,9 @@ use tracing::instrument; use ts_rs::TS; use crate::MAIN_DATA; -use crate::account::AccountInfo; use crate::context::RpcContext; use crate::context::config::ServerConfig; -use crate::disk::OsPartitionInfo; +use crate::disk::mount::guard::{MountGuard, TmpMountGuard}; use crate::hostname::Hostname; use crate::net::gateway::UpgradableListener; use crate::net::web_server::{WebServer, WebServerAcceptorSetter}; @@ -27,12 +27,15 @@ use crate::progress::FullProgressTracker; use crate::rpc_continuations::{Guid, RpcContinuation, RpcContinuations}; use crate::setup::SetupProgress; use crate::shutdown::Shutdown; +use crate::system::KeyboardOptions; use crate::util::future::NonDetachingJoinHandle; +use crate::util::serde::Pem; +use crate::util::sync::SyncMutex; lazy_static::lazy_static! { pub static ref CURRENT_SECRET: Jwk = Jwk::generate_ec_key(josekit::jwk::alg::ec::EcCurve::P256).unwrap_or_else(|e| { tracing::debug!("{:?}", e); - tracing::error!("Couldn't generate ec key"); + tracing::error!("{}", t!("context.setup.couldnt-generate-ec-key")); panic!("Couldn't generate ec key") }); } @@ -41,40 +44,25 @@ lazy_static::lazy_static! { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct SetupResult { - pub tor_addresses: Vec, #[ts(type = "string")] pub hostname: Hostname, - #[ts(type = "string")] - pub lan_address: InternedString, - pub root_ca: String, -} -impl TryFrom<&AccountInfo> for SetupResult { - type Error = Error; - fn try_from(value: &AccountInfo) -> Result { - Ok(Self { - tor_addresses: value - .tor_keys - .iter() - .map(|tor_key| format!("https://{}", tor_key.onion_address())) - .collect(), - hostname: value.hostname.clone(), - lan_address: value.hostname.lan_address(), - root_ca: String::from_utf8(value.root_ca_cert.to_pem()?)?, - }) - } + pub root_ca: Pem, + pub needs_restart: bool, } pub struct SetupContextSeed { pub webserver: WebServerAcceptorSetter, - pub config: ServerConfig, - pub os_partitions: OsPartitionInfo, + pub config: SyncMutex, pub disable_encryption: bool, pub progress: FullProgressTracker, pub task: OnceCell>, pub result: OnceCell>, - pub disk_guid: OnceCell>, + pub disk_guid: OnceCell, pub shutdown: Sender>, pub rpc_continuations: RpcContinuations, + pub install_rootfs: SyncMutex>, + pub keyboard: SyncMutex>, + pub language: SyncMutex>, } #[derive(Clone)] @@ -83,27 +71,24 @@ impl SetupContext { #[instrument(skip_all)] pub fn init( webserver: &WebServer, - config: &ServerConfig, + config: ServerConfig, ) -> Result { let (shutdown, _) = tokio::sync::broadcast::channel(1); let mut progress = FullProgressTracker::new(); progress.enable_logging(true); Ok(Self(Arc::new(SetupContextSeed { webserver: webserver.acceptor_setter(), - config: config.clone(), - os_partitions: config.os_partitions.clone().ok_or_else(|| { - Error::new( - eyre!("missing required configuration: `os-partitions`"), - ErrorKind::NotFound, - ) - })?, disable_encryption: config.disable_encryption.unwrap_or(false), + config: SyncMutex::new(config), progress, task: OnceCell::new(), result: OnceCell::new(), disk_guid: OnceCell::new(), shutdown, rpc_continuations: RpcContinuations::new(), + install_rootfs: SyncMutex::new(None), + language: SyncMutex::new(None), + keyboard: SyncMutex::new(None), }))) } #[instrument(skip_all)] @@ -129,11 +114,14 @@ impl SetupContext { .get_or_init(|| async { match f().await { Ok(res) => { - tracing::info!("Setup complete!"); + tracing::info!("{}", t!("context.setup.setup-complete")); Ok(res) } Err(e) => { - tracing::error!("Setup failed: {e}"); + tracing::error!( + "{}", + t!("context.setup.setup-failed", error = e) + ); tracing::debug!("{e:?}"); Err(e) } @@ -146,10 +134,13 @@ impl SetupContext { ) .map_err(|_| { if self.result.initialized() { - Error::new(eyre!("Setup already complete"), ErrorKind::InvalidRequest) + Error::new( + eyre!("{}", t!("context.setup.setup-already-complete")), + ErrorKind::InvalidRequest, + ) } else { Error::new( - eyre!("Setup already in progress"), + eyre!("{}", t!("context.setup.setup-already-in-progress")), ErrorKind::InvalidRequest, ) } @@ -199,7 +190,7 @@ impl SetupContext { } .await { - tracing::error!("Error in setup progress websocket: {e}"); + tracing::error!("{}", t!("context.setup.error-in-setup-progress-websocket", error = e)); tracing::debug!("{e:?}"); } }, diff --git a/core/src/control.rs b/core/src/control.rs index 565bfd529..f5d39d288 100644 --- a/core/src/control.rs +++ b/core/src/control.rs @@ -11,6 +11,7 @@ use crate::{Error, PackageId}; #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ControlParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, } diff --git a/core/src/db/mod.rs b/core/src/db/mod.rs index bdcce8f7f..2661b7ea3 100644 --- a/core/src/db/mod.rs +++ b/core/src/db/mod.rs @@ -54,7 +54,7 @@ pub fn db() -> ParentHandler { "dump", from_fn_async(cli_dump) .with_display_serializable() - .with_about("Filter/query db to display tables and records"), + .with_about("about.filter-query-db"), ) .subcommand("dump", from_fn_async(dump).no_cli()) .subcommand( @@ -65,13 +65,13 @@ pub fn db() -> ParentHandler { ) .subcommand( "put", - put::().with_about("Command for adding UI record to db"), + put::().with_about("about.command-add-ui-record-db"), ) .subcommand( "apply", from_fn_async(cli_apply) .no_display() - .with_about("Update a db record"), + .with_about("about.update-db-record"), ) .subcommand("apply", from_fn_async(apply).no_cli()) } @@ -87,9 +87,10 @@ pub enum RevisionsRes { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CliDumpParams { - #[arg(long = "include-private", short = 'p')] + #[arg(long = "include-private", short = 'p', help = "help.arg.include-private-data")] #[serde(default)] include_private: bool, + #[arg(help = "help.arg.db-path")] path: Option, } @@ -258,9 +259,11 @@ pub async fn subscribe( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CliApplyParams { - #[arg(long)] + #[arg(long, help = "help.arg.allow-model-mismatch")] allow_model_mismatch: bool, + #[arg(help = "help.arg.db-apply-expr")] expr: String, + #[arg(help = "help.arg.db-path")] path: Option, } @@ -327,6 +330,7 @@ async fn cli_apply( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ApplyParams { + #[arg(help = "help.arg.db-apply-expr")] expr: String, } @@ -358,7 +362,7 @@ pub fn put() -> ParentHandler { "ui", from_fn_async(ui) .with_display_serializable() - .with_about("Add path and value to db") + .with_about("about.add-path-value-db") .with_call_remote::(), ) } @@ -366,8 +370,10 @@ pub fn put() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct UiParams { + #[arg(help = "help.arg.json-pointer")] #[ts(type = "string")] pointer: JsonPointer, + #[arg(help = "help.arg.json-value")] #[ts(type = "any")] value: Value, } diff --git a/core/src/db/model/mod.rs b/core/src/db/model/mod.rs index b153ea44b..64a9ae4c6 100644 --- a/core/src/db/model/mod.rs +++ b/core/src/db/model/mod.rs @@ -14,6 +14,7 @@ use crate::notifications::Notifications; use crate::prelude::*; use crate::sign::AnyVerifyingKey; use crate::ssh::SshKeys; +use crate::system::KeyboardOptions; use crate::util::serde::Pem; pub mod package; @@ -28,9 +29,14 @@ pub struct Database { pub private: Private, } impl Database { - pub fn init(account: &AccountInfo, kiosk: Option) -> Result { + pub fn init( + account: &AccountInfo, + kiosk: Option, + language: Option, + keyboard: Option, + ) -> Result { Ok(Self { - public: Public::init(account, kiosk)?, + public: Public::init(account, kiosk, language, keyboard)?, private: Private { key_store: KeyStore::new(account)?, password: account.password.clone(), diff --git a/core/src/db/model/package.rs b/core/src/db/model/package.rs index b93904dfa..70c33a360 100644 --- a/core/src/db/model/package.rs +++ b/core/src/db/model/package.rs @@ -14,7 +14,7 @@ use crate::net::host::Hosts; use crate::net::service_interface::ServiceInterface; use crate::prelude::*; use crate::progress::FullProgress; -use crate::s9pk::manifest::Manifest; +use crate::s9pk::manifest::{LocaleString, Manifest}; use crate::status::StatusInfo; use crate::util::DataUrl; use crate::util::serde::{Pem, is_partial_of}; @@ -417,8 +417,7 @@ impl Map for CurrentDependencies { #[serde(rename_all = "camelCase")] #[model = "Model"] pub struct CurrentDependencyInfo { - #[ts(type = "string | null")] - pub title: Option, + pub title: Option, pub icon: Option>, #[serde(flatten)] pub kind: CurrentDependencyKind, diff --git a/core/src/db/model/public.rs b/core/src/db/model/public.rs index b24b2a210..20c5bc390 100644 --- a/core/src/db/model/public.rs +++ b/core/src/db/model/public.rs @@ -25,7 +25,7 @@ use crate::net::utils::ipv6_is_local; use crate::net::vhost::AlpnInfo; use crate::prelude::*; use crate::progress::FullProgress; -use crate::system::SmtpValue; +use crate::system::{KeyboardOptions, SmtpValue}; use crate::util::cpupower::Governor; use crate::util::lshw::LshwDevice; use crate::util::serde::MaybeUtf8String; @@ -45,7 +45,12 @@ pub struct Public { pub ui: Value, } impl Public { - pub fn init(account: &AccountInfo, kiosk: Option) -> Result { + pub fn init( + account: &AccountInfo, + kiosk: Option, + language: Option, + keyboard: Option, + ) -> Result { Ok(Self { server_info: ServerInfo { arch: get_arch(), @@ -139,6 +144,8 @@ impl Public { ram: 0, devices: Vec::new(), kiosk, + language, + keyboard, }, package_data: AllPackageData::default(), ui: serde_json::from_str(*DB_UI_SEED_CELL.get().unwrap_or(&"null")) @@ -195,6 +202,8 @@ pub struct ServerInfo { pub ram: u64, pub devices: Vec, pub kiosk: Option, + pub language: Option, + pub keyboard: Option, } #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] diff --git a/core/src/dependencies.rs b/core/src/dependencies.rs index 3b6f7bd75..73627075b 100644 --- a/core/src/dependencies.rs +++ b/core/src/dependencies.rs @@ -1,11 +1,11 @@ use std::collections::BTreeMap; use std::path::Path; -use imbl_value::InternedString; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::prelude::*; +use crate::s9pk::manifest::LocaleString; use crate::util::PathOrUrl; use crate::{Error, PackageId}; @@ -28,7 +28,7 @@ impl Map for Dependencies { #[serde(rename_all = "camelCase")] #[model = "Model"] pub struct DepInfo { - pub description: Option, + pub description: Option, pub optional: bool, #[serde(flatten)] pub metadata: Option, @@ -73,7 +73,7 @@ pub enum MetadataSrc { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Metadata { - pub title: InternedString, + pub title: LocaleString, pub icon: PathOrUrl, } @@ -82,5 +82,5 @@ pub struct Metadata { #[model = "Model"] pub struct DependencyMetadata { #[ts(type = "string")] - pub title: InternedString, + pub title: LocaleString, } diff --git a/core/src/diagnostic.rs b/core/src/diagnostic.rs index 820d05512..ba58f2354 100644 --- a/core/src/diagnostic.rs +++ b/core/src/diagnostic.rs @@ -17,45 +17,46 @@ pub fn diagnostic() -> ParentHandler { .subcommand( "error", from_fn(error) - .with_about("Display diagnostic error") + .with_about("about.display-diagnostic-error") .with_call_remote::(), ) .subcommand( "logs", - crate::system::logs::().with_about("Display OS logs"), + crate::system::logs::().with_about("about.display-os-logs"), ) .subcommand( "logs", from_fn_async(crate::logs::cli_logs::) .no_display() - .with_about("Display OS logs"), + .with_about("about.display-os-logs"), ) .subcommand( "kernel-logs", - crate::system::kernel_logs::().with_about("Display kernel logs"), + crate::system::kernel_logs::() + .with_about("about.display-kernel-logs"), ) .subcommand( "kernel-logs", from_fn_async(crate::logs::cli_logs::) .no_display() - .with_about("Display kernal logs"), + .with_about("about.display-kernel-logs"), ) .subcommand( "restart", from_fn(restart) .no_display() - .with_about("Restart the server") + .with_about("about.restart-server") .with_call_remote::(), ) .subcommand( "disk", - disk::().with_about("Command to remove disk from filesystem"), + disk::().with_about("about.command-remove-disk-filesystem"), ) .subcommand( "rebuild", from_fn_async(rebuild) .no_display() - .with_about("Teardown and rebuild service containers") + .with_about("about.teardown-rebuild-containers") .with_call_remote::(), ) } @@ -89,16 +90,16 @@ pub fn disk() -> ParentHandler { from_fn_async(forget_disk::).no_display(), ) .no_display() - .with_about("Remove disk from filesystem"), + .with_about("about.remove-disk-filesystem"), ) .subcommand("repair", from_fn_async(|_: C| repair()).no_cli()) .subcommand( "repair", CallRemoteHandler::::new( - from_fn_async(|_: RpcContext| repair()) - .no_display() - .with_about("Repair disk in the event of corruption"), - ), + from_fn_async(|_: RpcContext| repair()).no_display(), + ) + .no_display() + .with_about("about.repair-disk-corruption"), ) } diff --git a/core/src/disk/fsck/ext4.rs b/core/src/disk/fsck/ext4.rs index 534efd12d..696a806fc 100644 --- a/core/src/disk/fsck/ext4.rs +++ b/core/src/disk/fsck/ext4.rs @@ -4,6 +4,7 @@ use std::path::Path; use color_eyre::eyre::eyre; use futures::FutureExt; use futures::future::BoxFuture; +use rust_i18n::t; use tokio::process::Command; use tracing::instrument; @@ -62,33 +63,31 @@ async fn e2fsck_runner( let e2fsck_stderr = String::from_utf8(e2fsck_out.stderr)?; let code = e2fsck_out.status.code().ok_or_else(|| { Error::new( - eyre!("e2fsck: process terminated by signal"), + eyre!("{}", t!("disk.fsck.process-terminated-by-signal")), crate::ErrorKind::DiskManagement, ) })?; if code & 4 != 0 { tracing::error!( - "some filesystem errors NOT corrected on {}:\n{}", - logicalname.as_ref().display(), - e2fsck_stderr, + "{}", + t!("disk.fsck.errors-not-corrected", device = logicalname.as_ref().display(), stderr = e2fsck_stderr), ); } else if code & 1 != 0 { tracing::warn!( - "filesystem errors corrected on {}:\n{}", - logicalname.as_ref().display(), - e2fsck_stderr, + "{}", + t!("disk.fsck.errors-corrected", device = logicalname.as_ref().display(), stderr = e2fsck_stderr), ); } if code < 8 { if code & 2 != 0 { - tracing::warn!("reboot required"); + tracing::warn!("{}", t!("disk.fsck.reboot-required")); Ok(RequiresReboot(true)) } else { Ok(RequiresReboot(false)) } } else { Err(Error::new( - eyre!("e2fsck: {}", e2fsck_stderr), + eyre!("{}", t!("disk.fsck.e2fsck-error", stderr = e2fsck_stderr)), crate::ErrorKind::DiskManagement, )) } diff --git a/core/src/disk/main.rs b/core/src/disk/main.rs index af4c6e354..349cb045e 100644 --- a/core/src/disk/main.rs +++ b/core/src/disk/main.rs @@ -2,6 +2,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use color_eyre::eyre::eyre; +use imbl_value::InternedString; +use rust_i18n::t; use tokio::process::Command; use tracing::instrument; @@ -20,10 +22,10 @@ pub const MAIN_FS_SIZE: FsSize = FsSize::Gigabytes(8); #[instrument(skip_all)] pub async fn create( disks: &I, - pvscan: &BTreeMap>, + pvscan: &BTreeMap>, datadir: impl AsRef, password: Option<&str>, -) -> Result +) -> Result where for<'a> &'a I: IntoIterator, P: AsRef, @@ -37,9 +39,9 @@ where #[instrument(skip_all)] pub async fn create_pool( disks: &I, - pvscan: &BTreeMap>, + pvscan: &BTreeMap>, encrypted: bool, -) -> Result +) -> Result where for<'a> &'a I: IntoIterator, P: AsRef, @@ -79,7 +81,7 @@ where cmd.arg(disk.as_ref()); } cmd.invoke(crate::ErrorKind::DiskManagement).await?; - Ok(guid) + Ok(guid.into()) } #[derive(Debug, Clone, Copy)] @@ -224,7 +226,7 @@ pub async fn import>( .is_none() { return Err(Error::new( - eyre!("StartOS disk not found."), + eyre!("{}", t!("disk.main.disk-not-found")), crate::ErrorKind::DiskNotAvailable, )); } @@ -234,7 +236,7 @@ pub async fn import>( .any(|id| id == guid) { return Err(Error::new( - eyre!("A StartOS disk was found, but it is not the correct disk for this device."), + eyre!("{}", t!("disk.main.incorrect-disk")), crate::ErrorKind::IncorrectDisk, )); } diff --git a/core/src/disk/mod.rs b/core/src/disk/mod.rs index 7857dbdca..aea0ad9a3 100644 --- a/core/src/disk/mod.rs +++ b/core/src/disk/mod.rs @@ -25,6 +25,8 @@ pub struct OsPartitionInfo { pub bios: Option, pub boot: PathBuf, pub root: PathBuf, + #[serde(skip)] // internal use only + pub data: Option, } impl OsPartitionInfo { pub fn contains(&self, logicalname: impl AsRef) -> bool { @@ -49,7 +51,7 @@ pub fn disk() -> ParentHandler { from_fn_async(list) .with_display_serializable() .with_custom_display_fn(|handle, result| display_disk_info(handle.params, result)) - .with_about("List disk info") + .with_about("about.list-disk-info") .with_call_remote::(), ) .subcommand("repair", from_fn_async(|_: C| repair()).no_cli()) @@ -58,7 +60,7 @@ pub fn disk() -> ParentHandler { CallRemoteHandler::::new( from_fn_async(|_: RpcContext| repair()) .no_display() - .with_about("Repair disk in the event of corruption"), + .with_about("about.repair-disk-corruption"), ), ) } diff --git a/core/src/disk/mount/util.rs b/core/src/disk/mount/util.rs index 46fc27890..8586f4a03 100644 --- a/core/src/disk/mount/util.rs +++ b/core/src/disk/mount/util.rs @@ -23,9 +23,8 @@ pub async fn bind, P1: AsRef>( read_only: bool, ) -> Result<(), Error> { tracing::info!( - "Binding {} to {}", - src.as_ref().display(), - dst.as_ref().display() + "{}", + t!("disk.mount.binding", src = src.as_ref().display(), dst = dst.as_ref().display()) ); if is_mountpoint(&dst).await? { unmount(dst.as_ref(), true).await?; diff --git a/core/src/disk/util.rs b/core/src/disk/util.rs index 596b31f2b..fff4264a5 100644 --- a/core/src/disk/util.rs +++ b/core/src/disk/util.rs @@ -20,9 +20,9 @@ use super::mount::guard::TmpMountGuard; use crate::disk::OsPartitionInfo; use crate::disk::mount::guard::GenericMountGuard; use crate::hostname::Hostname; +use crate::prelude::*; use crate::util::Invoke; use crate::util::serde::IoFormat; -use crate::{Error, ResultExt as _}; #[derive(Clone, Copy, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -40,7 +40,7 @@ pub struct DiskInfo { pub model: Option, pub partitions: Vec, pub capacity: u64, - pub guid: Option, + pub guid: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -51,7 +51,7 @@ pub struct PartitionInfo { pub capacity: u64, pub used: Option, pub start_os: BTreeMap, - pub guid: Option, + pub guid: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -95,7 +95,7 @@ pub async fn get_vendor>(path: P) -> Result, Error Path::new(SYS_BLOCK_PATH) .join(path.as_ref().strip_prefix("/dev").map_err(|_| { Error::new( - eyre!("not a canonical block device"), + eyre!("{}", t!("disk.util.not-canonical-block-device")), crate::ErrorKind::BlockDevice, ) })?) @@ -118,7 +118,7 @@ pub async fn get_model>(path: P) -> Result, Error> Path::new(SYS_BLOCK_PATH) .join(path.as_ref().strip_prefix("/dev").map_err(|_| { Error::new( - eyre!("not a canonical block device"), + eyre!("{}", t!("disk.util.not-canonical-block-device")), crate::ErrorKind::BlockDevice, ) })?) @@ -215,7 +215,7 @@ pub async fn get_percentage>(path: P) -> Result { } #[instrument(skip_all)] -pub async fn pvscan() -> Result>, Error> { +pub async fn pvscan() -> Result>, Error> { let pvscan_out = Command::new("pvscan") .invoke(crate::ErrorKind::DiskManagement) .await?; @@ -259,6 +259,31 @@ pub async fn recovery_info( Ok(res) } +/// Returns the canonical path of the source device for a given mount point, +/// or None if the mount point doesn't exist or isn't mounted. +#[instrument(skip_all)] +pub async fn get_mount_source(mountpoint: impl AsRef) -> Result, Error> { + let mounts_content = tokio::fs::read_to_string("/proc/mounts") + .await + .with_ctx(|_| (crate::ErrorKind::Filesystem, "/proc/mounts"))?; + + let mountpoint = mountpoint.as_ref(); + for line in mounts_content.lines() { + let mut parts = line.split_whitespace(); + let source = parts.next(); + let mount = parts.next(); + if let (Some(source), Some(mount)) = (source, mount) { + if Path::new(mount) == mountpoint { + // Try to canonicalize the source path + if let Ok(canonical) = tokio::fs::canonicalize(source).await { + return Ok(Some(canonical)); + } + } + } + } + Ok(None) +} + #[instrument(skip_all)] pub async fn list(os: &OsPartitionInfo) -> Result, Error> { struct DiskIndex { @@ -374,23 +399,53 @@ async fn disk_info(disk: PathBuf) -> DiskInfo { .await .map_err(|e| { tracing::warn!( - "Could not get partition table of {}: {}", - disk.display(), - e.source + "{}", + t!( + "disk.util.could-not-get-partition-table", + disk = disk.display(), + error = e.source + ) ) }) .unwrap_or_default(); let vendor = get_vendor(&disk) .await - .map_err(|e| tracing::warn!("Could not get vendor of {}: {}", disk.display(), e.source)) + .map_err(|e| { + tracing::warn!( + "{}", + t!( + "disk.util.could-not-get-vendor", + disk = disk.display(), + error = e.source + ) + ) + }) .unwrap_or_default(); let model = get_model(&disk) .await - .map_err(|e| tracing::warn!("Could not get model of {}: {}", disk.display(), e.source)) + .map_err(|e| { + tracing::warn!( + "{}", + t!( + "disk.util.could-not-get-model", + disk = disk.display(), + error = e.source + ) + ) + }) .unwrap_or_default(); let capacity = get_capacity(&disk) .await - .map_err(|e| tracing::warn!("Could not get capacity of {}: {}", disk.display(), e.source)) + .map_err(|e| { + tracing::warn!( + "{}", + t!( + "disk.util.could-not-get-capacity", + disk = disk.display(), + error = e.source + ) + ) + }) .unwrap_or_default(); DiskInfo { logicalname: disk, @@ -407,21 +462,49 @@ async fn part_info(part: PathBuf) -> PartitionInfo { let mut start_os = BTreeMap::new(); let label = get_label(&part) .await - .map_err(|e| tracing::warn!("Could not get label of {}: {}", part.display(), e.source)) + .map_err(|e| { + tracing::warn!( + "{}", + t!( + "disk.util.could-not-get-label", + part = part.display(), + error = e.source + ) + ) + }) .unwrap_or_default(); let capacity = get_capacity(&part) .await - .map_err(|e| tracing::warn!("Could not get capacity of {}: {}", part.display(), e.source)) + .map_err(|e| { + tracing::warn!( + "{}", + t!( + "disk.util.could-not-get-capacity-part", + part = part.display(), + error = e.source + ) + ) + }) .unwrap_or_default(); let mut used = None; match TmpMountGuard::mount(&BlockDev::new(&part), ReadOnly).await { - Err(e) => tracing::warn!("Could not collect usage information: {}", e.source), + Err(e) => tracing::warn!( + "{}", + t!("disk.util.could-not-collect-usage-info", error = e.source) + ), Ok(mount_guard) => { used = get_used(mount_guard.path()) .await .map_err(|e| { - tracing::warn!("Could not get usage of {}: {}", part.display(), e.source) + tracing::warn!( + "{}", + t!( + "disk.util.could-not-get-usage", + part = part.display(), + error = e.source + ) + ) }) .ok(); match recovery_info(mount_guard.path()).await { @@ -429,11 +512,21 @@ async fn part_info(part: PathBuf) -> PartitionInfo { start_os = a; } Err(e) => { - tracing::error!("Error fetching unencrypted backup metadata: {}", e); + tracing::error!( + "{}", + t!("disk.util.error-fetching-backup-metadata", error = e) + ); } } if let Err(e) = mount_guard.unmount().await { - tracing::error!("Error unmounting partition {}: {}", part.display(), e); + tracing::error!( + "{}", + t!( + "disk.util.error-unmounting-partition", + part = part.display(), + error = e + ) + ); } } } @@ -448,7 +541,7 @@ async fn part_info(part: PathBuf) -> PartitionInfo { } } -fn parse_pvscan_output(pvscan_output: &str) -> BTreeMap> { +fn parse_pvscan_output(pvscan_output: &str) -> BTreeMap> { fn parse_line(line: &str) -> IResult<&str, (&str, Option<&str>)> { let pv_parse = preceded( tag(" PV "), @@ -471,10 +564,10 @@ fn parse_pvscan_output(pvscan_output: &str) -> BTreeMap> for entry in entries { match parse_line(entry) { Ok((_, (pv, vg))) => { - ret.insert(PathBuf::from(pv), vg.map(|s| s.to_owned())); + ret.insert(PathBuf::from(pv), vg.map(InternedString::intern)); } Err(_) => { - tracing::warn!("Failed to parse pvscan output line: {}", entry); + tracing::warn!("{}", t!("disk.util.failed-to-parse-pvscan", line = entry)); } } } diff --git a/core/src/error.rs b/core/src/error.rs index f8554e378..83518c8a0 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -9,6 +9,7 @@ use rpc_toolkit::reqwest; use rpc_toolkit::yajrc::{ INVALID_PARAMS_ERROR, INVALID_REQUEST_ERROR, METHOD_NOT_FOUND_ERROR, PARSE_ERROR, RpcError, }; +use rust_i18n::t; use serde::{Deserialize, Serialize}; use tokio::task::JoinHandle; use tokio_rustls::rustls; @@ -97,95 +98,97 @@ pub enum ErrorKind { InstallFailed = 76, UpdateFailed = 77, Smtp = 78, + SetSysInfo = 79, } impl ErrorKind { - pub fn as_str(&self) -> &'static str { + pub fn as_str(&self) -> String { use ErrorKind::*; match self { - Unknown => "Unknown Error", - Filesystem => "Filesystem I/O Error", - Docker => "Docker Error", - ConfigSpecViolation => "Config Spec Violation", - ConfigRulesViolation => "Config Rules Violation", - NotFound => "Not Found", - IncorrectPassword => "Incorrect Password", - VersionIncompatible => "Version Incompatible", - Network => "Network Error", - Registry => "Registry Error", - Serialization => "Serialization Error", - Deserialization => "Deserialization Error", - Utf8 => "UTF-8 Parse Error", - ParseVersion => "Version Parsing Error", - IncorrectDisk => "Incorrect Disk", - // Nginx => "Nginx Error", - Dependency => "Dependency Error", - ParseS9pk => "S9PK Parsing Error", - ParseUrl => "URL Parsing Error", - DiskNotAvailable => "Disk Not Available", - BlockDevice => "Block Device Error", - InvalidOnionAddress => "Invalid Onion Address", - Pack => "Pack Error", - ValidateS9pk => "S9PK Validation Error", - DiskCorrupted => "Disk Corrupted", // Remove - Tor => "Tor Daemon Error", - ConfigGen => "Config Generation Error", - ParseNumber => "Number Parsing Error", - Database => "Database Error", - InvalidId => "Invalid ID", - InvalidSignature => "Invalid Signature", - Backup => "Backup Error", - Restore => "Restore Error", - Authorization => "Unauthorized", - AutoConfigure => "Auto-Configure Error", - Action => "Action Failed", - RateLimited => "Rate Limited", - InvalidRequest => "Invalid Request", - MigrationFailed => "Migration Failed", - Uninitialized => "Uninitialized", - ParseNetAddress => "Net Address Parsing Error", - ParseSshKey => "SSH Key Parsing Error", - SoundError => "Sound Interface Error", - ParseTimestamp => "Timestamp Parsing Error", - ParseSysInfo => "System Info Parsing Error", - Wifi => "WiFi Internal Error", - Journald => "Journald Error", - DiskManagement => "Disk Management Error", - OpenSsl => "OpenSSL Internal Error", - PasswordHashGeneration => "Password Hash Generation Error", - DiagnosticMode => "Server is in Diagnostic Mode", - ParseDbField => "Database Field Parse Error", - Duplicate => "Duplication Error", - MultipleErrors => "Multiple Errors", - Incoherent => "Incoherent", - InvalidBackupTargetId => "Invalid Backup Target ID", - ProductKeyMismatch => "Incompatible Product Keys", - LanPortConflict => "Incompatible LAN Port Configuration", - Javascript => "Javascript Engine Error", - Pem => "PEM Encoding Error", - TLSInit => "TLS Backend Initialization Error", - Ascii => "ASCII Parse Error", - MissingHeader => "Missing Header", - Grub => "Grub Error", - Systemd => "Systemd Error", - OpenSsh => "OpenSSH Error", - Zram => "Zram Error", - Lshw => "LSHW Error", - CpuSettings => "CPU Settings Error", - Firmware => "Firmware Error", - Timeout => "Timeout Error", - Lxc => "LXC Error", - Cancelled => "Cancelled", - Git => "Git Error", - DBus => "DBus Error", - InstallFailed => "Install Failed", - UpdateFailed => "Update Failed", - Smtp => "SMTP Error", - } + Unknown => t!("error.unknown"), + Filesystem => t!("error.filesystem"), + Docker => t!("error.docker"), + ConfigSpecViolation => t!("error.config-spec-violation"), + ConfigRulesViolation => t!("error.config-rules-violation"), + NotFound => t!("error.not-found"), + IncorrectPassword => t!("error.incorrect-password"), + VersionIncompatible => t!("error.version-incompatible"), + Network => t!("error.network"), + Registry => t!("error.registry"), + Serialization => t!("error.serialization"), + Deserialization => t!("error.deserialization"), + Utf8 => t!("error.utf8"), + ParseVersion => t!("error.parse-version"), + IncorrectDisk => t!("error.incorrect-disk"), + // Nginx => t!("error.nginx"), + Dependency => t!("error.dependency"), + ParseS9pk => t!("error.parse-s9pk"), + ParseUrl => t!("error.parse-url"), + DiskNotAvailable => t!("error.disk-not-available"), + BlockDevice => t!("error.block-device"), + InvalidOnionAddress => t!("error.invalid-onion-address"), + Pack => t!("error.pack"), + ValidateS9pk => t!("error.validate-s9pk"), + DiskCorrupted => t!("error.disk-corrupted"), // Remove + Tor => t!("error.tor"), + ConfigGen => t!("error.config-gen"), + ParseNumber => t!("error.parse-number"), + Database => t!("error.database"), + InvalidId => t!("error.invalid-id"), + InvalidSignature => t!("error.invalid-signature"), + Backup => t!("error.backup"), + Restore => t!("error.restore"), + Authorization => t!("error.authorization"), + AutoConfigure => t!("error.auto-configure"), + Action => t!("error.action"), + RateLimited => t!("error.rate-limited"), + InvalidRequest => t!("error.invalid-request"), + MigrationFailed => t!("error.migration-failed"), + Uninitialized => t!("error.uninitialized"), + ParseNetAddress => t!("error.parse-net-address"), + ParseSshKey => t!("error.parse-ssh-key"), + SoundError => t!("error.sound-error"), + ParseTimestamp => t!("error.parse-timestamp"), + ParseSysInfo => t!("error.parse-sys-info"), + Wifi => t!("error.wifi"), + Journald => t!("error.journald"), + DiskManagement => t!("error.disk-management"), + OpenSsl => t!("error.openssl"), + PasswordHashGeneration => t!("error.password-hash-generation"), + DiagnosticMode => t!("error.diagnostic-mode"), + ParseDbField => t!("error.parse-db-field"), + Duplicate => t!("error.duplicate"), + MultipleErrors => t!("error.multiple-errors"), + Incoherent => t!("error.incoherent"), + InvalidBackupTargetId => t!("error.invalid-backup-target-id"), + ProductKeyMismatch => t!("error.product-key-mismatch"), + LanPortConflict => t!("error.lan-port-conflict"), + Javascript => t!("error.javascript"), + Pem => t!("error.pem"), + TLSInit => t!("error.tls-init"), + Ascii => t!("error.ascii"), + MissingHeader => t!("error.missing-header"), + Grub => t!("error.grub"), + Systemd => t!("error.systemd"), + OpenSsh => t!("error.openssh"), + Zram => t!("error.zram"), + Lshw => t!("error.lshw"), + CpuSettings => t!("error.cpu-settings"), + Firmware => t!("error.firmware"), + Timeout => t!("error.timeout"), + Lxc => t!("error.lxc"), + Cancelled => t!("error.cancelled"), + Git => t!("error.git"), + DBus => t!("error.dbus"), + InstallFailed => t!("error.install-failed"), + UpdateFailed => t!("error.update-failed"), + Smtp => t!("error.smtp"), + SetSysInfo => t!("error.set-sys-info"), + }.to_string() } } impl Display for ErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) + write!(f, "{}", &self.as_str()) } } @@ -199,7 +202,7 @@ pub struct Error { impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {:#}", self.kind.as_str(), self.source) + write!(f, "{}: {:#}", &self.kind.as_str(), self.source) } } impl Debug for Error { @@ -207,7 +210,7 @@ impl Debug for Error { write!( f, "{}: {:?}", - self.kind.as_str(), + &self.kind.as_str(), self.debug.as_ref().unwrap_or(&self.source) ) } diff --git a/core/src/init.rs b/core/src/init.rs index 741ca3e2d..39680015e 100644 --- a/core/src/init.rs +++ b/core/src/init.rs @@ -81,26 +81,28 @@ impl InitPhases { pub fn new(handle: &FullProgressTracker) -> Self { Self { preinit: if Path::new("/media/startos/config/preinit.sh").exists() { - Some(handle.add_phase("Running preinit.sh".into(), Some(5))) + Some(handle.add_phase(t!("init.running-preinit").into(), Some(5))) } else { None }, - local_auth: handle.add_phase("Enabling local authentication".into(), Some(1)), - load_database: handle.add_phase("Loading database".into(), Some(5)), - load_ssh_keys: handle.add_phase("Loading SSH Keys".into(), Some(1)), - start_net: handle.add_phase("Starting network controller".into(), Some(1)), - mount_logs: handle.add_phase("Switching logs to write to data drive".into(), Some(1)), - load_ca_cert: handle.add_phase("Loading CA certificate".into(), Some(1)), - load_wifi: handle.add_phase("Loading WiFi configuration".into(), Some(1)), - init_tmp: handle.add_phase("Initializing temporary files".into(), Some(1)), - set_governor: handle.add_phase("Setting CPU performance profile".into(), Some(1)), - sync_clock: handle.add_phase("Synchronizing system clock".into(), Some(10)), - enable_zram: handle.add_phase("Enabling ZRAM".into(), Some(1)), - update_server_info: handle.add_phase("Updating server info".into(), Some(1)), - launch_service_network: handle.add_phase("Launching service intranet".into(), Some(1)), - validate_db: handle.add_phase("Validating database".into(), Some(1)), + local_auth: handle.add_phase(t!("init.enabling-local-auth").into(), Some(1)), + load_database: handle.add_phase(t!("init.loading-database").into(), Some(5)), + load_ssh_keys: handle.add_phase(t!("init.loading-ssh-keys").into(), Some(1)), + start_net: handle.add_phase(t!("init.starting-network-controller").into(), Some(1)), + mount_logs: handle.add_phase(t!("init.switching-logs-to-data-drive").into(), Some(1)), + load_ca_cert: handle.add_phase(t!("init.loading-ca-certificate").into(), Some(1)), + load_wifi: handle.add_phase(t!("init.loading-wifi-configuration").into(), Some(1)), + init_tmp: handle.add_phase(t!("init.initializing-temporary-files").into(), Some(1)), + set_governor: handle + .add_phase(t!("init.setting-cpu-performance-profile").into(), Some(1)), + sync_clock: handle.add_phase(t!("init.synchronizing-system-clock").into(), Some(10)), + enable_zram: handle.add_phase(t!("init.enabling-zram").into(), Some(1)), + update_server_info: handle.add_phase(t!("init.updating-server-info").into(), Some(1)), + launch_service_network: handle + .add_phase(t!("init.launching-service-intranet").into(), Some(1)), + validate_db: handle.add_phase(t!("init.validating-database").into(), Some(1)), postinit: if Path::new("/media/startos/config/postinit.sh").exists() { - Some(handle.add_phase("Running postinit.sh".into(), Some(5))) + Some(handle.add_phase(t!("init.running-postinit").into(), Some(5))) } else { None }, @@ -127,7 +129,14 @@ pub async fn run_script>(path: P, mut progress: PhaseProgressTrac } .await { - tracing::error!("Error Running {}: {}", script.display(), e); + tracing::error!( + "{}", + t!( + "init.error-running-script", + script = script.display(), + error = e + ) + ); tracing::debug!("{:?}", e); } progress.complete(); @@ -230,6 +239,7 @@ pub async fn init( .arg("-R") .arg("+C") .arg("/var/log/journal") + .env("LANG", "C.UTF-8") .invoke(ErrorKind::Filesystem) .await { @@ -314,14 +324,17 @@ pub async fn init( { Some(governor) } else { - tracing::warn!("CPU Governor \"{governor}\" Not Available"); + tracing::warn!( + "{}", + t!("init.cpu-governor-not-available", governor = governor) + ); None } } else { cpupower::get_preferred_governor().await? }; if let Some(governor) = governor { - tracing::info!("Setting CPU Governor to \"{governor}\""); + tracing::info!("{}", t!("init.setting-cpu-governor", governor = governor)); cpupower::set_governor(governor).await?; } set_governor.complete(); @@ -349,14 +362,14 @@ pub async fn init( } } if !ntp_synced { - tracing::warn!("Timed out waiting for system time to synchronize"); + tracing::warn!("{}", t!("init.clock-sync-timeout")); } sync_clock.complete(); enable_zram.start(); if server_info.as_zram().de()? { crate::system::enable_zram().await?; - tracing::info!("Enabled ZRAM"); + tracing::info!("{}", t!("init.enabled-zram")); } enable_zram.complete(); @@ -404,7 +417,7 @@ pub async fn init( run_script("/media/startos/config/postinit.sh", progress).await; } - tracing::info!("System initialized."); + tracing::info!("{}", t!("init.system-initialized")); Ok(InitResult { net_ctrl, @@ -416,30 +429,30 @@ pub fn init_api() -> ParentHandler { ParentHandler::new() .subcommand( "logs", - crate::system::logs::().with_about("Disply OS logs"), + crate::system::logs::().with_about("about.display-os-logs"), ) .subcommand( "logs", from_fn_async(crate::logs::cli_logs::) .no_display() - .with_about("Display OS logs"), + .with_about("about.display-os-logs"), ) .subcommand( "kernel-logs", - crate::system::kernel_logs::().with_about("Display kernel logs"), + crate::system::kernel_logs::().with_about("about.display-kernel-logs"), ) .subcommand( "kernel-logs", from_fn_async(crate::logs::cli_logs::) .no_display() - .with_about("Display kernel logs"), + .with_about("about.display-kernel-logs"), ) .subcommand("subscribe", from_fn_async(init_progress).no_cli()) .subcommand( "subscribe", from_fn_async(cli_init_progress) .no_display() - .with_about("Get initialization progress"), + .with_about("about.get-initialization-progress"), ) } @@ -495,7 +508,7 @@ pub async fn init_progress(ctx: InitContext) -> Result { ); if let Err(e) = ws.close_result(res.map(|_| "complete")).await { - tracing::error!("error closing init progress websocket: {e}"); + tracing::error!("{}", t!("init.error-closing-websocket", error = e)); tracing::debug!("{e:?}"); } }, @@ -526,7 +539,7 @@ pub async fn cli_init_progress( .await?, )?; let mut ws = ctx.ws_continuation(res.guid).await?; - let mut bar = PhasedProgressBar::new("Initializing..."); + let mut bar = PhasedProgressBar::new(&t!("init.initializing")); while let Some(msg) = ws.try_next().await.with_kind(ErrorKind::Network)? { if let tokio_tungstenite::tungstenite::Message::Text(msg) = msg { bar.update(&serde_json::from_str(&msg).with_kind(ErrorKind::Deserialization)?); diff --git a/core/src/install/mod.rs b/core/src/install/mod.rs index 261bafeb4..c610505d9 100644 --- a/core/src/install/mod.rs +++ b/core/src/install/mod.rs @@ -283,6 +283,7 @@ pub async fn sideload( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CancelInstallParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, } @@ -299,7 +300,9 @@ pub fn cancel_install( #[derive(Deserialize, Serialize, Parser)] pub struct QueryPackageParams { + #[arg(help = "help.arg.package-id")] id: PackageId, + #[arg(help = "help.arg.version-range")] version: Option, } @@ -357,6 +360,7 @@ impl FromArgMatches for CliInstallParams { #[derive(Deserialize, Serialize, Parser, TS)] #[ts(export)] pub struct InstalledVersionParams { + #[arg(help = "help.arg.package-id")] id: PackageId, } @@ -516,11 +520,12 @@ pub async fn cli_install( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct UninstallParams { + #[arg(help = "help.arg.package-id")] id: PackageId, - #[arg(long, help = "Do not delete the service data")] + #[arg(long, help = "help.arg.soft-uninstall")] #[serde(default)] soft: bool, - #[arg(long, help = "Ignore errors in service uninit script")] + #[arg(long, help = "help.arg.force-uninstall")] #[serde(default)] force: bool, } diff --git a/core/src/lib.rs b/core/src/lib.rs index a371fb49f..756f880ac 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,5 +1,7 @@ use const_format::formatcp; +rust_i18n::i18n!("locales", fallback = ["en_US"]); + pub const DATA_DIR: &str = "/media/startos/data"; pub const MAIN_DATA: &str = formatcp!("{DATA_DIR}/main"); pub const PACKAGE_DATA: &str = formatcp!("{DATA_DIR}/package-data"); @@ -8,7 +10,7 @@ pub use std::env::consts::ARCH; lazy_static::lazy_static! { pub static ref PLATFORM: String = { if let Ok(platform) = std::fs::read_to_string("/usr/lib/startos/PLATFORM.txt") { - platform + platform.trim().to_string() } else { ARCH.to_string() } @@ -18,6 +20,17 @@ lazy_static::lazy_static! { }; } +/// Map a platform string to its architecture +pub fn platform_to_arch(platform: &str) -> &str { + if let Some(arch) = platform.strip_suffix("-nonfree") { + return arch; + } + match platform { + "raspberrypi" | "rockchip64" => "aarch64", + _ => platform, + } +} + mod cap { #![allow(non_upper_case_globals)] @@ -97,6 +110,7 @@ use crate::util::serde::{HandlerExtSerde, WithIoFormat, display_serializable}; #[command(rename_all = "kebab-case")] #[ts(export)] pub struct EchoParams { + #[arg(help = "help.arg.echo-message")] message: String, } @@ -122,80 +136,63 @@ pub fn main_api() -> ParentHandler { let mut api = ParentHandler::new() .subcommand( "git-info", - from_fn(|_: C| version::git_info()).with_about("Display the githash of StartOS CLI"), + from_fn(|_: C| version::git_info()).with_about("about.display-githash"), ) .subcommand( "echo", from_fn(echo::) .with_metadata("authenticated", Value::Bool(false)) - .with_about("Echo a message") + .with_about("about.echo-message") .with_call_remote::(), ) .subcommand( "state", from_fn(|_: RpcContext| Ok::<_, Error>(ApiState::Running)) .with_metadata("authenticated", Value::Bool(false)) - .with_about("Display the API that is currently serving") + .with_about("about.display-current-api") .with_call_remote::(), ) .subcommand( "state", from_fn(|_: InitContext| Ok::<_, Error>(ApiState::Initializing)) .with_metadata("authenticated", Value::Bool(false)) - .with_about("Display the API that is currently serving") + .with_about("about.display-current-api") .with_call_remote::(), ) .subcommand( "state", from_fn(|_: DiagnosticContext| Ok::<_, Error>(ApiState::Error)) .with_metadata("authenticated", Value::Bool(false)) - .with_about("Display the API that is currently serving") + .with_about("about.display-current-api") .with_call_remote::(), ) - .subcommand( - "server", - server::() - .with_about("Commands related to the server i.e. restart, update, and shutdown"), - ) + .subcommand("server", server::().with_about("about.commands-server")) .subcommand( "package", - package::().with_about("Commands related to packages"), + package::().with_about("about.commands-packages"), ) .subcommand( "net", - net::net_api::().with_about("Network commands related to tor and dhcp"), + net::net_api::().with_about("about.network-commands"), ) .subcommand( "auth", - auth::auth::() - .with_about("Commands related to Authentication i.e. login, logout"), - ) - .subcommand( - "db", - db::db::().with_about("Commands to interact with the db i.e. dump, put, apply"), - ) - .subcommand( - "ssh", - ssh::ssh::() - .with_about("Commands for interacting with ssh keys i.e. add, delete, list"), + auth::auth::().with_about("about.commands-authentication"), ) + .subcommand("db", db::db::().with_about("about.commands-db")) + .subcommand("ssh", ssh::ssh::().with_about("about.commands-ssh-keys")) .subcommand( "wifi", - net::wifi::wifi::() - .with_about("Commands related to wifi networks i.e. add, connect, delete"), - ) - .subcommand( - "disk", - disk::disk::().with_about("Commands for listing disk info and repairing"), + net::wifi::wifi::().with_about("about.commands-wifi"), ) + .subcommand("disk", disk::disk::().with_about("about.commands-disk")) .subcommand( "notification", - notifications::notification::().with_about("Create, delete, or list notifications"), + notifications::notification::().with_about("about.commands-notifications"), ) .subcommand( "backup", - backup::backup::() - .with_about("Commands related to backup creation and backup targets"), + backup::backup::().with_about("about.commands-backup"), ) .subcommand( "registry", @@ -206,7 +203,7 @@ pub fn main_api() -> ParentHandler { ) .subcommand( "registry", - registry::registry_api::().with_about("Commands related to the registry"), + registry::registry_api::().with_about("about.commands-registry"), ) .subcommand( "tunnel", @@ -215,41 +212,46 @@ pub fn main_api() -> ParentHandler { ) .subcommand( "tunnel", - tunnel::api::tunnel_api::().with_about("Commands related to StartTunnel"), - ) - .subcommand( - "s9pk", - s9pk::rpc::s9pk().with_about("Commands for interacting with s9pk files"), + tunnel::api::tunnel_api::().with_about("about.commands-tunnel"), ) + .subcommand("s9pk", s9pk::rpc::s9pk().with_about("about.commands-s9pk")) .subcommand( "util", - util::rpc::util::().with_about("Command for calculating the blake3 hash of a file"), + util::rpc::util::().with_about("about.command-blake3-hash"), ) .subcommand( "init-key", from_fn_async(developer::init) .no_display() - .with_about("Create developer key if it doesn't exist"), + .with_about("about.create-developer-key"), ) .subcommand( "pubkey", - from_fn_blocking(developer::pubkey) - .with_about("Get public key for developer private key"), + from_fn_blocking(developer::pubkey).with_about("about.get-developer-pubkey"), ) .subcommand( "diagnostic", - diagnostic::diagnostic::() - .with_about("Commands to display logs, restart the server, etc"), + diagnostic::diagnostic::().with_about("about.commands-diagnostic"), ) - .subcommand("init", init::init_api::()) - .subcommand("setup", setup::setup::()) .subcommand( - "install", - os_install::install::() - .with_about("Commands to list disk info, install StartOS, and reboot"), + "init", + init::init_api::().with_about("about.commands-init"), + ) + .subcommand( + "setup", + setup::setup::().with_about("about.commands-setup"), ); if &*PLATFORM != "raspberrypi" { - api = api.subcommand("kiosk", kiosk::()); + api = api.subcommand("kiosk", kiosk::().with_about("about.commands-kiosk")); + } + #[cfg(target_os = "linux")] + { + api = api.subcommand( + "flash-os", + from_fn_async(os_install::cli_install_os) + .no_display() + .with_about("about.flash-startos"), + ); } api } @@ -263,29 +265,32 @@ pub fn server() -> ParentHandler { .with_custom_display_fn(|handle, result| { system::display_time(handle.params, result) }) - .with_about("Display current time and server uptime") - .with_call_remote::() + .with_about("about.display-time-uptime") + .with_call_remote::(), ) .subcommand( "experimental", - system::experimental::() - .with_about("Commands related to configuring experimental options such as zram and cpu governor"), + system::experimental::().with_about("about.commands-experimental"), ) .subcommand( "logs", - system::logs::().with_about("Display OS logs"), + system::logs::().with_about("about.display-os-logs"), ) .subcommand( "logs", - from_fn_async(logs::cli_logs::).no_display().with_about("Display OS logs"), + from_fn_async(logs::cli_logs::) + .no_display() + .with_about("about.display-os-logs"), ) .subcommand( "kernel-logs", - system::kernel_logs::().with_about("Display Kernel logs"), + system::kernel_logs::().with_about("about.display-kernel-logs"), ) .subcommand( "kernel-logs", - from_fn_async(logs::cli_logs::).no_display().with_about("Display Kernel logs"), + from_fn_async(logs::cli_logs::) + .no_display() + .with_about("about.display-kernel-logs"), ) .subcommand( "metrics", @@ -293,35 +298,31 @@ pub fn server() -> ParentHandler { .root_handler( from_fn_async(system::metrics) .with_display_serializable() - .with_about("Display information about the server i.e. temperature, RAM, CPU, and disk usage") - .with_call_remote::() - ) - .subcommand( - "follow", - from_fn_async(system::metrics_follow) - .no_cli() + .with_about("about.display-server-metrics") + .with_call_remote::(), ) + .subcommand("follow", from_fn_async(system::metrics_follow).no_cli()), ) .subcommand( "shutdown", from_fn_async(shutdown::shutdown) .no_display() - .with_about("Shutdown the server") - .with_call_remote::() + .with_about("about.shutdown-server") + .with_call_remote::(), ) .subcommand( "restart", from_fn_async(shutdown::restart) .no_display() - .with_about("Restart the server") - .with_call_remote::() + .with_about("about.restart-server") + .with_call_remote::(), ) .subcommand( "rebuild", from_fn_async(shutdown::rebuild) .no_display() - .with_about("Teardown and rebuild service containers") - .with_call_remote::() + .with_about("about.teardown-rebuild-containers") + .with_call_remote::(), ) .subcommand( "update", @@ -331,7 +332,9 @@ pub fn server() -> ParentHandler { ) .subcommand( "update", - from_fn_async(update::cli_update_system).no_display().with_about("Check a given registry for StartOS updates and update if available"), + from_fn_async(update::cli_update_system) + .no_display() + .with_about("about.check-update-startos"), ) .subcommand( "update-firmware", @@ -346,37 +349,55 @@ pub fn server() -> ParentHandler { .with_custom_display_fn(|_handle, result| { Ok(firmware::display_firmware_update_result(result)) }) - .with_about("Update the mainboard's firmware to the latest firmware available in this version of StartOS if available. Note: This command does not reach out to the Internet") - .with_call_remote::() + .with_about("about.update-firmware") + .with_call_remote::(), ) .subcommand( "set-smtp", from_fn_async(system::set_system_smtp) .no_display() - .with_about("Set system smtp server and credentials") - .with_call_remote::() + .with_about("about.set-smtp") + .with_call_remote::(), ) .subcommand( - "test-smtp", + "test-smtp", from_fn_async(system::test_smtp) .no_display() - .with_about("Send test email using provided smtp server and credentials") - .with_call_remote::() + .with_about("about.test-smtp") + .with_call_remote::(), ) .subcommand( "clear-smtp", from_fn_async(system::clear_system_smtp) .no_display() - .with_about("Remove system smtp server and credentials") - .with_call_remote::() - ).subcommand("host", net::host::server_host_api::().with_about("Commands for modifying the host for the system ui")) + .with_about("about.clear-smtp") + .with_call_remote::(), + ) + .subcommand( + "host", + net::host::server_host_api::().with_about("about.commands-host-system-ui"), + ) + .subcommand( + "set-keyboard", + from_fn_async(system::set_keyboard) + .no_display() + .with_about("about.set-keyboard") + .with_call_remote::(), + ) + .subcommand( + "set-language", + from_fn_async(system::set_language) + .no_display() + .with_about("about.set-language") + .with_call_remote::(), + ) } pub fn package() -> ParentHandler { ParentHandler::new() .subcommand( "action", - action::action_api::().with_about("Commands to get action input or run an action"), + action::action_api::().with_about("about.commands-action"), ) .subcommand( "install", @@ -394,13 +415,13 @@ pub fn package() -> ParentHandler { "install", from_fn_async_local(install::cli_install) .no_display() - .with_about("Install a package from a marketplace or via sideloading"), + .with_about("about.install-package"), ) .subcommand( "cancel-install", from_fn(install::cancel_install) .no_display() - .with_about("Cancel an install of a package") + .with_about("about.cancel-install-package") .with_call_remote::(), ) .subcommand( @@ -408,21 +429,21 @@ pub fn package() -> ParentHandler { from_fn_async(install::uninstall) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Remove a package") + .with_about("about.remove-package") .with_call_remote::(), ) .subcommand( "list", from_fn_async(install::list) .with_display_serializable() - .with_about("List installed packages") + .with_about("about.list-installed-packages") .with_call_remote::(), ) .subcommand( "installed-version", from_fn_async(install::installed_version) .with_display_serializable() - .with_about("Display installed version for a PackageId") + .with_about("about.display-installed-version") .with_call_remote::(), ) .subcommand( @@ -430,7 +451,7 @@ pub fn package() -> ParentHandler { from_fn_async(control::start) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Start a service") + .with_about("about.start-service") .with_call_remote::(), ) .subcommand( @@ -438,7 +459,7 @@ pub fn package() -> ParentHandler { from_fn_async(control::stop) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Stop a service") + .with_about("about.stop-service") .with_call_remote::(), ) .subcommand( @@ -446,7 +467,7 @@ pub fn package() -> ParentHandler { from_fn_async(control::restart) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Restart a service") + .with_about("about.restart-service") .with_call_remote::(), ) .subcommand( @@ -454,7 +475,7 @@ pub fn package() -> ParentHandler { from_fn_async(service::rebuild) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Rebuild service container") + .with_about("about.rebuild-service-container") .with_call_remote::(), ) .subcommand( @@ -494,35 +515,34 @@ pub fn package() -> ParentHandler { table.print_tty(false)?; Ok(()) }) - .with_about("List information related to the lxc containers i.e. CPU, Memory, Disk") + .with_about("about.list-lxc-container-info") .with_call_remote::(), ) .subcommand("logs", logs::package_logs()) .subcommand( "logs", - logs::package_logs().with_about("Display package logs"), + logs::package_logs().with_about("about.display-package-logs"), ) .subcommand( "logs", from_fn_async(logs::cli_logs::) .no_display() - .with_about("Display package logs"), + .with_about("about.display-package-logs"), ) .subcommand( "backup", - backup::package_backup::() - .with_about("Commands for restoring package(s) from backup"), + backup::package_backup::().with_about("about.commands-restore-backup"), ) .subcommand( "attach", from_fn_async(service::attach) .with_metadata("get_session", Value::Bool(true)) - .with_about("Execute commands within a service container") + .with_about("about.execute-commands-container") .no_cli(), ) .subcommand("attach", from_fn_async(service::cli_attach).no_display()) .subcommand( "host", - net::host::host_api::().with_about("Manage network hosts for a package"), + net::host::host_api::().with_about("about.manage-network-hosts-package"), ) } diff --git a/core/src/logs.rs b/core/src/logs.rs index d4c7c97da..752950911 100644 --- a/core/src/logs.rs +++ b/core/src/logs.rs @@ -232,6 +232,7 @@ pub const SYSTEM_UNIT: &str = "startd"; #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct PackageIdParams { + #[arg(help = "help.arg.package-id")] id: PackageId, } @@ -327,14 +328,14 @@ pub struct LogsParams { #[command(flatten)] #[serde(flatten)] extra: Extra, - #[arg(short = 'l', long = "limit")] + #[arg(short = 'l', long = "limit", help = "help.arg.log-limit")] limit: Option, - #[arg(short = 'c', long = "cursor", conflicts_with = "follow")] + #[arg(short = 'c', long = "cursor", conflicts_with = "follow", help = "help.arg.log-cursor")] cursor: Option, - #[arg(short = 'b', long = "boot")] + #[arg(short = 'b', long = "boot", help = "help.arg.log-boot")] #[serde(default)] boot: Option, - #[arg(short = 'B', long = "before", conflicts_with = "follow")] + #[arg(short = 'B', long = "before", conflicts_with = "follow", help = "help.arg.log-before")] #[serde(default)] before: bool, } @@ -346,7 +347,7 @@ pub struct CliLogsParams { #[command(flatten)] #[serde(flatten)] rpc_params: LogsParams, - #[arg(short = 'f', long = "follow")] + #[arg(short = 'f', long = "follow", help = "help.arg.log-follow")] #[serde(default)] follow: bool, } @@ -554,7 +555,7 @@ pub async fn journalctl( let mut child = follow_cmd.stdout(Stdio::piped()).spawn()?; let out = BufReader::new(child.stdout.take().ok_or_else(|| { - Error::new(eyre!("No stdout available"), crate::ErrorKind::Journald) + Error::new(eyre!("{}", t!("logs.no-stdout-available")), crate::ErrorKind::Journald) })?); let journalctl_entries = LinesStream::new(out.lines()); @@ -700,7 +701,7 @@ pub async fn follow_logs>( RpcContinuation::ws( move |socket| async move { if let Err(e) = ws_handler(first_entry, stream, socket).await { - tracing::error!("Error in log stream: {}", e); + tracing::error!("{}", t!("logs.error-in-log-stream", error = e.to_string())); } }, Duration::from_secs(30), diff --git a/core/src/lxc/mod.rs b/core/src/lxc/mod.rs index bdca44ebe..baffb041e 100644 --- a/core/src/lxc/mod.rs +++ b/core/src/lxc/mod.rs @@ -141,7 +141,7 @@ impl LxcManager { > 0 { return Err(Error::new( - eyre!("rootfs is not empty, refusing to delete"), + eyre!("{}", t!("lxc.mod.rootfs-not-empty")), ErrorKind::InvalidRequest, )); } @@ -249,6 +249,7 @@ impl LxcContainer { .arg("-R") .arg("+C") .arg(&log_mount_point) + .env("LANG", "C.UTF-8") .invoke(ErrorKind::Filesystem) .await { @@ -381,7 +382,7 @@ impl LxcContainer { } if start.elapsed() > CONTAINER_DHCP_TIMEOUT { return Err(Error::new( - eyre!("Timed out waiting for container to acquire DHCP lease"), + eyre!("{}", t!("lxc.mod.dhcp-timeout")), ErrorKind::Timeout, )); } @@ -407,9 +408,12 @@ impl LxcContainer { if !output.status.success() { return Err(Error::new( eyre!( - "Command failed with exit code: {:?} \n Message: {:?}", - output.status.code(), - String::from_utf8(output.stderr) + "{}", + t!( + "lxc.mod.command-failed", + code = format!("{:?}", output.status.code()), + message = format!("{:?}", String::from_utf8(output.stderr)) + ) ), ErrorKind::Docker, )); @@ -437,7 +441,7 @@ impl LxcContainer { > 0 { return Err(Error::new( - eyre!("rootfs is not empty, refusing to delete"), + eyre!("{}", t!("lxc.mod.rootfs-not-empty")), ErrorKind::InvalidRequest, )); } @@ -473,13 +477,19 @@ impl LxcContainer { .await ); return Err(Error::new( - eyre!("timed out waiting for socket"), + eyre!("{}", t!("lxc.mod.socket-timeout")), ErrorKind::Timeout, )); } tokio::time::sleep(Duration::from_millis(100)).await; } - tracing::info!("Connected to socket in {:?}", started.elapsed()); + tracing::info!( + "{}", + t!( + "lxc.mod.connected-to-socket", + elapsed = format!("{:?}", started.elapsed()) + ) + ); Ok(UnixRpcClient::new(sock_path)) } @@ -569,8 +579,11 @@ impl Drop for LxcContainer { fn drop(&mut self) { if !self.exited { tracing::warn!( - "Container {} was ungracefully dropped. Cleaning up dangling containers...", - &**self.guid + "{}", + t!( + "lxc.mod.container-ungracefully-dropped", + container = &**self.guid + ) ); let rootfs = self.rootfs.take(); let guid = std::mem::take(&mut self.guid); @@ -589,16 +602,25 @@ impl Drop for LxcContainer { } .await { - tracing::error!("Error reading logs from crashed container: {e}"); + tracing::error!( + "{}", + t!("lxc.mod.error-reading-crashed-logs", error = e.to_string()) + ); tracing::debug!("{e:?}") } rootfs.unmount(true).await.log_err(); drop(guid); if let Err(e) = manager.gc().await { - tracing::error!("Error cleaning up dangling LXC containers: {e}"); + tracing::error!( + "{}", + t!( + "lxc.mod.error-cleaning-up-containers", + error = e.to_string() + ) + ); tracing::debug!("{e:?}") } else { - tracing::info!("Successfully cleaned up dangling LXC containers"); + tracing::info!("{}", t!("lxc.mod.cleaned-up-containers")); } }); } diff --git a/core/src/main/startbox.rs b/core/src/main/startbox.rs index d322aaeb7..050f02c2f 100644 --- a/core/src/main/startbox.rs +++ b/core/src/main/startbox.rs @@ -11,11 +11,6 @@ fn main() { "$CARGO_MANIFEST_DIR/../web/dist/static/setup-wizard" )) .ok(); - startos::net::static_server::INSTALL_WIZARD_CELL - .set(include_dir::include_dir!( - "$CARGO_MANIFEST_DIR/../web/dist/static/install-wizard" - )) - .ok(); #[cfg(not(feature = "beta"))] startos::db::model::public::DB_UI_SEED_CELL .set(include_str!(concat!( diff --git a/core/src/middleware/auth/local.rs b/core/src/middleware/auth/local.rs index 342e1a882..b723608d3 100644 --- a/core/src/middleware/auth/local.rs +++ b/core/src/middleware/auth/local.rs @@ -40,7 +40,7 @@ impl LocalAuthContext for RpcContext { } fn unauthorized() -> Error { - Error::new(eyre!("UNAUTHORIZED"), crate::ErrorKind::Authorization) + Error::new(eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization) } async fn check_from_header(header: Option<&HeaderValue>) -> Result<(), Error> { diff --git a/core/src/middleware/auth/session.rs b/core/src/middleware/auth/session.rs index 375351ce8..712298b10 100644 --- a/core/src/middleware/auth/session.rs +++ b/core/src/middleware/auth/session.rs @@ -146,7 +146,7 @@ impl HashSessionToken { } } Err(Error::new( - eyre!("UNAUTHORIZED"), + eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization, )) } @@ -221,7 +221,7 @@ impl ValidSessionToken { } } Err(Error::new( - eyre!("UNAUTHORIZED"), + eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization, )) } @@ -244,7 +244,7 @@ impl ValidSessionToken { C::access_sessions(db) .as_idx_mut(session_hash) .ok_or_else(|| { - Error::new(eyre!("UNAUTHORIZED"), crate::ErrorKind::Authorization) + Error::new(eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization) })? .mutate(|s| { s.last_active = Utc::now(); @@ -305,7 +305,7 @@ impl Middleware for SessionAuth { self.rate_limiter.mutate(|(count, time)| { if time.elapsed() < Duration::from_secs(20) && *count >= 3 { Err(Error::new( - eyre!("Please limit login attempts to 3 per 20 seconds."), + eyre!("{}", t!("middleware.auth.rate-limited-login")), crate::ErrorKind::RateLimited, )) } else { diff --git a/core/src/middleware/auth/signature.rs b/core/src/middleware/auth/signature.rs index 996a4d6a5..ff32b4ddb 100644 --- a/core/src/middleware/auth/signature.rs +++ b/core/src/middleware/auth/signature.rs @@ -90,7 +90,7 @@ impl SignatureAuthContext for RpcContext { } Err(Error::new( - eyre!("Key is not authorized"), + eyre!("{}", t!("middleware.auth.key-not-authorized")), ErrorKind::IncorrectPassword, )) } @@ -141,7 +141,7 @@ impl SignatureAuth { let mut cache = self.nonce_cache.lock().await; if cache.values().any(|n| *n == nonce) { return Err(Error::new( - eyre!("replay attack detected"), + eyre!("{}", t!("middleware.auth.replay-attack-detected")), ErrorKind::Authorization, )); } @@ -226,7 +226,7 @@ impl Middleware for SignatureAuth { context.sig_context().await.into_iter().fold( Err(Error::new( - eyre!("no valid signature context available to verify"), + eyre!("{}", t!("middleware.auth.no-valid-sig-context")), ErrorKind::Authorization, )), |acc, x| { @@ -249,7 +249,7 @@ impl Middleware for SignatureAuth { .unwrap_or_else(|e| e.duration().as_secs() as i64 * -1); if (now - commitment.timestamp).abs() > 30 { return Err(Error::new( - eyre!("timestamp not within 30s of now"), + eyre!("{}", t!("middleware.auth.timestamp-not-within-30s")), ErrorKind::InvalidSignature, )); } @@ -347,6 +347,6 @@ pub async fn call_remote>( .with_kind(ErrorKind::Deserialization)? .result } - _ => Err(Error::new(eyre!("unknown content type"), ErrorKind::Network).into()), + _ => Err(Error::new(eyre!("{}", t!("middleware.auth.unknown-content-type")), ErrorKind::Network).into()), } } diff --git a/core/src/middleware/db.rs b/core/src/middleware/db.rs index ec0b94821..f573dbeb5 100644 --- a/core/src/middleware/db.rs +++ b/core/src/middleware/db.rs @@ -2,6 +2,7 @@ use axum::response::Response; use http::HeaderValue; use http::header::InvalidHeaderValue; use rpc_toolkit::{Middleware, RpcRequest, RpcResponse}; +use rust_i18n::t; use serde::Deserialize; use crate::context::RpcContext; @@ -46,7 +47,7 @@ impl Middleware for SyncDb { } .await { - tracing::error!("error writing X-Patch-Sequence header: {e}"); + tracing::error!("{}", t!("middleware.db.error-writing-patch-sequence-header", error = e)); tracing::debug!("{e:?}"); } } diff --git a/core/src/net/acme.rs b/core/src/net/acme.rs index 1d0874c81..8054715af 100644 --- a/core/src/net/acme.rs +++ b/core/src/net/acme.rs @@ -395,7 +395,7 @@ pub fn acme_api() -> ParentHandler { from_fn_async(init) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Setup ACME certificate acquisition") + .with_about("about.setup-acme-certificate-acquisition") .with_call_remote::(), ) .subcommand( @@ -403,7 +403,7 @@ pub fn acme_api() -> ParentHandler { from_fn_async(remove) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Remove ACME certificate acquisition configuration") + .with_about("about.remove-acme-certificate-acquisition-configuration") .with_call_remote::(), ) } @@ -463,9 +463,9 @@ impl ValueParserFactory for AcmeProvider { #[derive(Deserialize, Serialize, Parser)] pub struct InitAcmeParams { - #[arg(long)] + #[arg(long, help = "help.arg.acme-provider")] pub provider: AcmeProvider, - #[arg(long)] + #[arg(long, help = "help.arg.acme-contact")] pub contact: Vec, } @@ -488,7 +488,7 @@ pub async fn init( #[derive(Deserialize, Serialize, Parser)] pub struct RemoveAcmeParams { - #[arg(long)] + #[arg(long, help = "help.arg.acme-provider")] pub provider: AcmeProvider, } diff --git a/core/src/net/dns.rs b/core/src/net/dns.rs index d2a3729d9..84c5cb3a4 100644 --- a/core/src/net/dns.rs +++ b/core/src/net/dns.rs @@ -54,13 +54,13 @@ pub fn dns_api() -> ParentHandler { Ok(()) }) - .with_about("Test the DNS configuration for a domain"), + .with_about("about.test-dns-configuration-for-domain"), ) .subcommand( "set-static", from_fn_async(set_static_dns) .no_display() - .with_about("Set static DNS servers") + .with_about("about.set-static-dns-servers") .with_call_remote::(), ) .subcommand( @@ -88,13 +88,14 @@ pub fn dns_api() -> ParentHandler { Ok(()) }) - .with_about("Dump address resolution table") + .with_about("about.dump-address-resolution-table") .with_call_remote::(), ) } #[derive(Deserialize, Serialize, Parser)] pub struct QueryDnsParams { + #[arg(help = "help.arg.fqdn")] pub fqdn: InternedString, } @@ -134,6 +135,7 @@ pub fn query_dns( #[derive(Deserialize, Serialize, Parser)] pub struct SetStaticDnsParams { + #[arg(help = "help.arg.dns-servers")] pub servers: Option>, } @@ -292,7 +294,7 @@ impl Resolver { .await .map_err(|_| { Error::new( - eyre!("timed out waiting to update dns catalog"), + eyre!("{}", t!("net.dns.timeout-updating-catalog")), ErrorKind::Timeout, ) })?; @@ -348,7 +350,13 @@ impl Resolver { }) { return Some(res); } else { - tracing::warn!("Could not determine source interface of {src}"); + tracing::warn!( + "{}", + t!( + "net.dns.could-not-determine-source-interface", + src = src.to_string() + ) + ); } } if STARTOS.zone_of(name) || EMBASSY.zone_of(name) { @@ -473,7 +481,10 @@ impl RequestHandler for Resolver { Ok(Some(a)) => return a, Ok(None) => (), Err(e) => { - tracing::error!("Error resolving internal DNS: {e}"); + tracing::error!( + "{}", + t!("net.dns.error-resolving-internal", error = e.to_string()) + ); tracing::debug!("{e:?}"); let mut header = Header::response_from_request(request.header()); header.set_recursion_available(true); @@ -557,7 +568,7 @@ impl DnsController { }) } else { Err(Error::new( - eyre!("DNS Server Thread has exited"), + eyre!("{}", t!("net.dns.server-thread-exited")), crate::ErrorKind::Network, )) } @@ -577,7 +588,7 @@ impl DnsController { }) } else { Err(Error::new( - eyre!("DNS Server Thread has exited"), + eyre!("{}", t!("net.dns.server-thread-exited")), crate::ErrorKind::Network, )) } @@ -598,7 +609,7 @@ impl DnsController { }) } else { Err(Error::new( - eyre!("DNS Server Thread has exited"), + eyre!("{}", t!("net.dns.server-thread-exited")), crate::ErrorKind::Network, )) } @@ -624,7 +635,7 @@ impl DnsController { }) } else { Err(Error::new( - eyre!("DNS Server Thread has exited"), + eyre!("{}", t!("net.dns.server-thread-exited")), crate::ErrorKind::Network, )) } diff --git a/core/src/net/forward.rs b/core/src/net/forward.rs index 25f0b2116..77a8171af 100644 --- a/core/src/net/forward.rs +++ b/core/src/net/forward.rs @@ -34,7 +34,7 @@ impl AvailablePorts { pub fn alloc(&mut self) -> Result { self.0.request_id().ok_or_else(|| { Error::new( - eyre!("No more dynamic ports available!"), + eyre!("{}", t!("net.forward.no-dynamic-ports-available")), ErrorKind::Network, ) }) @@ -240,7 +240,7 @@ impl PortForwardController { } .await { - tracing::error!("error initializing PortForwardController: {e:#}"); + tracing::error!("{}", t!("net.forward.error-initializing-controller", error = format!("{e:#}"))); tracing::debug!("{e:?}"); tokio::time::sleep(Duration::from_secs(5)).await; } @@ -400,7 +400,7 @@ impl InterfaceForwardEntry { ) -> Result, Error> { if external != self.external { return Err(Error::new( - eyre!("Mismatched external port in InterfaceForwardEntry"), + eyre!("{}", t!("net.forward.mismatched-external-port")), ErrorKind::InvalidRequest, )); } @@ -477,7 +477,7 @@ impl InterfaceForwardState { fn err_has_exited(_: T) -> Error { Error::new( - eyre!("PortForwardController thread has exited"), + eyre!("{}", t!("net.forward.controller-thread-exited")), ErrorKind::Unknown, ) } diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 7c7685ff8..6079efd76 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -95,7 +95,7 @@ pub fn gateway_api() -> ParentHandler { Ok(()) }) - .with_about("Show gateways StartOS can listen on") + .with_about("about.show-gateways-startos-can-listen-on") .with_call_remote::(), ) .subcommand( @@ -103,7 +103,7 @@ pub fn gateway_api() -> ParentHandler { from_fn_async(set_public) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Indicate whether this gateway has inbound access from the WAN") + .with_about("about.indicate-gateway-inbound-access-from-wan") .with_call_remote::(), ) .subcommand( @@ -111,10 +111,7 @@ pub fn gateway_api() -> ParentHandler { from_fn_async(unset_public) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about(concat!( - "Allow this gateway to infer whether it has", - " inbound access from the WAN based on its IPv4 address" - )) + .with_about("about.allow-gateway-infer-inbound-access-from-wan") .with_call_remote::(), ) .subcommand( @@ -122,7 +119,7 @@ pub fn gateway_api() -> ParentHandler { from_fn_async(forget_iface) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Forget a disconnected gateway") + .with_about("about.forget-disconnected-gateway") .with_call_remote::(), ) .subcommand( @@ -130,7 +127,7 @@ pub fn gateway_api() -> ParentHandler { from_fn_async(set_name) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Rename a gateway") + .with_about("about.rename-gateway") .with_call_remote::(), ) } @@ -143,7 +140,9 @@ async fn list_interfaces( #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] struct NetworkInterfaceSetPublicParams { + #[arg(help = "help.arg.gateway-id")] gateway: GatewayId, + #[arg(help = "help.arg.is-public")] public: Option, } @@ -159,6 +158,7 @@ async fn set_public( #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] struct UnsetPublicParams { + #[arg(help = "help.arg.gateway-id")] gateway: GatewayId, } @@ -174,6 +174,7 @@ async fn unset_public( #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] struct ForgetGatewayParams { + #[arg(help = "help.arg.gateway-id")] gateway: GatewayId, } @@ -186,7 +187,9 @@ async fn forget_iface( #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] struct RenameGatewayParams { + #[arg(help = "help.arg.gateway-id")] id: GatewayId, + #[arg(help = "help.arg.gateway-name")] name: InternedString, } @@ -464,7 +467,8 @@ async fn watcher( ensure_code!( !devices.is_empty(), ErrorKind::Network, - "NetworkManager returned no devices. Trying again..." + "{}", + t!("net.gateway.no-devices-returned") ); let mut ifaces = BTreeSet::new(); let mut jobs = Vec::new(); @@ -731,7 +735,8 @@ async fn watch_ip( Ok(a) => a, Err(e) => { tracing::error!( - "Failed to determine WAN IP for {iface}: {e}" + "{}", + t!("net.gateway.failed-to-determine-wan-ip", iface = iface.to_string(), error = e.to_string()) ); tracing::debug!("{e:?}"); None @@ -1021,7 +1026,13 @@ impl NetworkInterfaceController { info } Err(e) => { - tracing::error!("Error loading network interface info: {e}"); + tracing::error!( + "{}", + t!( + "net.gateway.error-loading-interface-info", + error = e.to_string() + ) + ); tracing::debug!("{e:?}"); OrdMap::new() } @@ -1050,7 +1061,10 @@ impl NetworkInterfaceController { } .await { - tracing::error!("Error syncing ip info to db: {e}"); + tracing::error!( + "{}", + t!("net.gateway.error-syncing-ip-info", error = e.to_string()) + ); tracing::debug!("{e:?}"); } @@ -1060,7 +1074,10 @@ impl NetworkInterfaceController { } .await; if let Err(e) = res { - tracing::error!("Error syncing ip info to db: {e}"); + tracing::error!( + "{}", + t!("net.gateway.error-syncing-ip-info", error = e.to_string()) + ); tracing::debug!("{e:?}"); } }) @@ -1121,7 +1138,7 @@ impl NetworkInterfaceController { .map_or(false, |i| i.ip_info.is_some()) { err = Some(Error::new( - eyre!("Cannot forget currently connected interface"), + eyre!("{}", t!("net.gateway.cannot-forget-connected-interface")), ErrorKind::InvalidRequest, )); return false; @@ -1167,7 +1184,7 @@ impl NetworkInterfaceController { if &*ac == "/" { return Err(Error::new( - eyre!("Cannot delete device without active connection"), + eyre!("{}", t!("net.gateway.cannot-delete-without-connection")), ErrorKind::InvalidRequest, )); } diff --git a/core/src/net/host/address.rs b/core/src/net/host/address.rs index 296b8a934..9c60ababe 100644 --- a/core/src/net/host/address.rs +++ b/core/src/net/host/address.rs @@ -120,7 +120,7 @@ pub fn address_api() .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|_, a| a) .no_display() - .with_about("Add a public domain to this host") + .with_about("about.add-public-domain-to-host") .with_call_remote::(), ) .subcommand( @@ -129,7 +129,7 @@ pub fn address_api() .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|_, a| a) .no_display() - .with_about("Remove a public domain from this host") + .with_about("about.remove-public-domain-from-host") .with_call_remote::(), ) .with_inherited(|_, a| a), @@ -143,7 +143,7 @@ pub fn address_api() .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|_, a| a) .no_display() - .with_about("Add a private domain to this host") + .with_about("about.add-private-domain-to-host") .with_call_remote::(), ) .subcommand( @@ -152,7 +152,7 @@ pub fn address_api() .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|_, a| a) .no_display() - .with_about("Remove a private domain from this host") + .with_about("about.remove-private-domain-from-host") .with_call_remote::(), ) .with_inherited(|_, a| a), @@ -168,7 +168,7 @@ pub fn address_api() .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|_, a| a) .no_display() - .with_about("Add an address to this host") + .with_about("about.add-address-to-host") .with_call_remote::(), ) .subcommand( @@ -177,7 +177,7 @@ pub fn address_api() .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|_, a| a) .no_display() - .with_about("Remove an address from this host") + .with_about("about.remove-address-from-host") .with_call_remote::(), ) .with_inherited(Kind::inheritance), @@ -230,16 +230,18 @@ pub fn address_api() Ok(()) }) - .with_about("List addresses for this host") + .with_about("about.list-addresses-for-host") .with_call_remote::(), ) } #[derive(Deserialize, Serialize, Parser)] pub struct AddPublicDomainParams { + #[arg(help = "help.arg.fqdn")] pub fqdn: InternedString, - #[arg(long)] + #[arg(long, help = "help.arg.acme-provider")] pub acme: Option, + #[arg(help = "help.arg.gateway-id")] pub gateway: GatewayId, } @@ -284,6 +286,7 @@ pub async fn add_public_domain( #[derive(Deserialize, Serialize, Parser)] pub struct RemoveDomainParams { + #[arg(help = "help.arg.fqdn")] pub fqdn: InternedString, } @@ -307,6 +310,7 @@ pub async fn remove_public_domain( #[derive(Deserialize, Serialize, Parser)] pub struct AddPrivateDomainParams { + #[arg(help = "help.arg.fqdn")] pub fqdn: InternedString, } @@ -349,6 +353,7 @@ pub async fn remove_private_domain( #[derive(Deserialize, Serialize, Parser)] pub struct OnionParams { + #[arg(help = "help.arg.onion-address")] pub onion: String, } diff --git a/core/src/net/host/binding.rs b/core/src/net/host/binding.rs index a74d351aa..8862e2bda 100644 --- a/core/src/net/host/binding.rs +++ b/core/src/net/host/binding.rs @@ -209,7 +209,7 @@ pub fn binding() Ok(()) }) - .with_about("List bindinges for this host") + .with_about("about.list-bindings-for-host") .with_call_remote::(), ) .subcommand( @@ -218,7 +218,7 @@ pub fn binding() .with_metadata("sync_db", Value::Bool(true)) .with_inherited(Kind::inheritance) .no_display() - .with_about("Set whether this gateway should be enabled for this binding") + .with_about("about.set-gateway-enabled-for-binding") .with_call_remote::(), ) } @@ -237,9 +237,11 @@ pub async fn list_bindings( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct BindingGatewaySetEnabledParams { + #[arg(help = "help.arg.internal-port")] internal_port: u16, + #[arg(help = "help.arg.gateway-id")] gateway: GatewayId, - #[arg(long)] + #[arg(long, help = "help.arg.binding-enabled")] enabled: Option, } diff --git a/core/src/net/host/mod.rs b/core/src/net/host/mod.rs index 8f9ab15e2..620991ca7 100644 --- a/core/src/net/host/mod.rs +++ b/core/src/net/host/mod.rs @@ -166,11 +166,13 @@ impl Model { #[derive(Deserialize, Serialize, Parser)] pub struct RequiresPackageId { + #[arg(help = "help.arg.package-id")] package: PackageId, } #[derive(Deserialize, Serialize, Parser)] pub struct RequiresHostId { + #[arg(help = "help.arg.host-id")] host: HostId, } @@ -243,7 +245,7 @@ pub fn host_api() -> ParentHandler { } Ok(()) }) - .with_about("List host IDs available for this service"), + .with_about("about.list-host-ids-for-service"), ) .subcommand( "address", diff --git a/core/src/net/mod.rs b/core/src/net/mod.rs index e59f41a56..f30c1383b 100644 --- a/core/src/net/mod.rs +++ b/core/src/net/mod.rs @@ -23,32 +23,29 @@ pub mod wifi; pub fn net_api() -> ParentHandler { ParentHandler::new() - .subcommand( - "tor", - tor::tor_api::().with_about("Tor commands such as list-services, logs, and reset"), - ) + .subcommand("tor", tor::tor_api::().with_about("about.tor-commands")) .subcommand( "acme", - acme::acme_api::().with_about("Setup automatic clearnet certificate acquisition"), + acme::acme_api::().with_about("about.setup-acme-certificate"), ) .subcommand( "dns", - dns::dns_api::().with_about("Manage and query DNS"), + dns::dns_api::().with_about("about.manage-query-dns"), ) .subcommand( "forward", - forward::forward_api::().with_about("Manage port forwards"), + forward::forward_api::().with_about("about.manage-port-forwards"), ) .subcommand( "gateway", - gateway::gateway_api::().with_about("View and edit gateway configurations"), + gateway::gateway_api::().with_about("about.view-edit-gateway-configs"), ) .subcommand( "tunnel", - tunnel::tunnel_api::().with_about("Manage tunnels"), + tunnel::tunnel_api::().with_about("about.manage-tunnels"), ) .subcommand( "vhost", - vhost::vhost_api::().with_about("Manage ssl virtual host proxy"), + vhost::vhost_api::().with_about("about.manage-ssl-vhost-proxy"), ) } diff --git a/core/src/net/ssl.rs b/core/src/net/ssl.rs index 2e5110a18..748abb493 100644 --- a/core/src/net/ssl.rs +++ b/core/src/net/ssl.rs @@ -170,7 +170,7 @@ impl FullchainCertData { ] .into_iter() .min() - .ok_or_else(|| Error::new(eyre!("unreachable"), ErrorKind::Unknown)) + .ok_or_else(|| Error::new(eyre!("{}", t!("net.ssl.unreachable")), ErrorKind::Unknown)) } } diff --git a/core/src/net/static_server.rs b/core/src/net/static_server.rs index 639fd3a2e..08af326b6 100644 --- a/core/src/net/static_server.rs +++ b/core/src/net/static_server.rs @@ -30,7 +30,7 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, BufReader}; use tokio_util::io::ReaderStream; use url::Url; -use crate::context::{DiagnosticContext, InitContext, InstallContext, RpcContext, SetupContext}; +use crate::context::{DiagnosticContext, InitContext, RpcContext, SetupContext}; use crate::hostname::Hostname; use crate::middleware::auth::Auth; use crate::middleware::auth::session::ValidSessionToken; @@ -178,20 +178,6 @@ impl UiContext for SetupContext { } } -pub static INSTALL_WIZARD_CELL: OnceLock> = OnceLock::new(); - -impl UiContext for InstallContext { - fn ui_dir() -> &'static Dir<'static> { - INSTALL_WIZARD_CELL.get().unwrap_or(&EMPTY_DIR) - } - fn api() -> ParentHandler { - main_api() - } - fn middleware(server: Server) -> HttpServer { - server.middleware(Cors::new()) - } -} - pub fn rpc_router>( ctx: C, server: HttpServer, diff --git a/core/src/net/tor/arti.rs b/core/src/net/tor/arti.rs index 8c9f54c4f..e521b8795 100644 --- a/core/src/net/tor/arti.rs +++ b/core/src/net/tor/arti.rs @@ -107,7 +107,10 @@ impl TorSecretKey { Ok(Self( tor_llcrypto::pk::ed25519::ExpandedKeypair::from_secret_key_bytes(bytes) .ok_or_else(|| { - Error::new(eyre!("invalid ed25519 expanded secret key"), ErrorKind::Tor) + Error::new( + eyre!("{}", t!("net.tor.invalid-ed25519-key")), + ErrorKind::Tor, + ) })? .into(), )) @@ -226,19 +229,19 @@ pub fn tor_api() -> ParentHandler { from_fn_async(list_services) .with_display_serializable() .with_custom_display_fn(|handle, result| display_services(handle.params, result)) - .with_about("Display Tor V3 Onion Addresses") + .with_about("about.display-tor-v3-onion-addresses") .with_call_remote::(), ) .subcommand( "reset", from_fn_async(reset) .no_display() - .with_about("Reset Tor daemon") + .with_about("about.reset-tor-daemon") .with_call_remote::(), ) .subcommand( "key", - key::().with_about("Manage the onion service key store"), + key::().with_about("about.manage-onion-service-key-store"), ) } @@ -247,13 +250,13 @@ pub fn key() -> ParentHandler { .subcommand( "generate", from_fn_async(generate_key) - .with_about("Generate an onion service key and add it to the key store") + .with_about("about.generate-onion-service-key-add-to-store") .with_call_remote::(), ) .subcommand( "add", from_fn_async(add_key) - .with_about("Add an onion service key to the key store") + .with_about("about.add-onion-service-key-to-store") .with_call_remote::(), ) .subcommand( @@ -265,7 +268,7 @@ pub fn key() -> ParentHandler { } Ok(()) }) - .with_about("List onion services with keys in the key store") + .with_about("about.list-onion-services-with-keys-in-store") .with_call_remote::(), ) } @@ -286,6 +289,7 @@ pub async fn generate_key(ctx: RpcContext) -> Result { #[derive(Deserialize, Serialize, Parser)] pub struct AddKeyParams { + #[arg(help = "help.arg.onion-secret-key")] pub key: Base64<[u8; 64]>, } @@ -320,7 +324,7 @@ pub async fn list_keys(ctx: RpcContext) -> Result, Error> #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ResetParams { - #[arg(name = "wipe-state", short = 'w', long = "wipe-state")] + #[arg(name = "wipe-state", short = 'w', long = "wipe-state", help = "help.arg.wipe-tor-state")] wipe_state: bool, } @@ -447,9 +451,13 @@ impl TorController { if prev_inst.elapsed() > BOOTSTRAP_PROGRESS_TIMEOUT { return Err(Error::new( eyre!( - "Bootstrap has not made progress for {}", - crate::util::serde::Duration::from( - BOOTSTRAP_PROGRESS_TIMEOUT + "{}", + t!( + "net.tor.bootstrap-no-progress", + duration = crate::util::serde::Duration::from( + BOOTSTRAP_PROGRESS_TIMEOUT + ) + .to_string() ) ), ErrorKind::Tor, @@ -466,7 +474,10 @@ impl TorController { res = bootstrap_fut => res, res = failure_fut => res, } { - tracing::error!("Tor Bootstrap Error: {e}"); + tracing::error!( + "{}", + t!("net.tor.bootstrap-error", error = e.to_string()) + ); tracing::debug!("{e:?}"); } else { bootstrapper_client.send_modify(|_| ()); @@ -516,7 +527,13 @@ impl TorController { } .await { - tracing::error!("Tor Health Error: {e}"); + tracing::error!( + "{}", + t!( + "net.tor.health-error", + error = e.to_string() + ) + ); tracing::debug!("{e:?}"); } }); @@ -529,7 +546,10 @@ impl TorController { } } Err(Error::new( - eyre!("status event stream ended"), + eyre!( + "{}", + t!("net.tor.status-stream-ended") + ), ErrorKind::Tor, )) }) @@ -560,13 +580,19 @@ impl TorController { } .await { - tracing::error!("Tor Client Health Error: {e}"); + tracing::error!( + "{}", + t!("net.tor.client-health-error", error = e.to_string()) + ); tracing::debug!("{e:?}"); } } tracing::error!( - "Client failed health check {} times, recycling", - HEALTH_CHECK_FAILURE_ALLOWANCE + "{}", + t!( + "net.tor.health-check-failed-recycling", + count = HEALTH_CHECK_FAILURE_ALLOWANCE + ) ); } @@ -574,7 +600,10 @@ impl TorController { }) .await { - tracing::error!("Tor Bootstrapper Error: {e}"); + tracing::error!( + "{}", + t!("net.tor.bootstrapper-error", error = e.to_string()) + ); tracing::debug!("{e:?}"); } if let Err::<(), Error>(e) = async { @@ -592,7 +621,10 @@ impl TorController { } .await { - tracing::error!("Tor Client Creation Error: {e}"); + tracing::error!( + "{}", + t!("net.tor.client-creation-error", error = e.to_string()) + ); tracing::debug!("{e:?}"); } } @@ -684,7 +716,10 @@ impl TorController { .await .with_kind(ErrorKind::Network)?; if let Err(e) = socket2::SockRef::from(&tcp_stream).set_keepalive(true) { - tracing::error!("Failed to set tcp keepalive: {e}"); + tracing::error!( + "{}", + t!("net.tor.failed-to-set-tcp-keepalive", error = e.to_string()) + ); tracing::debug!("{e:?}"); } Ok(Box::new(tcp_stream)) @@ -812,7 +847,7 @@ impl OnionService { .await .with_kind(ErrorKind::Network)?; if let Err(e) = socket2::SockRef::from(&outgoing).set_keepalive(true) { - tracing::error!("Failed to set tcp keepalive: {e}"); + tracing::error!("{}", t!("net.tor.failed-to-set-tcp-keepalive", error = e.to_string())); tracing::debug!("{e:?}"); } let mut incoming = req @@ -852,7 +887,7 @@ impl OnionService { } .await { - tracing::error!("Tor Client Error: {e}"); + tracing::error!("{}", t!("net.tor.client-error", error = e.to_string())); tracing::debug!("{e:?}"); } } diff --git a/core/src/net/tor/ctor.rs b/core/src/net/tor/ctor.rs index 726e0a9f9..91539fe62 100644 --- a/core/src/net/tor/ctor.rs +++ b/core/src/net/tor/ctor.rs @@ -249,26 +249,26 @@ pub fn tor_api() -> ParentHandler { from_fn_async(list_services) .with_display_serializable() .with_custom_display_fn(|handle, result| display_services(handle.params, result)) - .with_about("Display Tor V3 Onion Addresses") + .with_about("about.display-tor-v3-onion-addresses") .with_call_remote::(), ) - .subcommand("logs", logs().with_about("Display Tor logs")) + .subcommand("logs", logs().with_about("about.display-tor-logs")) .subcommand( "logs", from_fn_async(crate::logs::cli_logs::) .no_display() - .with_about("Display Tor logs"), + .with_about("about.display-tor-logs"), ) .subcommand( "reset", from_fn_async(reset) .no_display() - .with_about("Reset Tor daemon") + .with_about("about.reset-tor-daemon") .with_call_remote::(), ) .subcommand( "key", - key::().with_about("Manage the onion service key store"), + key::().with_about("about.manage-onion-service-key-store"), ) } @@ -277,13 +277,13 @@ pub fn key() -> ParentHandler { .subcommand( "generate", from_fn_async(generate_key) - .with_about("Generate an onion service key and add it to the key store") + .with_about("about.generate-onion-service-key-add-to-store") .with_call_remote::(), ) .subcommand( "add", from_fn_async(add_key) - .with_about("Add an onion service key to the key store") + .with_about("about.add-onion-service-key-to-store") .with_call_remote::(), ) .subcommand( @@ -295,7 +295,7 @@ pub fn key() -> ParentHandler { } Ok(()) }) - .with_about("List onion services with keys in the key store") + .with_about("about.list-onion-services-with-keys-in-store") .with_call_remote::(), ) } @@ -316,6 +316,7 @@ pub async fn generate_key(ctx: RpcContext) -> Result { #[derive(Deserialize, Serialize, Parser)] pub struct AddKeyParams { + #[arg(help = "help.arg.onion-secret-key")] pub key: Base64<[u8; 64]>, } @@ -350,8 +351,9 @@ pub async fn list_keys(ctx: RpcContext) -> Result, Error> #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ResetParams { - #[arg(name = "wipe-state", short = 'w', long = "wipe-state")] + #[arg(name = "wipe-state", short = 'w', long = "wipe-state", help = "help.arg.wipe-tor-state")] wipe_state: bool, + #[arg(help = "help.arg.reset-reason")] reason: String, } diff --git a/core/src/net/tunnel.rs b/core/src/net/tunnel.rs index 64e1ee284..f3b505850 100644 --- a/core/src/net/tunnel.rs +++ b/core/src/net/tunnel.rs @@ -19,14 +19,14 @@ pub fn tunnel_api() -> ParentHandler { .subcommand( "add", from_fn_async(add_tunnel) - .with_about("Add a new tunnel") + .with_about("about.add-new-tunnel") .with_call_remote::(), ) .subcommand( "remove", from_fn_async(remove_tunnel) .no_display() - .with_about("Remove a tunnel") + .with_about("about.remove-tunnel") .with_call_remote::(), ) } @@ -34,8 +34,11 @@ pub fn tunnel_api() -> ParentHandler { #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[ts(export)] pub struct AddTunnelParams { + #[arg(help = "help.arg.tunnel-name")] name: InternedString, + #[arg(help = "help.arg.wireguard-config")] config: String, + #[arg(help = "help.arg.is-public")] public: bool, } @@ -123,6 +126,7 @@ pub async fn add_tunnel( #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[ts(export)] pub struct RemoveTunnelParams { + #[arg(help = "help.arg.gateway-id")] id: GatewayId, } pub async fn remove_tunnel( diff --git a/core/src/net/wifi.rs b/core/src/net/wifi.rs index 6d6774998..046ad612f 100644 --- a/core/src/net/wifi.rs +++ b/core/src/net/wifi.rs @@ -30,7 +30,7 @@ type WifiManager = Arc>>; // Ok(wifi_manager) // } else { // Err(Error::new( -// color_eyre::eyre::eyre!("No WiFi interface available"), +// color_eyre::eyre::eyre!("{}", t!("net.wifi.no-interface-available")), // ErrorKind::Wifi, // )) // } @@ -42,28 +42,28 @@ pub fn wifi() -> ParentHandler { "set-enabled", from_fn_async(set_enabled) .no_display() - .with_about("Enable or disable wifi") + .with_about("about.enable-disable-wifi") .with_call_remote::(), ) .subcommand( "add", from_fn_async(add) .no_display() - .with_about("Add wifi ssid and password") + .with_about("about.add-wifi-ssid-password") .with_call_remote::(), ) .subcommand( "connect", from_fn_async(connect) .no_display() - .with_about("Connect to wifi network") + .with_about("about.connect-wifi-network") .with_call_remote::(), ) .subcommand( "remove", from_fn_async(remove) .no_display() - .with_about("Remove a wifi network") + .with_about("about.remove-wifi-network") .with_call_remote::(), ) .subcommand( @@ -71,16 +71,16 @@ pub fn wifi() -> ParentHandler { from_fn_async(get) .with_display_serializable() .with_custom_display_fn(|handle, result| display_wifi_info(handle.params, result)) - .with_about("List wifi info") + .with_about("about.list-wifi-info") .with_call_remote::(), ) .subcommand( "country", - country::().with_about("Command to set country"), + country::().with_about("about.command-set-country"), ) .subcommand( "available", - available::().with_about("Command to list available wifi networks"), + available::().with_about("about.command-list-available-wifi"), ) } @@ -88,6 +88,7 @@ pub fn wifi() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct SetWifiEnabledParams { + #[arg(help = "help.arg.wifi-enabled")] pub enabled: bool, } @@ -133,7 +134,7 @@ pub fn available() -> ParentHandler { from_fn_async(get_available) .with_display_serializable() .with_custom_display_fn(|handle, result| display_wifi_list(handle.params, result)) - .with_about("List available wifi networks") + .with_about("about.list-available-wifi-networks") .with_call_remote::(), ) } @@ -143,7 +144,7 @@ pub fn country() -> ParentHandler { "set", from_fn_async(set_country) .no_display() - .with_about("Set Country") + .with_about("about.set-country") .with_call_remote::(), ) } @@ -152,7 +153,9 @@ pub fn country() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct AddParams { + #[arg(help = "help.arg.wifi-ssid")] ssid: String, + #[arg(help = "help.arg.wifi-password")] password: String, } #[instrument(skip_all)] @@ -160,13 +163,13 @@ pub async fn add(ctx: RpcContext, AddParams { ssid, password }: AddParams) -> Re let wifi_manager = ctx.wifi_manager.clone(); if !ssid.is_ascii() { return Err(Error::new( - color_eyre::eyre::eyre!("SSID may not have special characters"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.ssid-no-special-characters")), ErrorKind::Wifi, )); } if !password.is_ascii() { return Err(Error::new( - color_eyre::eyre::eyre!("WiFi Password may not have special characters"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.password-no-special-characters")), ErrorKind::Wifi, )); } @@ -176,11 +179,11 @@ pub async fn add(ctx: RpcContext, AddParams { ssid, password }: AddParams) -> Re ssid: &Ssid, password: &Psk, ) -> Result<(), Error> { - tracing::info!("Adding new WiFi network: '{}'", ssid.0); + tracing::info!("{}", t!("net.wifi.adding-network", ssid = &ssid.0)); let mut wpa_supplicant = wifi_manager.write_owned().await; let wpa_supplicant = wpa_supplicant.as_mut().ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("No WiFi interface available"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.no-interface-available")), ErrorKind::Wifi, ) })?; @@ -195,10 +198,17 @@ pub async fn add(ctx: RpcContext, AddParams { ssid, password }: AddParams) -> Re ) .await { - tracing::error!("Failed to add new WiFi network '{}': {}", ssid, err); + tracing::error!( + "{}", + t!( + "net.wifi.failed-to-add-network", + ssid = &ssid, + error = err.to_string() + ) + ); tracing::debug!("{:?}", err); return Err(Error::new( - color_eyre::eyre::eyre!("Failed adding {}", ssid), + color_eyre::eyre::eyre!("{}", t!("net.wifi.failed-adding", ssid = &ssid)), ErrorKind::Wifi, )); } @@ -222,6 +232,7 @@ pub async fn add(ctx: RpcContext, AddParams { ssid, password }: AddParams) -> Re #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct SsidParams { + #[arg(help = "help.arg.wifi-ssid")] ssid: String, } @@ -230,7 +241,7 @@ pub async fn connect(ctx: RpcContext, SsidParams { ssid }: SsidParams) -> Result let wifi_manager = ctx.wifi_manager.clone(); if !ssid.is_ascii() { return Err(Error::new( - color_eyre::eyre::eyre!("SSID may not have special characters"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.ssid-no-special-characters")), ErrorKind::Wifi, )); } @@ -242,19 +253,19 @@ pub async fn connect(ctx: RpcContext, SsidParams { ssid }: SsidParams) -> Result let mut wpa_supplicant = wifi_manager.write_owned().await; let wpa_supplicant = wpa_supplicant.as_mut().ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("No WiFi interface available"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.no-interface-available")), ErrorKind::Wifi, ) })?; let current = wpa_supplicant.get_current_network().await?; let connected = wpa_supplicant.select_network(db.clone(), ssid).await?; if connected { - tracing::info!("Successfully connected to WiFi: '{}'", ssid.0); + tracing::info!("{}", t!("net.wifi.connected-successfully", ssid = &ssid.0)); } else { - tracing::info!("Failed to connect to WiFi: '{}'", ssid.0); + tracing::info!("{}", t!("net.wifi.connection-failed", ssid = &ssid.0)); match current { None => { - tracing::info!("No WiFi to revert to!"); + tracing::info!("{}", t!("net.wifi.no-wifi-to-revert")); } Some(current) => { wpa_supplicant.select_network(db, ¤t).await?; @@ -267,9 +278,16 @@ pub async fn connect(ctx: RpcContext, SsidParams { ssid }: SsidParams) -> Result if let Err(err) = connect_procedure(ctx.db.clone(), wifi_manager.clone(), &Ssid(ssid.clone())).await { - tracing::error!("Failed to connect to WiFi network '{}': {}", &ssid, err); + tracing::error!( + "{}", + t!( + "net.wifi.failed-to-connect", + ssid = &ssid, + error = err.to_string() + ) + ); return Err(Error::new( - color_eyre::eyre::eyre!("Can't connect to {}", ssid), + color_eyre::eyre::eyre!("{}", t!("net.wifi.cant-connect", ssid = &ssid)), ErrorKind::Wifi, )); } @@ -297,7 +315,7 @@ pub async fn remove(ctx: RpcContext, SsidParams { ssid }: SsidParams) -> Result< let wifi_manager = ctx.wifi_manager.clone(); if !ssid.is_ascii() { return Err(Error::new( - color_eyre::eyre::eyre!("SSID may not have special characters"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.ssid-no-special-characters")), ErrorKind::Wifi, )); } @@ -305,7 +323,7 @@ pub async fn remove(ctx: RpcContext, SsidParams { ssid }: SsidParams) -> Result< let mut wpa_supplicant = wifi_manager.write_owned().await; let wpa_supplicant = wpa_supplicant.as_mut().ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("No WiFi interface available"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.no-interface-available")), ErrorKind::Wifi, ) })?; @@ -316,9 +334,7 @@ pub async fn remove(ctx: RpcContext, SsidParams { ssid }: SsidParams) -> Result< is_current_being_removed && !interface_connected(&ctx.ethernet_interface).await?; if is_current_removed_and_no_hardwire { return Err(Error::new( - color_eyre::eyre::eyre!( - "Forbidden: Deleting this network would make your server unreachable. Either connect to ethernet or connect to a different WiFi network to remedy this." - ), + color_eyre::eyre::eyre!("{}", t!("net.wifi.forbidden-delete-would-disconnect")), ErrorKind::Wifi, )); } @@ -463,7 +479,7 @@ pub async fn get(ctx: RpcContext, _: Empty) -> Result { let wpa_supplicant = wifi_manager.read_owned().await; let wpa_supplicant = wpa_supplicant.as_ref().ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("No WiFi interface available"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.no-interface-available")), ErrorKind::Wifi, ) })?; @@ -517,7 +533,7 @@ pub async fn get_available(ctx: RpcContext, _: Empty) -> Result let wpa_supplicant = wifi_manager.read_owned().await; let wpa_supplicant = wpa_supplicant.as_ref().ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("No WiFi interface available"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.no-interface-available")), ErrorKind::Wifi, ) })?; @@ -547,7 +563,7 @@ pub async fn get_available(ctx: RpcContext, _: Empty) -> Result #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct SetCountryParams { - #[arg(value_parser = CountryCodeParser)] + #[arg(value_parser = CountryCodeParser, help = "help.arg.wifi-country-code")] #[ts(type = "string")] country: CountryCode, } @@ -558,14 +574,14 @@ pub async fn set_country( let wifi_manager = ctx.wifi_manager.clone(); if !interface_connected(&ctx.ethernet_interface).await? { return Err(Error::new( - color_eyre::eyre::eyre!("Won't change country without hardwire connection"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.wont-change-country-without-ethernet")), crate::ErrorKind::Wifi, )); } let mut wpa_supplicant = wifi_manager.write_owned().await; let wpa_supplicant = wpa_supplicant.as_mut().ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("No WiFi interface available"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.no-interface-available")), ErrorKind::Wifi, ) })?; @@ -684,7 +700,14 @@ impl WpaCli { .await .map(|_| ()) .unwrap_or_else(|e| { - tracing::warn!("Failed to set interface {} for {}", self.interface, ssid.0); + tracing::warn!( + "{}", + t!( + "net.wifi.failed-to-set-interface", + interface = &self.interface, + ssid = &ssid.0 + ) + ); tracing::debug!("{:?}", e); }); Command::new("nmcli") @@ -719,13 +742,13 @@ impl WpaCli { } let first_country = r.lines().find(|s| s.contains("country")).ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("Could not find a country config lines"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.could-not-find-country-config")), ErrorKind::Wifi, ) })?; let country = &RE.captures(first_country).ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("Could not find a country config with regex"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.could-not-parse-country-config")), ErrorKind::Wifi, ) })?[1]; @@ -734,7 +757,10 @@ impl WpaCli { } else { Ok(Some(CountryCode::for_alpha2(country).map_err(|_| { Error::new( - color_eyre::eyre::eyre!("Invalid Country Code: {}", country), + color_eyre::eyre::eyre!( + "{}", + t!("net.wifi.invalid-country-code", country = country) + ), ErrorKind::Wifi, ) })?)) @@ -877,7 +903,7 @@ impl WpaCli { let m_id = self.check_active_network(ssid).await?; match m_id { None => Err(Error::new( - color_eyre::eyre::eyre!("SSID Not Found"), + color_eyre::eyre::eyre!("{}", t!("net.wifi.ssid-not-found")), ErrorKind::Wifi, )), Some(x) => { @@ -1058,7 +1084,7 @@ pub async fn synchronize_network_manager>( .invoke(ErrorKind::Wifi) .await?; if let Some(last_country_code) = wifi.last_region { - tracing::info!("Setting the region"); + tracing::info!("{}", t!("net.wifi.setting-region")); let _ = Command::new("iw") .arg("reg") .arg("set") @@ -1066,7 +1092,7 @@ pub async fn synchronize_network_manager>( .invoke(ErrorKind::Wifi) .await?; } else { - tracing::info!("Setting the region fallback"); + tracing::info!("{}", t!("net.wifi.setting-region-fallback")); let _ = Command::new("iw") .arg("reg") .arg("set") diff --git a/core/src/notifications.rs b/core/src/notifications.rs index 4f440792c..1d6c147ba 100644 --- a/core/src/notifications.rs +++ b/core/src/notifications.rs @@ -27,49 +27,49 @@ pub fn notification() -> ParentHandler { "list", from_fn_async(list) .with_display_serializable() - .with_about("List notifications") + .with_about("about.list-notifications") .with_call_remote::(), ) .subcommand( "remove", from_fn_async(remove) .no_display() - .with_about("Remove notification for given ids") + .with_about("about.remove-notification-for-ids") .with_call_remote::(), ) .subcommand( "remove-before", from_fn_async(remove_before) .no_display() - .with_about("Remove notifications preceding a given id") + .with_about("about.remove-notifications-before-id") .with_call_remote::(), ) .subcommand( "mark-seen", from_fn_async(mark_seen) .no_display() - .with_about("Mark given notifications as seen") + .with_about("about.mark-notifications-seen") .with_call_remote::(), ) .subcommand( "mark-seen-before", from_fn_async(mark_seen_before) .no_display() - .with_about("Mark notifications preceding a given id as seen") + .with_about("about.mark-notifications-seen-before-id") .with_call_remote::(), ) .subcommand( "mark-unseen", from_fn_async(mark_unseen) .no_display() - .with_about("Mark given notifications as unseen") + .with_about("about.mark-notifications-unseen") .with_call_remote::(), ) .subcommand( "create", from_fn_async(create) .no_display() - .with_about("Persist a newly created notification") + .with_about("about.persist-new-notification") .with_call_remote::(), ) } @@ -78,8 +78,10 @@ pub fn notification() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ListNotificationParams { + #[arg(help = "help.arg.notification-before-id")] #[ts(type = "number | null")] before: Option, + #[arg(help = "help.arg.notification-limit")] #[ts(type = "number | null")] limit: Option, } @@ -141,6 +143,7 @@ pub async fn list( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ModifyNotificationParams { + #[arg(help = "help.arg.notification-ids")] #[ts(type = "number[]")] ids: Vec, } @@ -175,6 +178,7 @@ pub async fn remove( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ModifyNotificationBeforeParams { + #[arg(help = "help.arg.notification-before-id")] #[ts(type = "number")] before: u32, } @@ -296,9 +300,13 @@ pub async fn mark_unseen( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CreateParams { + #[arg(help = "help.arg.package-id")] package: Option, + #[arg(help = "help.arg.notification-level")] level: NotificationLevel, + #[arg(help = "help.arg.notification-title")] title: String, + #[arg(help = "help.arg.notification-message")] message: String, } @@ -346,7 +354,7 @@ pub struct InvalidNotificationLevel(String); impl From for crate::Error { fn from(val: InvalidNotificationLevel) -> Self { Error::new( - eyre!("Invalid Notification Level: {}", val.0), + eyre!("{}", t!("notifications.invalid-level", level = val.0)), ErrorKind::ParseDbField, ) } diff --git a/core/src/os_install/gpt.rs b/core/src/os_install/gpt.rs index 491c62431..0fe5d0665 100644 --- a/core/src/os_install/gpt.rs +++ b/core/src/os_install/gpt.rs @@ -1,138 +1,207 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use gpt::GptConfig; use gpt::disk::LogicalBlockSize; +use tokio::process::Command; use crate::disk::OsPartitionInfo; -use crate::disk::util::DiskInfo; use crate::os_install::partition_for; use crate::prelude::*; +use crate::util::Invoke; -pub async fn partition(disk: &DiskInfo, overwrite: bool) -> Result { - let efi = { - let disk = disk.clone(); - tokio::task::spawn_blocking(move || { - let use_efi = Path::new("/sys/firmware/efi").exists(); - let mut device = Box::new( - std::fs::File::options() - .read(true) - .write(true) - .open(&disk.logicalname)?, - ); - let (mut gpt, guid_part) = if overwrite { - let mbr = gpt::mbr::ProtectiveMBR::with_lb_size( - u32::try_from((disk.capacity / 512) - 1).unwrap_or(0xFF_FF_FF_FF), - ); - mbr.overwrite_lba0(&mut device)?; - ( - GptConfig::new() - .writable(true) - .logical_block_size(LogicalBlockSize::Lb512) - .create_from_device(device, None)?, - None, - ) - } else { - let gpt = GptConfig::new() - .writable(true) +pub async fn partition( + disk_path: &Path, + capacity: u64, + protect: Option<&Path>, + use_efi: bool, +) -> Result { + // Guard: cannot protect the whole disk + if let Some(p) = protect { + if p == disk_path { + return Err(Error::new( + eyre!( + "Cannot protect the entire disk {}; must specify a partition", + disk_path.display() + ), + crate::ErrorKind::InvalidRequest, + )); + } + } + + let disk_path = disk_path.to_owned(); + let disk_path_clone = disk_path.clone(); + let protect = protect.map(|p| p.to_owned()); + let (efi, data_part) = tokio::task::spawn_blocking(move || { + let disk_path = disk_path_clone; + + let protected_partition_info: Option<(u64, u64, PathBuf)> = + if let Some(ref protect_path) = protect { + let existing_gpt = GptConfig::new() + .writable(false) .logical_block_size(LogicalBlockSize::Lb512) - .open_from_device(device)?; - let mut guid_part = None; - for (idx, part_info) in disk - .partitions + .open_from_device(Box::new( + std::fs::File::options().read(true).open(&disk_path)?, + ))?; + let info = existing_gpt + .partitions() .iter() - .enumerate() - .map(|(idx, x)| (idx + 1, x)) - { - if let Some(entry) = gpt.partitions().get(&(idx as u32)) { - if part_info.guid.is_some() { - if entry.first_lba < if use_efi { 33759266 } else { 33570850 } { - return Err(Error::new( - eyre!("Not enough space before StartOS data"), - crate::ErrorKind::InvalidRequest, - )); - } - guid_part = Some(entry.clone()); - break; - } - } + .find(|(num, _)| partition_for(&disk_path, **num) == *protect_path) + .map(|(_, p)| (p.first_lba, p.last_lba, protect_path.clone())); + if info.is_none() { + return Err(Error::new( + eyre!( + "Protected partition {} not found in GPT on {}", + protect_path.display(), + disk_path.display() + ), + crate::ErrorKind::NotFound, + )); } - (gpt, guid_part) - }; - - gpt.update_partitions(Default::default())?; - - let efi = if use_efi { - gpt.add_partition("efi", 100 * 1024 * 1024, gpt::partition_types::EFI, 0, None)?; - true + info } else { - gpt.add_partition( - "bios-grub", - 8 * 1024 * 1024, - gpt::partition_types::BIOS, - 0, - None, - )?; - false + None }; - gpt.add_partition( - "boot", - 1024 * 1024 * 1024, - gpt::partition_types::LINUX_FS, - 0, - None, - )?; - gpt.add_partition( - "root", - 15 * 1024 * 1024 * 1024, - match crate::ARCH { - "x86_64" => gpt::partition_types::LINUX_ROOT_X64, - "aarch64" => gpt::partition_types::LINUX_ROOT_ARM_64, - _ => gpt::partition_types::LINUX_FS, - }, - 0, - None, - )?; - if overwrite { - gpt.add_partition( - "data", - gpt.find_free_sectors() - .iter() - .map(|(_, size)| *size * u64::from(*gpt.logical_block_size())) - .max() - .ok_or_else(|| { - Error::new( - eyre!("No free space left on device"), - crate::ErrorKind::BlockDevice, - ) - })?, - gpt::partition_types::LINUX_LVM, - 0, - None, - )?; - } else if let Some(guid_part) = guid_part { - let mut parts = gpt.partitions().clone(); - parts.insert( - gpt.find_next_partition_id().ok_or_else(|| { - Error::new(eyre!("Partition table is full"), ErrorKind::DiskManagement) - })?, - guid_part, - ); - gpt.update_partitions(parts)?; + let mut device = Box::new( + std::fs::File::options() + .read(true) + .write(true) + .open(&disk_path)?, + ); + + let mbr = gpt::mbr::ProtectiveMBR::with_lb_size( + u32::try_from((capacity / 512) - 1).unwrap_or(0xFF_FF_FF_FF), + ); + mbr.overwrite_lba0(&mut device)?; + let mut gpt = GptConfig::new() + .writable(true) + .logical_block_size(LogicalBlockSize::Lb512) + .create_from_device(device, None)?; + + gpt.update_partitions(Default::default())?; + + let efi = if use_efi { + gpt.add_partition("efi", 100 * 1024 * 1024, gpt::partition_types::EFI, 0, None)?; + true + } else { + gpt.add_partition( + "bios-grub", + 8 * 1024 * 1024, + gpt::partition_types::BIOS, + 0, + None, + )?; + false + }; + gpt.add_partition( + "boot", + 2 * 1024 * 1024 * 1024, + gpt::partition_types::LINUX_FS, + 0, + None, + )?; + gpt.add_partition( + "root", + 14 * 1024 * 1024 * 1024, + match crate::ARCH { + "x86_64" => gpt::partition_types::LINUX_ROOT_X64, + "aarch64" => gpt::partition_types::LINUX_ROOT_ARM_64, + _ => gpt::partition_types::LINUX_FS, + }, + 0, + None, + )?; + + // Check if protected partition would be overwritten by OS partitions + if let Some((first_lba, _, ref path)) = protected_partition_info { + // Get the actual end sector of the last OS partition (root = partition 3) + let os_partitions_end_sector = + gpt.partitions().get(&3).map(|p| p.last_lba).unwrap_or(0); + if first_lba <= os_partitions_end_sector { + return Err(Error::new( + eyre!( + concat!( + "Protected partition {} starts at sector {}", + " which would be overwritten by OS partitions ending at sector {}" + ), + path.display(), + first_lba, + os_partitions_end_sector + ), + crate::ErrorKind::DiskManagement, + )); } + } - gpt.write()?; + let data_part = if let Some((first_lba, last_lba, path)) = protected_partition_info { + // Re-create the data partition entry at the same location + let length_lba = last_lba - first_lba + 1; + let next_id = gpt.partitions().keys().max().map(|k| k + 1).unwrap_or(1); + gpt.add_partition_at( + "data", + next_id, + first_lba, + length_lba, + gpt::partition_types::LINUX_LVM, + 0, + )?; + Some(path) + } else { + gpt.add_partition( + "data", + gpt.find_free_sectors() + .iter() + .map(|(_, size)| *size * u64::from(*gpt.logical_block_size())) + .max() + .ok_or_else(|| { + Error::new( + eyre!("No free space left on device"), + crate::ErrorKind::BlockDevice, + ) + })?, + gpt::partition_types::LINUX_LVM, + 0, + None, + )?; + gpt.partitions() + .last_key_value() + .map(|(num, _)| partition_for(&disk_path, *num)) + }; - Ok(efi) - }) + gpt.write()?; + + Ok::<_, Error>((efi, data_part)) + }) + .await + .unwrap()?; + + // Re-read partition table and wait for udev to create device nodes + Command::new("vgchange") + .arg("-an") + .invoke(crate::ErrorKind::DiskManagement) .await - .unwrap()? - }; + .ok(); + Command::new("dmsetup") + .arg("remove_all") + .arg("--force") + .invoke(crate::ErrorKind::DiskManagement) + .await + .ok(); + Command::new("blockdev") + .arg("--rereadpt") + .arg(&disk_path) + .invoke(crate::ErrorKind::DiskManagement) + .await?; + Command::new("udevadm") + .arg("settle") + .invoke(crate::ErrorKind::DiskManagement) + .await?; Ok(OsPartitionInfo { - efi: efi.then(|| partition_for(&disk.logicalname, 1)), - bios: (!efi).then(|| partition_for(&disk.logicalname, 1)), - boot: partition_for(&disk.logicalname, 2), - root: partition_for(&disk.logicalname, 3), + efi: efi.then(|| partition_for(&disk_path, 1)), + bios: (!efi).then(|| partition_for(&disk_path, 1)), + boot: partition_for(&disk_path, 2), + root: partition_for(&disk_path, 3), + data: data_part, }) } diff --git a/core/src/os_install/mbr.rs b/core/src/os_install/mbr.rs index 6d85e29a3..b121198f8 100644 --- a/core/src/os_install/mbr.rs +++ b/core/src/os_install/mbr.rs @@ -1,88 +1,173 @@ +use std::path::{Path, PathBuf}; + use color_eyre::eyre::eyre; use mbrman::{CHS, MBR, MBRPartitionEntry}; +use tokio::process::Command; -use crate::Error; use crate::disk::OsPartitionInfo; -use crate::disk::util::DiskInfo; use crate::os_install::partition_for; +use crate::prelude::*; +use crate::util::Invoke; -pub async fn partition(disk: &DiskInfo, overwrite: bool) -> Result { - { - let sectors = (disk.capacity / 512) as u32; - let disk = disk.clone(); - tokio::task::spawn_blocking(move || { - let mut file = std::fs::File::options() - .read(true) - .write(true) - .open(&disk.logicalname)?; - let (mut mbr, guid_part) = if overwrite { - (MBR::new_from(&mut file, 512, rand::random())?, None) - } else { - let mut mbr = MBR::read_from(&mut file, 512)?; - let mut guid_part = None; - for (idx, part_info) in disk - .partitions - .iter() - .enumerate() - .map(|(idx, x)| (idx + 1, x)) - { - if let Some(entry) = mbr.get_mut(idx) { - if part_info.guid.is_some() { - if entry.starting_lba < 33556480 { - return Err(Error::new( - eyre!("Not enough space before embassy data"), - crate::ErrorKind::InvalidRequest, - )); - } - guid_part = Some(std::mem::replace(entry, MBRPartitionEntry::empty())); +pub async fn partition( + disk_path: &Path, + capacity: u64, + protect: Option<&Path>, +) -> Result { + // Guard: cannot protect the whole disk + if let Some(p) = protect { + if p == disk_path { + return Err(Error::new( + eyre!( + "Cannot protect the entire disk {}; must specify a partition", + disk_path.display() + ), + crate::ErrorKind::InvalidRequest, + )); + } + } + + let disk_path = disk_path.to_owned(); + let disk_path_clone = disk_path.clone(); + let protect = protect.map(|p| p.to_owned()); + let sectors = (capacity / 512) as u32; + let data_part = tokio::task::spawn_blocking(move || { + let disk_path = disk_path_clone; + + // If protecting a partition, read its location from the existing MBR + let protected_partition_info: Option<(u32, u32, PathBuf)> = + if let Some(ref protect_path) = protect { + let mut file = std::fs::File::options().read(true).open(&disk_path)?; + let existing_mbr = MBR::read_from(&mut file, 512)?; + // Find the partition matching the protected path (check partitions 1-4) + let info = (1..=4u32) + .find(|&idx| partition_for(&disk_path, idx) == *protect_path) + .and_then(|idx| { + let entry = &existing_mbr[idx as usize]; + if entry.sectors > 0 { + Some((entry.starting_lba, entry.sectors, protect_path.clone())) + } else { + None } - *entry = MBRPartitionEntry::empty(); - } + }); + if info.is_none() { + return Err(Error::new( + eyre!( + "Protected partition {} not found in MBR on {}", + protect_path.display(), + disk_path.display() + ), + crate::ErrorKind::NotFound, + )); } - (mbr, guid_part) + info + } else { + None }; - mbr[1] = MBRPartitionEntry { - boot: 0x80, - first_chs: CHS::empty(), - sys: 0x0b, - last_chs: CHS::empty(), - starting_lba: 2048, - sectors: 2099200 - 2048, - }; - mbr[2] = MBRPartitionEntry { + // MBR partition layout: + // Partition 1 (boot): starts at 2048, ends at 4196352 (sectors: 4194304 = 2GB) + // Partition 2 (root): starts at 4196352, ends at 33556480 (sectors: 29360128 = 14GB) + // OS partitions end at sector 33556480 + let os_partitions_end_sector: u32 = 33556480; + + // Check if protected partition would be overwritten + if let Some((starting_lba, _, ref path)) = protected_partition_info { + if starting_lba < os_partitions_end_sector { + return Err(Error::new( + eyre!( + concat!( + "Protected partition {} starts at sector {}", + " which would be overwritten by OS partitions ending at sector {}" + ), + path.display(), + starting_lba, + os_partitions_end_sector + ), + crate::ErrorKind::DiskManagement, + )); + } + } + + let mut file = std::fs::File::options() + .read(true) + .write(true) + .open(&disk_path)?; + let mut mbr = MBR::new_from(&mut file, 512, rand::random())?; + + mbr[1] = MBRPartitionEntry { + boot: 0x80, + first_chs: CHS::empty(), + sys: 0x0b, + last_chs: CHS::empty(), + starting_lba: 2048, + sectors: 4196352 - 2048, + }; + mbr[2] = MBRPartitionEntry { + boot: 0, + first_chs: CHS::empty(), + sys: 0x83, + last_chs: CHS::empty(), + starting_lba: 4196352, + sectors: 33556480 - 4196352, + }; + + let data_part = if let Some((starting_lba, part_sectors, path)) = protected_partition_info { + // Re-create the data partition entry at the same location + mbr[3] = MBRPartitionEntry { boot: 0, first_chs: CHS::empty(), - sys: 0x83, + sys: 0x8e, last_chs: CHS::empty(), - starting_lba: 2099200, - sectors: 33556480 - 2099200, + starting_lba, + sectors: part_sectors, }; + Some(path) + } else { + mbr[3] = MBRPartitionEntry { + boot: 0, + first_chs: CHS::empty(), + sys: 0x8e, + last_chs: CHS::empty(), + starting_lba: 33556480, + sectors: sectors - 33556480, + }; + Some(partition_for(&disk_path, 3)) + }; + mbr.write_into(&mut file)?; - if overwrite { - mbr[3] = MBRPartitionEntry { - boot: 0, - first_chs: CHS::empty(), - sys: 0x8e, - last_chs: CHS::empty(), - starting_lba: 33556480, - sectors: sectors - 33556480, - } - } else if let Some(guid_part) = guid_part { - mbr[3] = guid_part; - } - mbr.write_into(&mut file)?; + Ok::<_, Error>(data_part) + }) + .await + .unwrap()?; - Ok(()) - }) + // Re-read partition table and wait for udev to create device nodes + Command::new("vgchange") + .arg("-an") + .invoke(crate::ErrorKind::DiskManagement) .await - .unwrap()?; - } + .ok(); + Command::new("dmsetup") + .arg("remove_all") + .arg("--force") + .invoke(crate::ErrorKind::DiskManagement) + .await + .ok(); + Command::new("blockdev") + .arg("--rereadpt") + .arg(&disk_path) + .invoke(crate::ErrorKind::DiskManagement) + .await?; + Command::new("udevadm") + .arg("settle") + .invoke(crate::ErrorKind::DiskManagement) + .await?; Ok(OsPartitionInfo { efi: None, bios: None, - boot: partition_for(&disk.logicalname, 1), - root: partition_for(&disk.logicalname, 2), + boot: partition_for(&disk_path, 1), + root: partition_for(&disk_path, 2), + data: data_part, }) } diff --git a/core/src/os_install/mod.rs b/core/src/os_install/mod.rs index 649397322..1e823c754 100644 --- a/core/src/os_install/mod.rs +++ b/core/src/os_install/mod.rs @@ -2,13 +2,13 @@ use std::path::{Path, PathBuf}; use clap::Parser; use color_eyre::eyre::eyre; -use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; use tokio::process::Command; use ts_rs::TS; +use crate::Error; use crate::context::config::ServerConfig; -use crate::context::{CliContext, InstallContext}; +use crate::context::{CliContext, SetupContext}; use crate::disk::OsPartitionInfo; use crate::disk::mount::filesystem::bind::Bind; use crate::disk::mount::filesystem::block_dev::BlockDev; @@ -16,81 +16,31 @@ use crate::disk::mount::filesystem::efivarfs::EfiVarFs; use crate::disk::mount::filesystem::overlayfs::OverlayFs; use crate::disk::mount::filesystem::{MountType, ReadWrite}; use crate::disk::mount::guard::{GenericMountGuard, MountGuard, TmpMountGuard}; -use crate::disk::util::{DiskInfo, PartitionTable}; -use crate::net::utils::find_eth_iface; +use crate::disk::util::PartitionTable; use crate::prelude::*; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; +use crate::setup::SetupInfo; use crate::util::Invoke; -use crate::util::io::{TmpDir, delete_file, open_file}; +use crate::util::io::{TmpDir, delete_file, open_file, write_file_atomic}; use crate::util::serde::IoFormat; -use crate::{ARCH, Error}; mod gpt; mod mbr; -pub fn install() -> ParentHandler { - ParentHandler::new() - .subcommand("disk", disk::().with_about("Command to list disk info")) - .subcommand( - "execute", - from_fn_async(execute::) - .no_display() - .with_about("Install StartOS over existing version") - .with_call_remote::(), - ) - .subcommand( - "reboot", - from_fn_async(reboot) - .no_display() - .with_about("Restart the server") - .with_call_remote::(), - ) +/// Probe a squashfs image to determine its target architecture +async fn probe_squashfs_arch(squashfs_path: &Path) -> Result { + let output = String::from_utf8( + Command::new("unsquashfs") + .arg("-cat") + .arg(squashfs_path) + .arg("usr/lib/startos/PLATFORM.txt") + .invoke(ErrorKind::ParseSysInfo) + .await?, + )?; + Ok(crate::platform_to_arch(&output.trim()).into()) } -pub fn disk() -> ParentHandler { - ParentHandler::new().subcommand( - "list", - from_fn_async(list) - .no_display() - .with_about("List disk info") - .with_call_remote::(), - ) -} - -pub async fn list(_: InstallContext) -> Result, Error> { - let skip = match async { - Ok::<_, Error>( - Path::new( - &String::from_utf8( - Command::new("grub-probe-default") - .arg("-t") - .arg("disk") - .arg("/run/live/medium") - .invoke(crate::ErrorKind::Grub) - .await?, - )? - .trim(), - ) - .to_owned(), - ) - } - .await - { - Ok(a) => Some(a), - Err(e) => { - tracing::error!("Could not determine live usb device: {}", e); - tracing::debug!("{:?}", e); - None - } - }; - Ok(crate::disk::util::list(&Default::default()) - .await? - .into_iter() - .filter(|i| Some(&*i.logicalname) != skip.as_deref()) - .collect()) -} - -pub fn partition_for(disk: impl AsRef, idx: usize) -> PathBuf { +pub fn partition_for(disk: impl AsRef, idx: u32) -> PathBuf { let disk_path = disk.as_ref(); let (root, leaf) = if let (Some(root), Some(leaf)) = ( disk_path.parent(), @@ -107,49 +57,90 @@ pub fn partition_for(disk: impl AsRef, idx: usize) -> PathBuf { } } -async fn partition(disk: &mut DiskInfo, overwrite: bool) -> Result { - let partition_type = match (overwrite, disk.partition_table) { +async fn partition( + disk_path: &Path, + capacity: u64, + partition_table: Option, + protect: Option<&Path>, + use_efi: bool, +) -> Result { + let partition_type = match (protect.is_none(), partition_table) { (true, _) | (_, None) => PartitionTable::Gpt, (_, Some(t)) => t, }; - disk.partition_table = Some(partition_type); match partition_type { - PartitionTable::Gpt => gpt::partition(disk, overwrite).await, - PartitionTable::Mbr => mbr::partition(disk, overwrite).await, + PartitionTable::Gpt => gpt::partition(disk_path, capacity, protect, use_efi).await, + PartitionTable::Mbr => mbr::partition(disk_path, capacity, protect).await, } } +async fn get_block_device_size(path: impl AsRef) -> Result { + let path = path.as_ref(); + let device_name = path.file_name().and_then(|s| s.to_str()).ok_or_else(|| { + Error::new( + eyre!("Invalid block device path: {}", path.display()), + ErrorKind::BlockDevice, + ) + })?; + let size_path = Path::new("/sys/block").join(device_name).join("size"); + let sectors: u64 = tokio::fs::read_to_string(&size_path) + .await + .with_ctx(|_| { + ( + ErrorKind::BlockDevice, + format!("reading {}", size_path.display()), + ) + })? + .trim() + .parse() + .map_err(|e| { + Error::new( + eyre!("Failed to parse block device size: {}", e), + ErrorKind::BlockDevice, + ) + })?; + Ok(sectors * 512) +} + #[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] -pub struct ExecuteParams { - logicalname: PathBuf, - #[arg(short = 'o')] - overwrite: bool, +pub struct InstallOsParams { + #[arg(help = "help.arg.os-drive-path")] + os_drive: PathBuf, + #[command(flatten)] + data_drive: Option, } -pub async fn execute( - _: C, - ExecuteParams { - logicalname, - mut overwrite, - }: ExecuteParams, -) -> Result<(), Error> { - let mut disk = crate::disk::util::list(&Default::default()) - .await? - .into_iter() - .find(|d| &d.logicalname == &logicalname) - .ok_or_else(|| { - Error::new( - eyre!("Unknown disk {}", logicalname.display()), - crate::ErrorKind::DiskManagement, - ) - })?; - let eth_iface = find_eth_iface().await?; +#[derive(Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[command(rename_all = "kebab-case")] +struct DataDrive { + #[arg(long = "data-drive", help = "help.arg.data-drive-path")] + logicalname: PathBuf, + #[arg(long, help = "help.arg.wipe-drive")] + wipe: bool, +} - overwrite |= disk.guid.is_none() && disk.partitions.iter().all(|p| p.guid.is_none()); +pub struct InstallOsResult { + pub part_info: OsPartitionInfo, + pub rootfs: TmpMountGuard, +} - let part_info = partition(&mut disk, overwrite).await?; +pub async fn install_os_to( + squashfs_path: impl AsRef, + disk_path: impl AsRef, + capacity: u64, + partition_table: Option, + protect: Option>, + arch: &str, + use_efi: bool, +) -> Result { + let squashfs_path = squashfs_path.as_ref(); + let disk_path = disk_path.as_ref(); + let protect = protect.as_ref().map(|p| p.as_ref()); + + let part_info = partition(disk_path, capacity, partition_table, protect, use_efi).await?; if let Some(efi) = &part_info.efi { Command::new("mkfs.vfat") @@ -173,7 +164,7 @@ pub async fn execute( .invoke(crate::ErrorKind::DiskManagement) .await?; - if !overwrite { + if protect.is_some() { if let Ok(guard) = TmpMountGuard::mount(&BlockDev::new(part_info.root.clone()), MountType::ReadWrite).await { @@ -234,13 +225,13 @@ pub async fn execute( tokio::fs::create_dir_all(&images_path).await?; let image_path = images_path .join(hex::encode( - &MultiCursorFile::from(open_file("/run/live/medium/live/filesystem.squashfs").await?) + &MultiCursorFile::from(open_file(squashfs_path).await?) .blake3_mmap() .await? .as_bytes()[..16], )) .with_extension("rootfs"); - tokio::fs::copy("/run/live/medium/live/filesystem.squashfs", &image_path).await?; + tokio::fs::copy(squashfs_path, &image_path).await?; // TODO: check hash of fs let unsquash_target = TmpDir::new().await?; let bootfs = MountGuard::mount( @@ -254,7 +245,7 @@ pub async fn execute( .arg("-f") .arg("-d") .arg(&*unsquash_target) - .arg("/run/live/medium/live/filesystem.squashfs") + .arg(squashfs_path) .arg("boot") .invoke(crate::ErrorKind::Filesystem) .await?; @@ -271,7 +262,6 @@ pub async fn execute( rootfs.path().join("config/config.yaml"), IoFormat::Yaml.to_vec(&ServerConfig { os_partitions: Some(part_info.clone()), - ethernet_interface: Some(eth_iface), ..Default::default() })?, ) @@ -357,13 +347,13 @@ pub async fn execute( let mut install = Command::new("chroot"); install.arg(overlay.path()).arg("grub-install"); - if tokio::fs::metadata("/sys/firmware/efi").await.is_err() { - match ARCH { + if !use_efi { + match arch { "x86_64" => install.arg("--target=i386-pc"), _ => &mut install, }; } else { - match ARCH { + match arch { "x86_64" => install.arg("--target=x86_64-efi"), "aarch64" => install.arg("--target=arm64-efi"), "riscv64" => install.arg("--target=riscv64-efi"), @@ -371,7 +361,7 @@ pub async fn execute( }; } install - .arg(&disk.logicalname) + .arg(disk_path) .invoke(crate::ErrorKind::Grub) .await?; @@ -396,15 +386,158 @@ pub async fn execute( tokio::fs::remove_dir_all(&work).await?; lower.unmount().await?; + Ok(InstallOsResult { part_info, rootfs }) +} + +pub async fn install_os( + ctx: SetupContext, + InstallOsParams { + os_drive, + data_drive, + }: InstallOsParams, +) -> Result { + let mut disks = crate::disk::util::list(&Default::default()).await?; + let disk = disks + .iter_mut() + .find(|d| &d.logicalname == &os_drive) + .ok_or_else(|| { + Error::new( + eyre!("Unknown disk {}", os_drive.display()), + crate::ErrorKind::DiskManagement, + ) + })?; + + let protect: Option = data_drive.as_ref().and_then(|dd| { + if dd.wipe { + return None; + } + if disk.guid.as_ref().map_or(false, |g| { + g.starts_with("EMBASSY_") || g.starts_with("STARTOS_") + }) && disk.logicalname == dd.logicalname + { + return Some(disk.logicalname.clone()); + } + disk.partitions + .iter() + .find(|p| { + p.guid.as_ref().map_or(false, |g| { + g.starts_with("EMBASSY_") || g.starts_with("STARTOS_") + }) + }) + .map(|p| p.logicalname.clone()) + }); + + let use_efi = tokio::fs::metadata("/sys/firmware/efi").await.is_ok(); + let InstallOsResult { part_info, rootfs } = install_os_to( + "/run/live/medium/live/filesystem.squashfs", + &disk.logicalname, + disk.capacity, + disk.partition_table, + protect.as_ref(), + crate::ARCH, + use_efi, + ) + .await?; + + ctx.config + .mutate(|c| c.os_partitions = Some(part_info.clone())); + + let mut setup_info = SetupInfo::default(); + + if let Some(data_drive) = data_drive { + let mut logicalname = &*data_drive.logicalname; + if logicalname == &os_drive { + logicalname = part_info.data.as_deref().ok_or_else(|| { + Error::new( + eyre!("not enough room on OS drive for data"), + ErrorKind::InvalidRequest, + ) + })?; + } + if let Some(guid) = (!data_drive.wipe) + .then(|| disks.iter()) + .into_iter() + .flatten() + .find_map(|d| { + d.guid + .as_ref() + .filter(|_| &d.logicalname == logicalname) + .cloned() + .or_else(|| { + d.partitions.iter().find_map(|p| { + p.guid + .as_ref() + .filter(|_| &p.logicalname == logicalname) + .cloned() + }) + }) + }) + { + setup_info.guid = Some(guid); + setup_info.attach = true; + } else { + let guid = crate::setup::setup_data_drive(&ctx, logicalname).await?; + setup_info.guid = Some(guid); + } + } + + let config = MountGuard::mount( + &Bind::new(rootfs.path().join("config")), + "/media/startos/config", + ReadWrite, + ) + .await?; + + write_file_atomic( + "/media/startos/config/setup.json", + IoFormat::JsonPretty.to_vec(&setup_info)?, + ) + .await?; + + ctx.install_rootfs.replace(Some((rootfs, config))); + + Ok(setup_info) +} + +#[derive(Deserialize, Serialize, Parser)] +#[serde(rename_all = "camelCase")] +#[command(rename_all = "kebab-case")] +pub struct CliInstallOsParams { + #[arg(help = "help.arg.squashfs-image-path")] + squashfs: PathBuf, + #[arg(help = "help.arg.target-disk")] + disk: PathBuf, + #[arg(long, help = "help.arg.use-efi-boot")] + efi: Option, +} + +pub async fn cli_install_os( + _ctx: CliContext, + CliInstallOsParams { + squashfs, + disk, + efi, + }: CliInstallOsParams, +) -> Result { + let capacity = get_block_device_size(&disk).await?; + let partition_table = crate::disk::util::get_partition_table(&disk).await?; + + let arch = probe_squashfs_arch(&squashfs).await?; + + let use_efi = efi.unwrap_or_else(|| !matches!(partition_table, Some(PartitionTable::Mbr))); + + let InstallOsResult { part_info, rootfs } = install_os_to( + &squashfs, + &disk, + capacity, + partition_table, + None::<&str>, + &*arch, + use_efi, + ) + .await?; + rootfs.unmount().await?; - Ok(()) -} - -pub async fn reboot(ctx: InstallContext) -> Result<(), Error> { - Command::new("sync") - .invoke(crate::ErrorKind::Filesystem) - .await?; - ctx.shutdown.send(()).unwrap(); - Ok(()) + Ok(part_info) } diff --git a/core/src/prelude.rs b/core/src/prelude.rs index 369890500..e2adf9eeb 100644 --- a/core/src/prelude.rs +++ b/core/src/prelude.rs @@ -1,6 +1,7 @@ pub use color_eyre::eyre::eyre; pub use imbl_value::InternedString; pub use lazy_format::lazy_format; +pub use rust_i18n::t; pub use tracing::instrument; pub use crate::db::prelude::*; diff --git a/core/src/registry/admin.rs b/core/src/registry/admin.rs index 21a1f5e51..a9ed78c66 100644 --- a/core/src/registry/admin.rs +++ b/core/src/registry/admin.rs @@ -21,7 +21,7 @@ pub fn admin_api() -> ParentHandler { ParentHandler::new() .subcommand( "signer", - signers_api::().with_about("Commands to add or list signers"), + signers_api::().with_about("about.commands-add-list-signers"), ) .subcommand( "add", @@ -33,14 +33,14 @@ pub fn admin_api() -> ParentHandler { "add", from_fn_async(cli_add_admin) .no_display() - .with_about("Add admin signer"), + .with_about("about.add-admin-signer"), ) .subcommand( "remove", from_fn_async(remove_admin) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Remove an admin signer") + .with_about("about.remove-admin-signer") .with_call_remote::(), ) .subcommand( @@ -49,7 +49,7 @@ pub fn admin_api() -> ParentHandler { .with_metadata("admin", Value::Bool(true)) .with_display_serializable() .with_custom_display_fn(|handle, result| display_signers(handle.params, result)) - .with_about("List admin signers") + .with_about("about.list-admin-signers") .with_call_remote::(), ) } @@ -62,7 +62,7 @@ fn signers_api() -> ParentHandler { .with_metadata("admin", Value::Bool(true)) .with_display_serializable() .with_custom_display_fn(|handle, result| display_signers(handle.params, result)) - .with_about("List signers") + .with_about("about.list-signers") .with_call_remote::(), ) .subcommand( @@ -73,13 +73,14 @@ fn signers_api() -> ParentHandler { ) .subcommand( "add", - from_fn_async(cli_add_signer).with_about("Add signer"), + from_fn_async(cli_add_signer).with_about("about.add-signer"), ) .subcommand( "edit", from_fn_async(edit_signer) .with_metadata("admin", Value::Bool(true)) .no_display() + .with_about("about.edit-signer") .with_call_remote::(), ) } @@ -93,7 +94,7 @@ impl Model> { .next() .transpose()? .map(|(a, _)| a) - .ok_or_else(|| Error::new(eyre!("unknown signer"), ErrorKind::Authorization)) + .ok_or_else(|| Error::new(eyre!("{}", t!("registry.admin.unknown-signer")), ErrorKind::Authorization)) } pub fn get_signer_info(&self, key: &AnyVerifyingKey) -> Result<(Guid, SignerInfo), Error> { @@ -103,7 +104,7 @@ impl Model> { .filter_ok(|(_, s)| s.keys.contains(key)) .next() .transpose()? - .ok_or_else(|| Error::new(eyre!("unknown signer"), ErrorKind::Authorization)) + .ok_or_else(|| Error::new(eyre!("{}", t!("registry.admin.unknown-signer")), ErrorKind::Authorization)) } pub fn add_signer(&mut self, signer: &SignerInfo) -> Result { @@ -117,9 +118,8 @@ impl Model> { { return Err(Error::new( eyre!( - "A signer {} ({}) already exists with a matching key", - guid, - s.name + "{}", + t!("registry.admin.signer-already-exists", guid = guid, name = s.name) ), ErrorKind::InvalidRequest, )); @@ -206,16 +206,17 @@ pub async fn add_signer(ctx: RegistryContext, signer: SignerInfo) -> Result, - #[arg(short = 'c', long)] + #[arg(short = 'c', long, help = "help.arg.add-signer-contact")] pub add_contact: Vec, - #[arg(short = 'k', long)] + #[arg(short = 'k', long, help = "help.arg.add-signer-key")] pub add_key: Vec, - #[arg(short = 'C', long)] + #[arg(short = 'C', long, help = "help.arg.remove-signer-contact")] pub remove_contact: Vec, - #[arg(short = 'K', long)] + #[arg(short = 'K', long, help = "help.arg.remove-signer-key")] pub remove_key: Vec, } @@ -264,12 +265,13 @@ pub async fn edit_signer( #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliAddSignerParams { - #[arg(long = "name", short = 'n')] + #[arg(long = "name", short = 'n', help = "help.arg.signer-name")] pub name: String, - #[arg(long = "contact", short = 'c')] + #[arg(long = "contact", short = 'c', help = "help.arg.signer-contact")] pub contact: Vec, - #[arg(long = "key")] + #[arg(long = "key", help = "help.arg.signer-key")] pub keys: Vec, + #[arg(help = "help.arg.database-path")] pub database: Option, } @@ -339,6 +341,7 @@ pub async fn add_admin( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct RemoveAdminParams { + #[arg(help = "help.arg.signer-id")] pub signer: Guid, } @@ -360,7 +363,9 @@ pub async fn remove_admin( #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliAddAdminParams { + #[arg(help = "help.arg.signer-id")] pub signer: Guid, + #[arg(help = "help.arg.database-path")] pub database: Option, } diff --git a/core/src/registry/asset.rs b/core/src/registry/asset.rs index 03f320c41..936e29d81 100644 --- a/core/src/registry/asset.rs +++ b/core/src/registry/asset.rs @@ -49,7 +49,7 @@ impl RegistryAsset { } } Err(Error::new( - eyre!("Failed to load any http url"), + eyre!("{}", t!("registry.asset.failed-to-load-http-url")), ErrorKind::Network, )) } @@ -64,7 +64,7 @@ impl RegistryAsset { } } Err(Error::new( - eyre!("Failed to load any http url"), + eyre!("{}", t!("registry.asset.failed-to-load-http-url")), ErrorKind::Network, )) } @@ -80,7 +80,7 @@ impl RegistryAsset { } } Err(Error::new( - eyre!("Failed to load any http url"), + eyre!("{}", t!("registry.asset.failed-to-load-http-url")), ErrorKind::Network, )) } diff --git a/core/src/registry/context.rs b/core/src/registry/context.rs index dd598b27d..033abe9a9 100644 --- a/core/src/registry/context.rs +++ b/core/src/registry/context.rs @@ -42,17 +42,17 @@ const DEFAULT_REGISTRY_LISTEN: SocketAddr = #[serde(rename_all = "kebab-case")] #[command(rename_all = "kebab-case")] pub struct RegistryConfig { - #[arg(short = 'c', long = "config")] + #[arg(short = 'c', long = "config", help = "help.arg.config-file-path")] pub config: Option, - #[arg(short = 'l', long = "listen")] + #[arg(short = 'l', long = "listen", help = "help.arg.registry-listen-address")] pub registry_listen: Option, - #[arg(short = 'H', long = "hostname")] + #[arg(short = 'H', long = "hostname", help = "help.arg.registry-hostname")] pub registry_hostname: Vec, - #[arg(short = 'p', long = "tor-proxy")] + #[arg(short = 'p', long = "tor-proxy", help = "help.arg.tor-proxy-url")] pub tor_proxy: Option, - #[arg(short = 'd', long = "datadir")] + #[arg(short = 'd', long = "datadir", help = "help.arg.data-directory")] pub datadir: Option, - #[arg(short = 'u', long = "pg-connection-url")] + #[arg(short = 'u', long = "pg-connection-url", help = "help.arg.postgres-connection-url")] pub pg_connection_url: Option, } impl ContextConfig for RegistryConfig { @@ -124,7 +124,7 @@ impl RegistryContext { }; if config.registry_hostname.is_empty() { return Err(Error::new( - eyre!("missing required configuration: registry-hostname"), + eyre!("{}", t!("registry.context.missing-hostname")), ErrorKind::NotFound, )); } @@ -165,6 +165,7 @@ impl Deref for RegistryContext { #[derive(Debug, Deserialize, Serialize, Parser)] pub struct RegistryUrlParams { + #[arg(help = "help.arg.registry-url")] pub registry: Url, } @@ -195,7 +196,7 @@ impl CallRemote for CliContext { url } else { return Err( - Error::new(eyre!("`--registry` required"), ErrorKind::InvalidRequest).into(), + Error::new(eyre!("{}", t!("registry.context.registry-required")), ErrorKind::InvalidRequest).into(), ); }; @@ -330,7 +331,7 @@ impl SignatureAuthContext for RegistryContext { } } - Err(Error::new(eyre!("UNAUTHORIZED"), ErrorKind::Authorization)) + Err(Error::new(eyre!("{}", t!("registry.context.unauthorized")), ErrorKind::Authorization)) } async fn post_auth_hook( &self, diff --git a/core/src/registry/db.rs b/core/src/registry/db.rs index 3c3da4c12..2d3498cac 100644 --- a/core/src/registry/db.rs +++ b/core/src/registry/db.rs @@ -22,7 +22,7 @@ pub fn db_api() -> ParentHandler { "dump", from_fn_async(cli_dump) .with_display_serializable() - .with_about("Filter/query db to display tables and records"), + .with_about("about.filter-query-db"), ) .subcommand( "dump", @@ -34,7 +34,7 @@ pub fn db_api() -> ParentHandler { "apply", from_fn_async(cli_apply) .no_display() - .with_about("Update a db record"), + .with_about("about.update-db-record"), ) .subcommand( "apply", @@ -48,8 +48,9 @@ pub fn db_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CliDumpParams { - #[arg(long = "pointer", short = 'p')] + #[arg(long = "pointer", short = 'p', help = "help.arg.db-pointer")] pointer: Option, + #[arg(help = "help.arg.database-path")] path: Option, } @@ -81,7 +82,7 @@ async fn cli_dump( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct DumpParams { - #[arg(long = "pointer", short = 'p')] + #[arg(long = "pointer", short = 'p', help = "help.arg.db-pointer")] #[ts(type = "string | null")] pointer: Option, } @@ -97,7 +98,9 @@ pub async fn dump(ctx: RegistryContext, DumpParams { pointer }: DumpParams) -> R #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CliApplyParams { + #[arg(help = "help.arg.db-apply-expr")] expr: String, + #[arg(help = "help.arg.database-path")] path: Option, } @@ -152,7 +155,9 @@ async fn cli_apply( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ApplyParams { + #[arg(help = "help.arg.db-apply-expr")] expr: String, + #[arg(help = "help.arg.database-path")] path: Option, } diff --git a/core/src/registry/device_info.rs b/core/src/registry/device_info.rs index 08f233936..d2deff41a 100644 --- a/core/src/registry/device_info.rs +++ b/core/src/registry/device_info.rs @@ -44,28 +44,44 @@ impl DeviceInfo { impl DeviceInfo { pub fn to_header_value(&self) -> HeaderValue { let mut url: Url = "http://localhost".parse().unwrap(); - url.query_pairs_mut() - .append_pair("os.version", &self.os.version.to_string()) + let mut qp = url.query_pairs_mut(); + qp.append_pair("os.version", &self.os.version.to_string()) .append_pair("os.compat", &self.os.compat.to_string()) .append_pair("os.platform", &*self.os.platform); + if let Some(lang) = self.os.language.as_deref() { + qp.append_pair("os.language", lang); + } + drop(qp); HeaderValue::from_str(url.query().unwrap_or_default()).unwrap() } pub fn from_header_value(header: &HeaderValue) -> Result { let query: BTreeMap<_, _> = form_urlencoded::parse(header.as_bytes()).collect(); let has_hw_info = query.keys().any(|k| k.starts_with("hardware.")); + let version = query + .get("os.version") + .or_not_found("os.version")? + .parse()?; Ok(Self { os: OsInfo { - version: query - .get("os.version") - .or_not_found("os.version")? - .parse()?, compat: query.get("os.compat").or_not_found("os.compat")?.parse()?, platform: query .get("os.platform") .or_not_found("os.platform")? .deref() .into(), + language: query + .get("os.language") + .map(|v| v.deref()) + .map(InternedString::intern) + .or_else(|| { + if version < "0.4.0-alpha.18".parse().ok()? { + Some(rust_i18n::locale().deref().into()) + } else { + None + } + }), + version, }, hardware: has_hw_info .then(|| { @@ -190,8 +206,8 @@ pub struct OsInfo { pub version: Version, #[ts(type = "string")] pub compat: VersionRange, - #[ts(type = "string")] pub platform: InternedString, + pub language: Option, } impl From<&RpcContext> for OsInfo { fn from(_: &RpcContext) -> Self { @@ -199,6 +215,7 @@ impl From<&RpcContext> for OsInfo { version: crate::version::Current::default().semver(), compat: crate::version::Current::default().compat().clone(), platform: InternedString::intern(&*crate::PLATFORM), + language: Some(InternedString::intern(&*rust_i18n::locale())), } } } diff --git a/core/src/registry/info.rs b/core/src/registry/info.rs index 5d18885e4..83742fa0c 100644 --- a/core/src/registry/info.rs +++ b/core/src/registry/info.rs @@ -21,7 +21,7 @@ pub fn info_api() -> ParentHandler> { from_fn_async(get_info) .with_metadata("authenticated", Value::Bool(false)) .with_display_serializable() - .with_about("Display registry name, icon, and package categories") + .with_about("about.display-registry-info") .with_call_remote::(), ) .subcommand( @@ -29,7 +29,7 @@ pub fn info_api() -> ParentHandler> { from_fn_async(set_name) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Set the name for the registry") + .with_about("about.set-registry-name") .with_call_remote::(), ) .subcommand( @@ -42,7 +42,7 @@ pub fn info_api() -> ParentHandler> { "set-icon", from_fn_async(cli_set_icon) .no_display() - .with_about("Set the icon for the registry"), + .with_about("about.set-registry-icon"), ) } @@ -69,6 +69,7 @@ pub async fn get_info(ctx: RegistryContext) -> Result { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct SetNameParams { + #[arg(help = "help.arg.registry-name")] pub name: String, } @@ -104,6 +105,7 @@ pub async fn set_icon( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct CliSetIconParams { + #[arg(help = "help.arg.icon-path")] pub icon: PathBuf, } diff --git a/core/src/registry/mod.rs b/core/src/registry/mod.rs index bed299586..b0cbb1326 100644 --- a/core/src/registry/mod.rs +++ b/core/src/registry/mod.rs @@ -76,26 +76,26 @@ pub fn registry_api() -> ParentHandler { "index", from_fn_async(get_full_index) .with_display_serializable() - .with_about("List info including registry name and packages") + .with_about("about.list-registry-info-packages") .with_call_remote::(), ) .subcommand("info", info::info_api::()) // set info and categories .subcommand( "os", - os::os_api::().with_about("Commands related to OS assets and versions"), + os::os_api::().with_about("about.commands-os-assets-versions"), ) .subcommand( "package", - package::package_api::().with_about("Commands to index, add, or get packages"), + package::package_api::().with_about("about.commands-index-add-get-packages"), ) .subcommand( "admin", - admin::admin_api::().with_about("Commands to add or list admins or signers"), + admin::admin_api::().with_about("about.commands-add-list-admins-signers"), ) .subcommand( "db", - db::db_api::().with_about("Commands to interact with the db i.e. dump and apply"), + db::db_api::().with_about("about.commands-registry-db"), ) } diff --git a/core/src/registry/os/asset/add.rs b/core/src/registry/os/asset/add.rs index 0f1d2f061..37d78b8bb 100644 --- a/core/src/registry/os/asset/add.rs +++ b/core/src/registry/os/asset/add.rs @@ -141,7 +141,7 @@ async fn add_asset( .mutate(|s| { if s.commitment != commitment { Err(Error::new( - eyre!("commitment does not match"), + eyre!("{}", t!("registry.os.asset.commitment-mismatch")), ErrorKind::InvalidSignature, )) } else { @@ -154,7 +154,7 @@ async fn add_asset( })?; Ok(()) } else { - Err(Error::new(eyre!("UNAUTHORIZED"), ErrorKind::Authorization)) + Err(Error::new(eyre!("{}", t!("registry.os.asset.unauthorized")), ErrorKind::Authorization)) } }) .await @@ -179,11 +179,13 @@ pub async fn add_squashfs(ctx: RegistryContext, params: AddAssetParams) -> Resul #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliAddAssetParams { - #[arg(short = 'p', long = "platform")] + #[arg(short = 'p', long = "platform", help = "help.arg.platform")] pub platform: InternedString, - #[arg(short = 'v', long = "version")] + #[arg(short = 'v', long = "version", help = "help.arg.os-version")] pub version: Version, + #[arg(help = "help.arg.asset-file-path")] pub file: PathBuf, + #[arg(help = "help.arg.asset-url")] pub url: Url, } @@ -208,7 +210,7 @@ pub async fn cli_add_asset( Some("squashfs") => "squashfs", _ => { return Err(Error::new( - eyre!("Unknown extension"), + eyre!("{}", t!("registry.os.asset.unknown-extension")), ErrorKind::InvalidRequest, )); } @@ -232,7 +234,7 @@ pub async fn cli_add_asset( let size = file .size() .await - .ok_or_else(|| Error::new(eyre!("failed to read file metadata"), ErrorKind::Filesystem))?; + .ok_or_else(|| Error::new(eyre!("{}", t!("registry.os.asset.failed-read-metadata")), ErrorKind::Filesystem))?; let commitment = Blake3Commitment { hash: Base64(*blake3.as_bytes()), size, @@ -334,7 +336,7 @@ async fn remove_asset( .remove(&platform)?; Ok(()) } else { - Err(Error::new(eyre!("UNAUTHORIZED"), ErrorKind::Authorization)) + Err(Error::new(eyre!("{}", t!("registry.os.asset.unauthorized")), ErrorKind::Authorization)) } }) .await diff --git a/core/src/registry/os/asset/get.rs b/core/src/registry/os/asset/get.rs index 5904e9b0d..d48bbd7ef 100644 --- a/core/src/registry/os/asset/get.rs +++ b/core/src/registry/os/asset/get.rs @@ -34,7 +34,7 @@ pub fn get_api() -> ParentHandler { "iso", from_fn_async(cli_get_os_asset) .no_display() - .with_about("Download iso"), + .with_about("about.download-iso"), ) .subcommand( "img", @@ -46,7 +46,7 @@ pub fn get_api() -> ParentHandler { "img", from_fn_async(cli_get_os_asset) .no_display() - .with_about("Download img"), + .with_about("about.download-img"), ) .subcommand( "squashfs", @@ -58,7 +58,7 @@ pub fn get_api() -> ParentHandler { "squashfs", from_fn_async(cli_get_os_asset) .no_display() - .with_about("Download squashfs"), + .with_about("about.download-squashfs"), ) } @@ -121,18 +121,20 @@ pub async fn get_squashfs( #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliGetOsAssetParams { + #[arg(help = "help.arg.os-version")] pub version: Version, + #[arg(help = "help.arg.platform")] pub platform: InternedString, #[arg( long = "download", short = 'd', - help = "The path of the directory to download to" + help = "help.arg.download-directory" )] pub download: Option, #[arg( long = "reverify", short = 'r', - help = "verify the hash of the file a second time after download" + help = "help.arg.reverify-hash" )] pub reverify: bool, } diff --git a/core/src/registry/os/asset/mod.rs b/core/src/registry/os/asset/mod.rs index 9fbb193f2..a2fcd72a8 100644 --- a/core/src/registry/os/asset/mod.rs +++ b/core/src/registry/os/asset/mod.rs @@ -11,7 +11,7 @@ pub fn asset_api() -> ParentHandler { "add", from_fn_async(add::cli_add_asset) .no_display() - .with_about("Add asset to registry"), + .with_about("about.add-asset-registry"), ) .subcommand("remove", add::remove_api::()) .subcommand("sign", sign::sign_api::()) @@ -19,10 +19,10 @@ pub fn asset_api() -> ParentHandler { "sign", from_fn_async(sign::cli_sign_asset) .no_display() - .with_about("Sign file and add to registry index"), + .with_about("about.sign-file-add-registry"), ) .subcommand( "get", - get::get_api::().with_about("Commands to download image, iso, or squashfs files"), + get::get_api::().with_about("about.commands-download-assets"), ) } diff --git a/core/src/registry/os/asset/sign.rs b/core/src/registry/os/asset/sign.rs index 68c0f571c..c71759470 100644 --- a/core/src/registry/os/asset/sign.rs +++ b/core/src/registry/os/asset/sign.rs @@ -89,7 +89,7 @@ async fn sign_asset( .contains(&guid) { return Err(Error::new( - eyre!("signer {guid} is not authorized"), + eyre!("{}", t!("registry.os.asset.signer-not-authorized", guid = guid)), ErrorKind::Authorization, )); } @@ -136,10 +136,11 @@ pub async fn sign_squashfs(ctx: RegistryContext, params: SignAssetParams) -> Res #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliSignAssetParams { - #[arg(short = 'p', long = "platform")] + #[arg(short = 'p', long = "platform", help = "help.arg.platform")] pub platform: InternedString, - #[arg(short = 'v', long = "version")] + #[arg(short = 'v', long = "version", help = "help.arg.os-version")] pub version: Version, + #[arg(help = "help.arg.asset-file-path")] pub file: PathBuf, } @@ -163,7 +164,7 @@ pub async fn cli_sign_asset( Some("squashfs") => "squashfs", _ => { return Err(Error::new( - eyre!("Unknown extension"), + eyre!("{}", t!("registry.os.asset.unknown-extension")), ErrorKind::InvalidRequest, )); } @@ -186,7 +187,7 @@ pub async fn cli_sign_asset( let size = file .size() .await - .ok_or_else(|| Error::new(eyre!("failed to read file metadata"), ErrorKind::Filesystem))?; + .ok_or_else(|| Error::new(eyre!("{}", t!("registry.os.asset.failed-read-metadata")), ErrorKind::Filesystem))?; let commitment = Blake3Commitment { hash: Base64(*blake3.as_bytes()), size, diff --git a/core/src/registry/os/mod.rs b/core/src/registry/os/mod.rs index d4d308281..e3bb0b863 100644 --- a/core/src/registry/os/mod.rs +++ b/core/src/registry/os/mod.rs @@ -17,16 +17,16 @@ pub fn os_api() -> ParentHandler { from_fn_async(index::get_os_index) .with_metadata("authenticated", Value::Bool(false)) .with_display_serializable() - .with_about("List index of OS versions") + .with_about("about.list-os-versions-index") .with_call_remote::(), ) .subcommand( "asset", - asset::asset_api::().with_about("Commands to add, sign, or get registry assets"), + asset::asset_api::().with_about("about.commands-add-sign-get-assets"), ) .subcommand( "version", version::version_api::() - .with_about("Commands to add, remove, or list versions or version signers"), + .with_about("about.commands-add-remove-list-versions"), ) } diff --git a/core/src/registry/os/version/mod.rs b/core/src/registry/os/version/mod.rs index 292be863c..786d33277 100644 --- a/core/src/registry/os/version/mod.rs +++ b/core/src/registry/os/version/mod.rs @@ -27,7 +27,7 @@ pub fn version_api() -> ParentHandler { .with_metadata("admin", Value::Bool(true)) .with_metadata("get_signer", Value::Bool(true)) .no_display() - .with_about("Add OS version") + .with_about("about.add-os-version") .with_call_remote::(), ) .subcommand( @@ -35,12 +35,12 @@ pub fn version_api() -> ParentHandler { from_fn_async(remove_version) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Remove OS version") + .with_about("about.remove-os-version") .with_call_remote::(), ) .subcommand( "signer", - signer::signer_api::().with_about("Add, remove, and list version signers"), + signer::signer_api::().with_about("about.add-remove-list-version-signers"), ) .subcommand( "get", @@ -51,7 +51,7 @@ pub fn version_api() -> ParentHandler { .with_custom_display_fn(|handle, result| { display_version_info(handle.params, result) }) - .with_about("Get OS versions and related version info") + .with_about("about.get-os-versions-info") .with_call_remote::(), ) } @@ -62,10 +62,14 @@ pub fn version_api() -> ParentHandler { #[ts(export)] pub struct AddVersionParams { #[ts(type = "string")] + #[arg(help = "help.arg.os-version")] pub version: Version, + #[arg(help = "help.arg.version-headline")] pub headline: String, + #[arg(help = "help.arg.release-notes")] pub release_notes: String, #[ts(type = "string")] + #[arg(help = "help.arg.source-version-range")] pub source_version: VersionRange, #[arg(skip)] #[ts(skip)] @@ -110,6 +114,7 @@ pub async fn add_version( #[ts(export)] pub struct RemoveVersionParams { #[ts(type = "string")] + #[arg(help = "help.arg.os-version")] pub version: Version, } @@ -135,15 +140,15 @@ pub async fn remove_version( #[ts(export)] pub struct GetOsVersionParams { #[ts(type = "string | null")] - #[arg(long = "src")] + #[arg(long = "src", help = "help.arg.source-version")] pub source_version: Option, #[ts(type = "string | null")] - #[arg(long)] + #[arg(long, help = "help.arg.target-version-range")] pub target_version: Option, - #[arg(long = "id")] + #[arg(long = "id", help = "help.arg.server-id")] server_id: Option, #[ts(type = "string | null")] - #[arg(long)] + #[arg(long, help = "help.arg.platform")] platform: Option, #[ts(skip)] #[arg(skip)] diff --git a/core/src/registry/os/version/signer.rs b/core/src/registry/os/version/signer.rs index a668e1c17..1d1755ffd 100644 --- a/core/src/registry/os/version/signer.rs +++ b/core/src/registry/os/version/signer.rs @@ -21,7 +21,7 @@ pub fn signer_api() -> ParentHandler { from_fn_async(add_version_signer) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Add version signer") + .with_about("about.add-version-signer") .with_call_remote::(), ) .subcommand( @@ -29,7 +29,7 @@ pub fn signer_api() -> ParentHandler { from_fn_async(remove_version_signer) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Remove version signer") + .with_about("about.remove-version-signer") .with_call_remote::(), ) .subcommand( @@ -38,7 +38,7 @@ pub fn signer_api() -> ParentHandler { .with_metadata("authenticated", Value::Bool(false)) .with_display_serializable() .with_custom_display_fn(|handle, result| display_signers(handle.params, result)) - .with_about("List version signers and related signer info") + .with_about("about.list-version-signers") .with_call_remote::(), ) } @@ -95,7 +95,7 @@ pub async fn remove_version_signer( .mutate(|s| Ok(s.remove(&signer)))? { return Err(Error::new( - eyre!("signer {signer} is not authorized to sign for v{version}"), + eyre!("{}", t!("registry.os.version.signer-not-authorized", signer = signer, version = version)), ErrorKind::NotFound, )); } diff --git a/core/src/registry/package/add.rs b/core/src/registry/package/add.rs index 938b5e649..2b3624d58 100644 --- a/core/src/registry/package/add.rs +++ b/core/src/registry/package/add.rs @@ -56,7 +56,7 @@ pub async fn add_package( let Some(([url], rest)) = urls.split_at_checked(1) else { return Err(Error::new( - eyre!("must specify at least 1 url"), + eyre!("{}", t!("registry.package.add.must-specify-url")), ErrorKind::InvalidRequest, )); }; @@ -112,7 +112,7 @@ pub async fn add_package( Ok(()) } else { - Err(Error::new(eyre!("UNAUTHORIZED"), ErrorKind::Authorization)) + Err(Error::new(eyre!("{}", t!("registry.package.add.unauthorized")), ErrorKind::Authorization)) } }) .await @@ -123,10 +123,11 @@ pub async fn add_package( #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliAddPackageParams { + #[arg(help = "help.arg.s9pk-file-path")] pub file: PathBuf, - #[arg(long)] + #[arg(long, help = "help.arg.package-url")] pub url: Vec, - #[arg(long)] + #[arg(long, help = "help.arg.no-verify")] pub no_verify: bool, } @@ -205,9 +206,11 @@ pub async fn cli_add_package( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct RemovePackageParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, + #[arg(help = "help.arg.package-version")] pub version: VersionString, - #[arg(long)] + #[arg(long, help = "help.arg.signature-hash")] pub sighash: Option>, #[ts(skip)] #[arg(skip)] @@ -226,7 +229,7 @@ pub async fn remove_package( ) -> Result { let peek = ctx.db.peek().await; let signer = - signer.ok_or_else(|| Error::new(eyre!("missing signer"), ErrorKind::InvalidRequest))?; + signer.ok_or_else(|| Error::new(eyre!("{}", t!("registry.package.missing-signer")), ErrorKind::InvalidRequest))?; let signer_guid = peek.as_index().as_signers().get_signer(&signer)?; let rev = ctx @@ -267,7 +270,7 @@ pub async fn remove_package( } Ok(()) } else { - Err(Error::new(eyre!("UNAUTHORIZED"), ErrorKind::Authorization)) + Err(Error::new(eyre!("{}", t!("registry.package.unauthorized")), ErrorKind::Authorization)) } }) .await; @@ -342,7 +345,7 @@ pub async fn add_mirror( Ok(()) } else { - Err(Error::new(eyre!("UNAUTHORIZED"), ErrorKind::Authorization)) + Err(Error::new(eyre!("{}", t!("registry.package.add-mirror.unauthorized")), ErrorKind::Authorization)) } }) .await @@ -353,8 +356,11 @@ pub async fn add_mirror( #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliAddMirrorParams { + #[arg(help = "help.arg.s9pk-file-path")] pub file: PathBuf, + #[arg(help = "help.arg.mirror-url")] pub url: Url, + #[arg(long, help = "help.arg.no-verify")] pub no_verify: bool, } @@ -432,9 +438,11 @@ pub async fn cli_add_mirror( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct RemoveMirrorParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, + #[arg(help = "help.arg.package-version")] pub version: VersionString, - #[arg(long)] + #[arg(long, help = "help.arg.mirror-url")] #[ts(type = "string")] pub url: Url, #[ts(skip)] @@ -454,7 +462,7 @@ pub async fn remove_mirror( ) -> Result<(), Error> { let peek = ctx.db.peek().await; let signer = - signer.ok_or_else(|| Error::new(eyre!("missing signer"), ErrorKind::InvalidRequest))?; + signer.ok_or_else(|| Error::new(eyre!("{}", t!("registry.package.missing-signer")), ErrorKind::InvalidRequest))?; let signer_guid = peek.as_index().as_signers().get_signer(&signer)?; ctx.db @@ -483,7 +491,7 @@ pub async fn remove_mirror( .for_each(|(_, asset)| asset.urls.retain(|u| u != &url)); if s.iter().any(|(_, asset)| asset.urls.is_empty()) { Err(Error::new( - eyre!("cannot remove last mirror from an s9pk"), + eyre!("{}", t!("registry.package.cannot-remove-last-mirror")), ErrorKind::InvalidRequest, )) } else { @@ -493,7 +501,7 @@ pub async fn remove_mirror( } Ok(()) } else { - Err(Error::new(eyre!("UNAUTHORIZED"), ErrorKind::Authorization)) + Err(Error::new(eyre!("{}", t!("registry.package.remove-mirror.unauthorized")), ErrorKind::Authorization)) } }) .await diff --git a/core/src/registry/package/category.rs b/core/src/registry/package/category.rs index 131ab42f2..f41ae9363 100644 --- a/core/src/registry/package/category.rs +++ b/core/src/registry/package/category.rs @@ -11,6 +11,7 @@ use crate::context::CliContext; use crate::prelude::*; use crate::registry::context::RegistryContext; use crate::registry::package::index::Category; +use crate::s9pk::manifest::LocaleString; use crate::util::serde::{HandlerExtSerde, WithIoFormat, display_serializable}; pub fn category_api() -> ParentHandler { @@ -20,7 +21,7 @@ pub fn category_api() -> ParentHandler { from_fn_async(add_category) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Add a category to the registry") + .with_about("about.add-category-registry") .with_call_remote::(), ) .subcommand( @@ -28,7 +29,7 @@ pub fn category_api() -> ParentHandler { from_fn_async(remove_category) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Remove a category from the registry") + .with_about("about.remove-category-registry") .with_call_remote::(), ) .subcommand( @@ -36,7 +37,7 @@ pub fn category_api() -> ParentHandler { from_fn_async(add_package) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Add a package to a category") + .with_about("about.add-package-category") .with_call_remote::(), ) .subcommand( @@ -44,7 +45,7 @@ pub fn category_api() -> ParentHandler { from_fn_async(remove_package) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Remove a package from a category") + .with_about("about.remove-package-category") .with_call_remote::(), ) .subcommand( @@ -66,7 +67,7 @@ pub fn category_api() -> ParentHandler { pub struct AddCategoryParams { #[ts(type = "string")] pub id: InternedString, - pub name: String, + pub name: LocaleString, } pub async fn add_category( @@ -196,7 +197,7 @@ pub fn display_categories( "NAME", ]); for (id, info) in categories { - table.add_row(row![&*id, &info.name]); + table.add_row(row![&*id, &info.name.localized()]); } table.print_tty(false)?; Ok(()) diff --git a/core/src/registry/package/get.rs b/core/src/registry/package/get.rs index f796622e1..7525ce54d 100644 --- a/core/src/registry/package/get.rs +++ b/core/src/registry/package/get.rs @@ -51,17 +51,18 @@ pub struct PackageInfoShort { #[ts(export)] #[model = "Model"] pub struct GetPackageParams { + #[arg(help = "help.arg.package-id")] pub id: Option, #[ts(type = "string | null")] - #[arg(long, short = 'v')] + #[arg(long, short = 'v', help = "help.arg.target-version-range")] pub target_version: Option, - #[arg(long)] + #[arg(long, help = "help.arg.source-version")] pub source_version: Option, #[ts(skip)] #[arg(skip)] #[serde(rename = "__DeviceInfo_device_info")] pub device_info: Option, - #[arg(default_value = "none")] + #[arg(default_value = "none", help = "help.arg.other-versions-detail")] pub other_versions: Option, } @@ -78,20 +79,20 @@ pub struct GetPackageResponse { pub other_versions: Option>, } impl GetPackageResponse { - pub fn tables(&self) -> Vec { + pub fn tables(self) -> Vec { use prettytable::*; let mut res = Vec::with_capacity(self.best.len()); - for (version, info) in &self.best { - let mut table = info.table(version); + for (version, info) in self.best { + let mut table = info.table(&version); let lesser_versions: BTreeMap<_, _> = self .other_versions .as_ref() .into_iter() .flatten() - .filter(|(v, _)| ***v < **version) + .filter(|(v, _)| ***v < *version) .collect(); if !lesser_versions.is_empty() { @@ -120,13 +121,17 @@ pub struct GetPackageResponseFull { pub other_versions: BTreeMap, } impl GetPackageResponseFull { - pub fn tables(&self) -> Vec { + pub fn tables(self) -> Vec { let mut res = Vec::with_capacity(self.best.len()); - let all: BTreeMap<_, _> = self.best.iter().chain(self.other_versions.iter()).collect(); + let all: BTreeMap<_, _> = self + .best + .into_iter() + .chain(self.other_versions.into_iter()) + .collect(); for (version, info) in all { - res.push(info.table(version)); + res.push(info.table(&version)); } res @@ -401,11 +406,12 @@ pub fn display_package_info( #[derive(Debug, Deserialize, Serialize, TS, Parser)] #[serde(rename_all = "camelCase")] pub struct CliDownloadParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, - #[arg(long, short = 'v')] + #[arg(long, short = 'v', help = "help.arg.target-version-range")] #[ts(type = "string | null")] pub target_version: Option, - #[arg(short, long)] + #[arg(short, long, help = "help.arg.destination-path")] pub dest: Option, } @@ -441,8 +447,12 @@ pub async fn cli_download( 0 => { return Err(Error::new( eyre!( - "Could not find a version of {id} that satisfies {}", - target_version.unwrap_or(VersionRange::Any) + "{}", + t!( + "registry.package.get.version-not-found", + id = id, + version = target_version.unwrap_or(VersionRange::Any) + ) ), ErrorKind::NotFound, )); @@ -462,8 +472,12 @@ pub async fn cli_download( 0 => { return Err(Error::new( eyre!( - "Could not find a version of {id} that satisfies {}", - target_version.unwrap_or(VersionRange::Any) + "{}", + t!( + "registry.package.get.version-not-found", + id = id, + version = target_version.unwrap_or(VersionRange::Any) + ) ), ErrorKind::NotFound, )); @@ -551,7 +565,7 @@ pub async fn cli_download( progress_tracker.complete(); progress.await.unwrap(); - println!("Download Complete"); + println!("{}", t!("registry.package.get.download-complete")); Ok(()) } diff --git a/core/src/registry/package/index.rs b/core/src/registry/package/index.rs index 61240f025..64b83d5e4 100644 --- a/core/src/registry/package/index.rs +++ b/core/src/registry/package/index.rs @@ -17,7 +17,7 @@ use crate::registry::device_info::DeviceInfo; use crate::rpc_continuations::Guid; use crate::s9pk::S9pk; use crate::s9pk::git_hash::GitHash; -use crate::s9pk::manifest::{Alerts, Description, HardwareRequirements}; +use crate::s9pk::manifest::{Alerts, Description, HardwareRequirements, LocaleString}; use crate::s9pk::merkle_archive::source::FileSource; use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment; use crate::sign::{AnySignature, AnyVerifyingKey}; @@ -49,22 +49,27 @@ pub struct PackageInfo { #[model = "Model"] #[ts(export)] pub struct Category { - pub name: String, + pub name: LocaleString, } -#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq, Eq)] +#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq)] #[serde(rename_all = "camelCase")] #[model = "Model"] #[ts(export)] pub struct DependencyMetadata { - #[ts(type = "string | null")] - pub title: Option, + pub title: Option, pub icon: Option>, - pub description: Option, + pub description: Option, pub optional: bool, } +impl DependencyMetadata { + pub fn localize_for(&mut self, locale: &str) { + self.title.as_mut().map(|t| t.localize_for(locale)); + self.description.as_mut().map(|d| d.localize_for(locale)); + } +} -#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq, Eq)] +#[derive(Debug, Deserialize, Serialize, HasModel, TS, PartialEq)] #[serde(rename_all = "camelCase")] #[model = "Model"] pub struct PackageMetadata { @@ -72,7 +77,7 @@ pub struct PackageMetadata { pub title: InternedString, pub icon: DataUrl<'static>, pub description: Description, - pub release_notes: String, + pub release_notes: LocaleString, pub git_hash: Option, #[ts(type = "string")] pub license: InternedString, @@ -199,20 +204,20 @@ impl PackageVersionInfo { self.s9pks.sort_by_key(|(h, _)| h.specificity_desc()); Ok(()) } - pub fn table(&self, version: &VersionString) -> prettytable::Table { + pub fn table(self, version: &VersionString) -> prettytable::Table { use prettytable::*; let mut table = Table::new(); table.add_row(row![bc => &self.metadata.title]); table.add_row(row![br -> "VERSION", AsRef::::as_ref(version)]); - table.add_row(row![br -> "RELEASE NOTES", &self.metadata.release_notes]); + table.add_row(row![br -> "RELEASE NOTES", &self.metadata.release_notes.localized()]); table.add_row( - row![br -> "ABOUT", &textwrap::wrap(&self.metadata.description.short, 80).join("\n")], + row![br -> "ABOUT", &textwrap::wrap(&self.metadata.description.short.localized(), 80).join("\n")], ); table.add_row(row![ br -> "DESCRIPTION", - &textwrap::wrap(&self.metadata.description.long, 80).join("\n") + &textwrap::wrap(&self.metadata.description.long.localized(), 80).join("\n") ]); table.add_row(row![br -> "GIT HASH", self.metadata.git_hash.as_deref().unwrap_or("N/A")]); table.add_row(row![br -> "LICENSE", &self.metadata.license]); @@ -280,6 +285,24 @@ impl Model { { return Ok(false); } + + if let Some(locale) = device_info.os.language.as_deref() { + let metadata = self.as_metadata_mut(); + metadata + .as_alerts_mut() + .mutate(|a| Ok(a.localize_for(locale)))?; + metadata + .as_dependency_metadata_mut() + .as_entries_mut()? + .into_iter() + .try_for_each(|(_, d)| d.mutate(|d| Ok(d.localize_for(locale))))?; + metadata + .as_description_mut() + .mutate(|d| Ok(d.localize_for(locale)))?; + metadata + .as_release_notes_mut() + .mutate(|r| Ok(r.localize_for(locale)))?; + } } Ok(true) diff --git a/core/src/registry/package/mod.rs b/core/src/registry/package/mod.rs index e09dbbb9a..e9de53ac9 100644 --- a/core/src/registry/package/mod.rs +++ b/core/src/registry/package/mod.rs @@ -17,7 +17,7 @@ pub fn package_api() -> ParentHandler { from_fn_async(index::get_package_index) .with_metadata("authenticated", Value::Bool(false)) .with_display_serializable() - .with_about("List packages and categories") + .with_about("about.list-packages-categories") .with_call_remote::(), ) .subcommand( @@ -30,7 +30,7 @@ pub fn package_api() -> ParentHandler { "add", from_fn_async(add::cli_add_package) .no_display() - .with_about("Add package to registry index"), + .with_about("about.add-package-registry"), ) .subcommand( "add-mirror", @@ -42,7 +42,7 @@ pub fn package_api() -> ParentHandler { "add-mirror", from_fn_async(add::cli_add_mirror) .no_display() - .with_about("Add a mirror for an s9pk"), + .with_about("about.add-mirror-s9pk"), ) .subcommand( "remove", @@ -51,17 +51,17 @@ pub fn package_api() -> ParentHandler { .with_custom_display_fn(|args, changed| { if !changed { tracing::warn!( - "{}@{}{} does not exist, so not removed", - args.params.id, - args.params.version, - args.params - .sighash - .map_or(String::new(), |h| format!("#{h}")) + "{}", + t!("registry.package.remove-not-exist", + id = args.params.id, + version = args.params.version, + sighash = args.params.sighash.map_or(String::new(), |h| format!("#{h}")) + ) ); } Ok(()) }) - .with_about("Remove package from registry index") + .with_about("about.remove-package-registry") .with_call_remote::(), ) .subcommand( @@ -69,12 +69,12 @@ pub fn package_api() -> ParentHandler { from_fn_async(add::remove_mirror) .with_metadata("get_signer", Value::Bool(true)) .no_display() - .with_about("Remove a mirror from a package") + .with_about("about.remove-mirror-package") .with_call_remote::(), ) .subcommand( "signer", - signer::signer_api::().with_about("Add, remove, and list package signers"), + signer::signer_api::().with_about("about.add-remove-list-package-signers"), ) .subcommand( "get", @@ -85,18 +85,18 @@ pub fn package_api() -> ParentHandler { .with_custom_display_fn(|handle, result| { get::display_package_info(handle.params, result) }) - .with_about("List installation candidate package(s)") + .with_about("about.list-installation-candidates") .with_call_remote::(), ) .subcommand( "download", from_fn_async_local(get::cli_download) .no_display() - .with_about("Download an s9pk"), + .with_about("about.download-s9pk"), ) .subcommand( "category", category::category_api::() - .with_about("Update the categories for packages on the registry"), + .with_about("about.update-categories-registry"), ) } diff --git a/core/src/registry/package/signer.rs b/core/src/registry/package/signer.rs index 3ab73d521..deb1d5adb 100644 --- a/core/src/registry/package/signer.rs +++ b/core/src/registry/package/signer.rs @@ -22,7 +22,7 @@ pub fn signer_api() -> ParentHandler { from_fn_async(add_package_signer) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Add package signer") + .with_about("about.add-package-signer") .with_call_remote::(), ) .subcommand( @@ -30,7 +30,7 @@ pub fn signer_api() -> ParentHandler { from_fn_async(remove_package_signer) .with_metadata("admin", Value::Bool(true)) .no_display() - .with_about("Remove package signer") + .with_about("about.remove-package-signer") .with_call_remote::(), ) .subcommand( @@ -41,7 +41,7 @@ pub fn signer_api() -> ParentHandler { .with_custom_display_fn(|handle, result| { display_package_signers(handle.params, result) }) - .with_about("List package signers and related signer info") + .with_about("about.list-package-signers") .with_call_remote::(), ) } @@ -51,9 +51,11 @@ pub fn signer_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct AddPackageSignerParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, + #[arg(help = "help.arg.signer-id")] pub signer: Guid, - #[arg(long)] + #[arg(long, help = "help.arg.version-range")] #[ts(type = "string | null")] pub versions: Option, } @@ -93,7 +95,9 @@ pub async fn add_package_signer( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct RemovePackageSignerParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, + #[arg(help = "help.arg.signer-id")] pub signer: Guid, } @@ -114,7 +118,7 @@ pub async fn remove_package_signer( .is_some() { return Err(Error::new( - eyre!("signer {signer} is not authorized to sign for {id}"), + eyre!("{}", t!("registry.package.signer.not-authorized", signer = signer, id = id)), ErrorKind::NotFound, )); } @@ -130,6 +134,7 @@ pub async fn remove_package_signer( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct ListPackageSignersParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, } diff --git a/core/src/registry/signer.rs b/core/src/registry/signer.rs index 3a4fc2d0d..8501f3f16 100644 --- a/core/src/registry/signer.rs +++ b/core/src/registry/signer.rs @@ -90,7 +90,7 @@ impl AcceptSigners { Ok(()) } else { Err(Error::new( - eyre!("signer(s) not accepted"), + eyre!("{}", t!("registry.signer.not-accepted")), ErrorKind::InvalidSignature, )) } diff --git a/core/src/s9pk/rpc.rs b/core/src/s9pk/rpc.rs index ec7dd3696..f1cc71ecc 100644 --- a/core/src/s9pk/rpc.rs +++ b/core/src/s9pk/rpc.rs @@ -25,7 +25,7 @@ pub fn s9pk() -> ParentHandler { "pack", from_fn_async(super::v2::pack::pack) .no_display() - .with_about("Package s9pk input files into valid s9pk"), + .with_about("about.package-s9pk-input-files-into-valid-s9pk"), ) .subcommand( "list-ingredients", @@ -45,26 +45,27 @@ pub fn s9pk() -> ParentHandler { println!(); Ok(()) }) - .with_about("List paths of package ingredients"), + .with_about("about.list-paths-of-package-ingredients"), ) .subcommand( "edit", - edit().with_about("Commands to add an image to an s9pk or edit the manifest"), + edit().with_about("about.commands-add-image-or-edit-manifest"), ) .subcommand( "inspect", - inspect().with_about("Commands to display file paths, file contents, or manifest"), + inspect().with_about("about.commands-display-file-paths-contents-manifest"), ) .subcommand( "convert", from_fn_async(convert) .no_display() - .with_about("Convert s9pk from v1 to v2"), + .with_about("about.convert-s9pk-v1-to-v2"), ) } #[derive(Deserialize, Serialize, Parser)] struct S9pkPath { + #[arg(help = "help.arg.s9pk-file-path")] s9pk: PathBuf, } @@ -76,14 +77,14 @@ fn edit() -> ParentHandler { from_fn_async(add_image) .with_inherited(only_parent) .no_display() - .with_about("Add image to s9pk"), + .with_about("about.add-image-to-s9pk"), ) .subcommand( "manifest", from_fn_async(edit_manifest) .with_inherited(only_parent) .with_display_serializable() - .with_about("Edit s9pk manifest"), + .with_about("about.edit-s9pk-manifest"), ) } @@ -95,26 +96,27 @@ fn inspect() -> ParentHandler { from_fn_async(file_tree) .with_inherited(only_parent) .with_display_serializable() - .with_about("Display list of paths"), + .with_about("about.display-list-of-paths"), ) .subcommand( "cat", from_fn_async(cat) .with_inherited(only_parent) .no_display() - .with_about("Display file contents"), + .with_about("about.display-file-contents"), ) .subcommand( "manifest", from_fn_async(inspect_manifest) .with_inherited(only_parent) .with_display_serializable() - .with_about("Display s9pk manifest"), + .with_about("about.display-s9pk-manifest"), ) } #[derive(Deserialize, Serialize, Parser, TS)] struct AddImageParams { + #[arg(help = "help.arg.image-id")] id: ImageId, #[command(flatten)] config: ImageConfig, @@ -148,6 +150,7 @@ async fn add_image( #[derive(Deserialize, Serialize, Parser, TS)] struct EditManifestParams { + #[arg(help = "help.arg.db-apply-expr")] expression: String, } async fn edit_manifest( @@ -194,6 +197,7 @@ async fn file_tree( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] struct CatParams { + #[arg(help = "help.arg.file-path")] file_path: PathBuf, } async fn cat( diff --git a/core/src/s9pk/v1/mod.rs b/core/src/s9pk/v1/mod.rs index 9910d0adb..3773bac81 100644 --- a/core/src/s9pk/v1/mod.rs +++ b/core/src/s9pk/v1/mod.rs @@ -16,5 +16,6 @@ pub const SIG_CONTEXT: &[u8] = b"s9pk"; #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct VerifyParams { + #[arg(help = "help.arg.s9pk-file-path")] pub path: PathBuf, } diff --git a/core/src/s9pk/v2/compat.rs b/core/src/s9pk/v2/compat.rs index c15baef42..837632fff 100644 --- a/core/src/s9pk/v2/compat.rs +++ b/core/src/s9pk/v2/compat.rs @@ -9,7 +9,7 @@ use tokio::process::Command; use crate::dependencies::{DepInfo, Dependencies}; use crate::prelude::*; -use crate::s9pk::manifest::{DeviceFilter, Manifest}; +use crate::s9pk::manifest::{DeviceFilter, LocaleString, Manifest}; use crate::s9pk::merkle_archive::directory_contents::DirectoryContents; use crate::s9pk::merkle_archive::source::TmpSource; use crate::s9pk::merkle_archive::{Entry, MerkleArchive}; @@ -198,7 +198,7 @@ impl TryFrom for Manifest { title: format!("{} (Legacy)", value.title).into(), version: version.into(), satisfies: BTreeSet::new(), - release_notes: value.release_notes, + release_notes: LocaleString::Translated(value.release_notes), can_migrate_from: VersionRange::any(), can_migrate_to: VersionRange::none(), license: value.license.into(), @@ -226,7 +226,7 @@ impl TryFrom for Manifest { ( id, DepInfo { - description: value.description, + description: value.description.map(LocaleString::Translated), optional: !value.requirement.required(), metadata: None, }, diff --git a/core/src/s9pk/v2/manifest.rs b/core/src/s9pk/v2/manifest.rs index 4f18453c4..00e57f18c 100644 --- a/core/src/s9pk/v2/manifest.rs +++ b/core/src/s9pk/v2/manifest.rs @@ -1,9 +1,10 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; +use clap::builder::ValueParserFactory; use color_eyre::eyre::eyre; use exver::{Version, VersionRange}; -use imbl_value::InternedString; +use imbl_value::{InOMap, InternedString}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use url::Url; @@ -17,7 +18,7 @@ use crate::s9pk::merkle_archive::expected::{Expected, Filter}; use crate::s9pk::v2::pack::ImageConfig; use crate::util::lshw::{LshwDevice, LshwDisplay, LshwProcessor}; use crate::util::serde::Regex; -use crate::util::{VersionString, mime}; +use crate::util::{FromStrParser, VersionString, mime}; use crate::version::{Current, VersionT}; use crate::{ImageId, VolumeId}; @@ -35,7 +36,7 @@ pub struct Manifest { pub title: InternedString, pub version: VersionString, pub satisfies: BTreeSet, - pub release_notes: String, + pub release_notes: LocaleString, #[ts(type = "string")] pub can_migrate_to: VersionRange, #[ts(type = "string")] @@ -190,6 +191,118 @@ impl HardwareRequirements { } } +#[derive(Clone, Debug, PartialEq, TS)] +#[ts(type = "string | Record")] +pub enum LocaleString { + Translated(String), + LanguageMap(InOMap), +} +impl std::str::FromStr for LocaleString { + type Err = std::convert::Infallible; + fn from_str(s: &str) -> Result { + // Try JSON parse first (for maps or quoted strings) + if let Ok(parsed) = serde_json::from_str::(s) { + return Ok(parsed); + } + // Fall back to plain string + Ok(LocaleString::Translated(s.to_owned())) + } +} +impl LocaleString { + pub fn localize_for(&mut self, locale: &str) { + if let Self::LanguageMap(map) = self { + if let Some(translated) = map.remove(locale) { + *self = Self::Translated(translated); + return; + } + let prefix = locale.split_inclusive("_").next().unwrap(); + let mut first = None; + for (lang, translated) in std::mem::take(map) { + if lang.starts_with(prefix) { + *self = Self::Translated(translated); + return; + } + if first.is_none() { + first = Some(translated); + } + } + *self = Self::Translated(first.unwrap_or_default()) + } + } + pub fn localized_for(mut self, locale: &str) -> String { + self.localize_for(locale); + if let Self::Translated(s) = self { + s + } else { + unreachable!() + } + } + pub fn localize(&mut self) { + self.localize_for(&*rust_i18n::locale()); + } + pub fn localized(mut self) -> String { + self.localized_for(&*rust_i18n::locale()) + } +} +impl<'de> Deserialize<'de> for LocaleString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct LocaleStringVisitor; + + impl<'de> serde::de::Visitor<'de> for LocaleStringVisitor { + type Value = LocaleString; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or a map of language codes to strings") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(LocaleString::Translated(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + Ok(LocaleString::Translated(value)) + } + + fn visit_map(self, map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let language_map = + InOMap::deserialize(serde::de::value::MapAccessDeserializer::new(map))?; + Ok(LocaleString::LanguageMap(language_map)) + } + } + + deserializer.deserialize_any(LocaleStringVisitor) + } +} +impl Serialize for LocaleString { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + LocaleString::Translated(s) => serializer.serialize_str(s), + LocaleString::LanguageMap(map) => map.serialize(serializer), + } + } +} +impl ValueParserFactory for LocaleString { + type Parser = FromStrParser; + fn value_parser() -> Self::Parser { + FromStrParser::new() + } +} + #[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] @@ -294,21 +407,32 @@ impl DeviceFilter { } } -#[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq)] #[ts(export)] pub struct Description { - pub short: String, - pub long: String, + pub short: LocaleString, + pub long: LocaleString, } impl Description { + pub fn localize_for(&mut self, locale: &str) { + self.short.localize_for(locale); + self.long.localize_for(locale); + } + pub fn validate(&self) -> Result<(), Error> { - if self.short.chars().skip(160).next().is_some() { + if match &self.short { + LocaleString::Translated(s) => s.len() > 160, + LocaleString::LanguageMap(map) => map.values().any(|s| s.len() > 160), + } { return Err(Error::new( eyre!("Short description must be 160 characters or less."), crate::ErrorKind::ValidateS9pk, )); } - if self.long.chars().skip(5000).next().is_some() { + if match &self.short { + LocaleString::Translated(s) => s.len() > 5000, + LocaleString::LanguageMap(map) => map.values().any(|s| s.len() > 5000), + } { return Err(Error::new( eyre!("Long description must be 5000 characters or less."), crate::ErrorKind::ValidateS9pk, @@ -318,13 +442,22 @@ impl Description { } } -#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq, Eq)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct Alerts { - pub install: Option, - pub uninstall: Option, - pub restore: Option, - pub start: Option, - pub stop: Option, + pub install: Option, + pub uninstall: Option, + pub restore: Option, + pub start: Option, + pub stop: Option, +} +impl Alerts { + pub fn localize_for(&mut self, locale: &str) { + self.install.as_mut().map(|s| s.localize_for(locale)); + self.uninstall.as_mut().map(|s| s.localize_for(locale)); + self.restore.as_mut().map(|s| s.localize_for(locale)); + self.start.as_mut().map(|s| s.localize_for(locale)); + self.stop.as_mut().map(|s| s.localize_for(locale)); + } } diff --git a/core/src/s9pk/v2/pack.rs b/core/src/s9pk/v2/pack.rs index 9deeab978..d60e05f5b 100644 --- a/core/src/s9pk/v2/pack.rs +++ b/core/src/s9pk/v2/pack.rs @@ -20,7 +20,7 @@ use crate::prelude::*; use crate::rpc_continuations::Guid; use crate::s9pk::S9pk; use crate::s9pk::git_hash::GitHash; -use crate::s9pk::manifest::Manifest; +use crate::s9pk::manifest::{LocaleString, Manifest}; use crate::s9pk::merkle_archive::directory_contents::DirectoryContents; use crate::s9pk::merkle_archive::source::http::HttpSource; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; @@ -155,20 +155,21 @@ impl From for DynFileSource { #[derive(Deserialize, Serialize, Parser)] pub struct PackParams { + #[arg(help = "help.arg.input-path")] pub path: Option, - #[arg(short, long)] + #[arg(short, long, help = "help.arg.output-path")] pub output: Option, - #[arg(long)] + #[arg(long, help = "help.arg.javascript-path")] pub javascript: Option, - #[arg(long)] + #[arg(long, help = "help.arg.icon-path")] pub icon: Option, - #[arg(long)] + #[arg(long, help = "help.arg.license-path")] pub license: Option, - #[arg(long, conflicts_with = "no-assets")] + #[arg(long, conflicts_with = "no-assets", help = "help.arg.assets-path")] pub assets: Option, - #[arg(long, conflicts_with = "assets")] + #[arg(long, conflicts_with = "assets", help = "help.arg.no-assets")] pub no_assets: bool, - #[arg(long, help = "Architecture Mask")] + #[arg(long, help = "help.arg.architecture-mask")] pub arch: Vec, } impl PackParams { @@ -280,19 +281,19 @@ pub struct ImageConfig { #[derive(Parser)] struct CliImageConfig { - #[arg(long, conflicts_with("docker-tag"))] + #[arg(long, conflicts_with("docker-tag"), help = "help.arg.docker-build")] docker_build: bool, - #[arg(long, requires("docker-build"))] + #[arg(long, requires("docker-build"), help = "help.arg.dockerfile-path")] dockerfile: Option, - #[arg(long, requires("docker-build"))] + #[arg(long, requires("docker-build"), help = "help.arg.workdir-path")] workdir: Option, - #[arg(long, conflicts_with_all(["dockerfile", "workdir"]))] + #[arg(long, conflicts_with_all(["dockerfile", "workdir"]), help = "help.arg.docker-tag")] docker_tag: Option, - #[arg(long)] + #[arg(long, help = "help.arg.architecture-mask")] arch: Vec, - #[arg(long)] + #[arg(long, help = "help.arg.emulate-missing-arch")] emulate_missing_as: Option, - #[arg(long)] + #[arg(long, help = "help.arg.nvidia-container")] nvidia_container: bool, } impl TryFrom for ImageConfig { @@ -755,7 +756,7 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> { } }; Some(( - s9pk.as_manifest().title.clone(), + LocaleString::Translated(s9pk.as_manifest().title.to_string()), s9pk.icon_data_url().await?, )) } diff --git a/core/src/service/action.rs b/core/src/service/action.rs index 0e75e1c3c..2d149741a 100644 --- a/core/src/service/action.rs +++ b/core/src/service/action.rs @@ -102,7 +102,7 @@ pub fn update_tasks( } } None => { - tracing::error!("action request exists in an invalid state {:?}", v.task); + tracing::error!("{}", t!("service.action.action-request-invalid-state", task = format!("{:?}", v.task))); } }, } @@ -151,7 +151,7 @@ impl Handler for ServiceActor { .de()?; if matches!(&action.visibility, ActionVisibility::Disabled(_)) { return Err(Error::new( - eyre!("action {action_id} is disabled"), + eyre!("{}", t!("service.action.action-is-disabled", action_id = action_id)), ErrorKind::Action, )); } @@ -162,7 +162,7 @@ impl Handler for ServiceActor { _ => false, } { return Err(Error::new( - eyre!("service is not in allowed status for {action_id}"), + eyre!("{}", t!("service.action.service-not-in-allowed-status", action_id = action_id)), ErrorKind::Action, )); } diff --git a/core/src/service/cli.rs b/core/src/service/cli.rs index 7941124a4..c2b6c2af3 100644 --- a/core/src/service/cli.rs +++ b/core/src/service/cli.rs @@ -14,7 +14,7 @@ use crate::service::effects::context::EffectContext; #[derive(Debug, Default, Parser)] pub struct ContainerClientConfig { - #[arg(long = "socket")] + #[arg(long = "socket", help = "help.arg.socket-path")] pub socket: Option, } diff --git a/core/src/service/effects/action.rs b/core/src/service/effects/action.rs index ff98dfd13..56c62896b 100644 --- a/core/src/service/effects/action.rs +++ b/core/src/service/effects/action.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async}; +use rust_i18n::t; use crate::action::{ActionInput, ActionResult, display_action_result}; use crate::db::model::package::{ @@ -80,7 +81,7 @@ pub async fn export_action( #[ts(export)] #[serde(rename_all = "camelCase")] pub struct ClearActionsParams { - #[arg(long)] + #[arg(long, help = "help.arg.except-actions")] pub except: Vec, } @@ -117,7 +118,9 @@ pub struct GetActionInputParams { #[arg(skip)] procedure_id: Guid, #[ts(optional)] + #[arg(help = "help.arg.package-id")] package_id: Option, + #[arg(help = "help.arg.action-id")] action_id: ActionId, } async fn get_action_input( @@ -155,9 +158,12 @@ pub struct RunActionParams { #[arg(skip)] procedure_id: Guid, #[ts(optional)] + #[arg(help = "help.arg.package-id")] package_id: Option, + #[arg(help = "help.arg.action-id")] action_id: ActionId, #[ts(type = "any")] + #[arg(help = "help.arg.action-input")] input: Value, } async fn run_action( @@ -175,7 +181,7 @@ async fn run_action( if package_id != &context.seed.id { return Err(Error::new( - eyre!("calling actions on other packages is unsupported at this time"), + eyre!("{}", t!("service.effects.action.calling-actions-on-other-packages-unsupported")), ErrorKind::InvalidRequest, )); context @@ -220,7 +226,7 @@ async fn create_task( TaskCondition::InputNotMatches => { let Some(input) = task.input.as_ref() else { return Err(Error::new( - eyre!("input-not-matches trigger requires input to be specified"), + eyre!("{}", t!("service.effects.action.input-not-matches-requires-input")), ErrorKind::InvalidRequest, )); }; @@ -238,9 +244,7 @@ async fn create_task( else { return Err(Error::new( eyre!( - "action {} of {} has no input", - task.action_id, - task.package_id + "{}", t!("service.effects.action.action-has-no-input", action_id = task.action_id, package_id = task.package_id) ), ErrorKind::InvalidRequest, )); @@ -286,9 +290,9 @@ async fn create_task( #[ts(type = "{ only: string[] } | { except: string[] }")] #[ts(export)] pub struct ClearTasksParams { - #[arg(long, conflicts_with = "except")] + #[arg(long, conflicts_with = "except", help = "help.arg.only-tasks")] pub only: Option>, - #[arg(long, conflicts_with = "only")] + #[arg(long, conflicts_with = "only", help = "help.arg.except-tasks")] pub except: Option>, } diff --git a/core/src/service/effects/callbacks.rs b/core/src/service/effects/callbacks.rs index 1b928d3a1..c86773095 100644 --- a/core/src/service/effects/callbacks.rs +++ b/core/src/service/effects/callbacks.rs @@ -319,9 +319,9 @@ impl CallbackHandlers { #[ts(type = "{ only: number[] } | { except: number[] }")] #[ts(export)] pub struct ClearCallbacksParams { - #[arg(long, conflicts_with = "except")] + #[arg(long, conflicts_with = "except", help = "help.arg.only-callbacks")] pub only: Option>, - #[arg(long, conflicts_with = "only")] + #[arg(long, conflicts_with = "only", help = "help.arg.except-callbacks")] pub except: Option>, } diff --git a/core/src/service/effects/dependency.rs b/core/src/service/effects/dependency.rs index 41ca110b4..43f4297b9 100644 --- a/core/src/service/effects/dependency.rs +++ b/core/src/service/effects/dependency.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use clap::builder::ValueParserFactory; use exver::VersionRange; -use imbl_value::InternedString; +use rust_i18n::t; use crate::db::model::package::{ CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference, @@ -148,13 +148,25 @@ impl FromStr for DependencyRequirement { .map(|id| id.parse().map_err(Error::from)) .collect(), Some((kind, _)) => Err(Error::new( - eyre!("unknown dependency kind {kind}"), + eyre!( + "{}", + t!( + "service.effects.dependency.unknown-dependency-kind", + kind = kind + ) + ), ErrorKind::InvalidRequest, )), None => match rest { "r" | "running" => Ok(BTreeSet::new()), kind => Err(Error::new( - eyre!("unknown dependency kind {kind}"), + eyre!( + "{}", + t!( + "service.effects.dependency.unknown-dependency-kind", + kind = kind + ) + ), ErrorKind::InvalidRequest, )), }, @@ -293,8 +305,7 @@ pub struct CheckDependenciesParam { #[ts(export)] pub struct CheckDependenciesResult { package_id: PackageId, - #[ts(type = "string | null")] - title: Option, + title: Option, installed_version: Option, satisfies: BTreeSet, is_running: bool, @@ -334,7 +345,7 @@ pub async fn check_dependencies( .collect(); results.push(CheckDependenciesResult { package_id, - title, + title: title.map(|t| t.localized()), installed_version: None, satisfies: BTreeSet::new(), is_running: false, @@ -360,7 +371,7 @@ pub async fn check_dependencies( .collect(); results.push(CheckDependenciesResult { package_id, - title, + title: title.map(|t| t.localized()), installed_version, satisfies, is_running, diff --git a/core/src/service/effects/prelude.rs b/core/src/service/effects/prelude.rs index 74a3ce476..60b0ecc9d 100644 --- a/core/src/service/effects/prelude.rs +++ b/core/src/service/effects/prelude.rs @@ -11,6 +11,6 @@ pub(super) use crate::service::effects::context::EffectContext; #[ts(export)] pub struct EventId { #[serde(default)] - #[arg(default_value_t, long)] + #[arg(default_value_t, long, help = "help.arg.event-id")] pub event_id: Guid, } diff --git a/core/src/service/effects/subcontainer/sync.rs b/core/src/service/effects/subcontainer/sync.rs index dba63c1cd..176203c03 100644 --- a/core/src/service/effects/subcontainer/sync.rs +++ b/core/src/service/effects/subcontainer/sync.rs @@ -107,22 +107,23 @@ fn open_file_read(path: impl AsRef) -> Result { #[derive(Debug, Clone, Serialize, Deserialize, Parser)] pub struct ExecParams { - #[arg(long)] + #[arg(long, help = "help.arg.force-tty")] force_tty: bool, - #[arg(long)] + #[arg(long, help = "help.arg.force-stderr-tty")] force_stderr_tty: bool, - #[arg(long)] + #[arg(long, help = "help.arg.pty-size")] pty_size: Option, - #[arg(short, long)] + #[arg(short, long, help = "help.arg.env-variable")] env: Vec, - #[arg(long)] + #[arg(long, help = "help.arg.env-file-path")] env_file: Option, - #[arg(short, long)] + #[arg(short, long, help = "help.arg.workdir-path")] workdir: Option, - #[arg(short, long)] + #[arg(short, long, help = "help.arg.user-name")] user: Option, + #[arg(help = "help.arg.chroot-path")] chroot: PathBuf, - #[arg(trailing_var_arg = true)] + #[arg(trailing_var_arg = true, help = "help.arg.command-to-execute")] command: Vec, } impl ExecParams { diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index 575d4d619..b55d0665f 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -28,6 +28,7 @@ use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; use ts_rs::TS; use url::Url; + use crate::context::{CliContext, RpcContext}; use crate::db::model::package::{ InstalledState, ManifestPreference, PackageState, PackageStateMatchModelRef, TaskSeverity, @@ -172,7 +173,7 @@ impl ServiceRef { } let service = Arc::try_unwrap(self.0).map_err(|_| { Error::new( - eyre!("ServiceActor held somewhere after actor shutdown"), + eyre!("{}", t!("service.mod.service-actor-held-after-shutdown")), ErrorKind::Unknown, ) })?; @@ -183,7 +184,7 @@ impl ServiceRef { Arc::try_unwrap(service.seed) .map_err(|_| { Error::new( - eyre!("ServiceActorSeed held somewhere after actor shutdown"), + eyre!("{}", t!("service.mod.service-actor-seed-held-after-shutdown")), ErrorKind::Unknown, ) })? @@ -375,7 +376,7 @@ impl Service { { Ok(PackageState::Installed(InstalledState { manifest })) } else { - Err(Error::new(eyre!("Race condition detected - package state changed during load"), ErrorKind::Database)) + Err(Error::new(eyre!("{}", t!("service.mod.race-condition-detected")), ErrorKind::Database)) } }) } @@ -446,7 +447,7 @@ impl Service { handle_installed(S9pk::open(s9pk_path, Some(id)).await?).await } PackageStateMatchModelRef::Error(e) => Err(Error::new( - eyre!("Failed to parse PackageDataEntry, found {e:?}"), + eyre!("{}", t!("service.mod.failed-to-parse-package-data-entry", error = format!("{e:?}"))), ErrorKind::Deserialization, )), } @@ -552,7 +553,7 @@ impl Service { true } else { tracing::warn!( - "Deleting task {id} because action no longer exists" + "{}", t!("service.mod.deleting-task-action-no-longer-exists", id = id) ); false } @@ -684,6 +685,7 @@ struct ServiceActorSeed { #[derive(Deserialize, Serialize, Parser, TS)] pub struct RebuildParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, } pub async fn rebuild(ctx: RpcContext, RebuildParams { id }: RebuildParams) -> Result<(), Error> { @@ -789,7 +791,7 @@ pub async fn attach( .join("\n"); return Err(Error::new( eyre!( - "no matching subcontainers are running for {id}; some possible choices are:\n{subcontainers}" + "{}", t!("service.mod.no-matching-subcontainers", id = id, subcontainers = subcontainers) ), ErrorKind::NotFound, )); @@ -828,7 +830,7 @@ pub async fn attach( .map(format_subcontainer_pair) .join("\n"); return Err(Error::new( - eyre!("multiple subcontainers found for {id}: \n{subcontainer_ids}"), + eyre!("{}", t!("service.mod.multiple-subcontainers-found", id = id, subcontainer_ids = subcontainer_ids)), ErrorKind::InvalidRequest, )); } @@ -985,7 +987,7 @@ pub async fn attach( "signal" => { if data.len() != 4 { return Err(Error::new( - eyre!("invalid byte length for signal: {}", data.len()), + eyre!("{}", t!("service.mod.invalid-byte-length-for-signal", length = data.len())), ErrorKind::InvalidRequest )); } @@ -1118,14 +1120,14 @@ async fn get_passwd_command(etc_passwd_path: PathBuf, user: &str) -> RootCommand } } Err(Error::new( - eyre!("Could not parse /etc/passwd for shell: {}", contents), + eyre!("{}", t!("service.mod.could-not-parse-etc-passwd", contents = contents)), ErrorKind::Filesystem, )) } .await .map(RootCommand) .unwrap_or_else(|e| { - tracing::error!("Could not get the /etc/passwd: {e}"); + tracing::error!("{}", t!("service.mod.could-not-get-etc-passwd", error = e)); tracing::debug!("{e:?}"); RootCommand("/bin/sh".to_string()) }) @@ -1133,18 +1135,19 @@ async fn get_passwd_command(etc_passwd_path: PathBuf, user: &str) -> RootCommand #[derive(Deserialize, Serialize, Parser)] pub struct CliAttachParams { + #[arg(help = "help.arg.package-id")] pub id: PackageId, - #[arg(long)] + #[arg(long, help = "help.arg.force-tty")] pub force_tty: bool, - #[arg(trailing_var_arg = true)] + #[arg(trailing_var_arg = true, help = "help.arg.command-to-execute")] pub command: Vec, - #[arg(long, short)] + #[arg(long, short, help = "help.arg.subcontainer-name")] subcontainer: Option, - #[arg(long, short)] + #[arg(long, short, help = "help.arg.container-name")] name: Option, - #[arg(long, short)] + #[arg(long, short, help = "help.arg.user-name")] user: Option, - #[arg(long, short)] + #[arg(long, short, help = "help.arg.image-id")] image_id: Option, } #[instrument[skip_all]] @@ -1287,7 +1290,7 @@ pub async fn cli_attach( "exit" => { if data.len() != 4 { return Err(Error::new( - eyre!("invalid byte length for exit code: {}", data.len()), + eyre!("{}", t!("service.mod.invalid-byte-length-for-exit-code", length = data.len())), ErrorKind::InvalidRequest )); } diff --git a/core/src/service/persistent_container.rs b/core/src/service/persistent_container.rs index e71b5fd5b..16f14047d 100644 --- a/core/src/service/persistent_container.rs +++ b/core/src/service/persistent_container.rs @@ -318,7 +318,7 @@ impl PersistentContainer { .get() .ok_or_else(|| { Error::new( - eyre!("PersistentContainer has been destroyed"), + eyre!("{}", t!("service.persistent-container.container-destroyed")), ErrorKind::Incoherent, ) })? @@ -354,7 +354,7 @@ impl PersistentContainer { .get() .ok_or_else(|| { Error::new( - eyre!("PersistentContainer has been destroyed"), + eyre!("{}", t!("service.persistent-container.container-destroyed")), ErrorKind::Incoherent, ) })? @@ -364,7 +364,7 @@ impl PersistentContainer { let handle = NonDetachingJoinHandle::from(tokio::spawn(async move { let chown_status = async { let res = server.run_unix(&path, |err| { - tracing::error!("error on unix socket {}: {err}", path.display()) + tracing::error!("{}", t!("service.persistent-container.error-on-unix-socket", path = path.display(), error = err)) })?; Command::new("chown") .arg("100000:100000") @@ -386,7 +386,7 @@ impl PersistentContainer { })); let shutdown = recv.await.map_err(|_| { Error::new( - eyre!("unix socket server thread panicked"), + eyre!("{}", t!("service.persistent-container.unix-socket-server-panicked")), ErrorKind::Unknown, ) })??; @@ -396,7 +396,7 @@ impl PersistentContainer { .is_some() { return Err(Error::new( - eyre!("PersistentContainer already initialized"), + eyre!("{}", t!("service.persistent-container.already-initialized")), ErrorKind::InvalidRequest, )); } @@ -473,7 +473,7 @@ impl PersistentContainer { if let Some(destroy) = self.destroy(uninit) { destroy.await?; } - tracing::info!("Service for {} exited", self.s9pk.as_manifest().id); + tracing::info!("{}", t!("service.persistent-container.service-exited", id = self.s9pk.as_manifest().id)); Ok(()) } diff --git a/core/src/service/service_actor.rs b/core/src/service/service_actor.rs index 724be7f16..ae728e8a5 100644 --- a/core/src/service/service_actor.rs +++ b/core/src/service/service_actor.rs @@ -47,9 +47,9 @@ impl Actor for ServiceActor { } .await { - tracing::error!("error synchronizing state of service: {e}"); + tracing::error!("{}", t!("service.service-actor.error-synchronizing-state", error = e)); tracing::debug!("{e:?}"); - tracing::error!("Retrying in {}s...", SYNC_RETRY_COOLDOWN_SECONDS); + tracing::error!("{}", t!("service.service-actor.retrying-in-seconds", seconds = SYNC_RETRY_COOLDOWN_SECONDS)); tokio::time::timeout( Duration::from_secs(SYNC_RETRY_COOLDOWN_SECONDS), async { diff --git a/core/src/service/transition/backup.rs b/core/src/service/transition/backup.rs index 30787311e..089d9f127 100644 --- a/core/src/service/transition/backup.rs +++ b/core/src/service/transition/backup.rs @@ -25,7 +25,7 @@ impl ServiceActorSeed { fut.await.map_err(Error::from) } else { Err(Error::new( - eyre!("No backup to resume"), + eyre!("{}", t!("service.transition.backup.no-backup-to-resume")), ErrorKind::Cancelled, )) }; diff --git a/core/src/service/uninstall.rs b/core/src/service/uninstall.rs index 90fc5eade..598e02cb9 100644 --- a/core/src/service/uninstall.rs +++ b/core/src/service/uninstall.rs @@ -62,7 +62,7 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(), | PackageState::Removing(InstalledState { manifest }) => manifest, s => { return Err(Error::new( - eyre!("Invalid package state for cleanup: {s:?}"), + eyre!("{}", t!("service.uninstall.invalid-package-state-for-cleanup", state = format!("{s:?}"))), ErrorKind::InvalidRequest, )); } diff --git a/core/src/setup.rs b/core/src/setup.rs index 1c1103961..95aa276a7 100644 --- a/core/src/setup.rs +++ b/core/src/setup.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::time::Duration; use color_eyre::eyre::eyre; @@ -18,8 +17,9 @@ use ts_rs::TS; use crate::account::AccountInfo; use crate::auth::write_shadow; -use crate::backup::restore::recover_full_embassy; +use crate::backup::restore::recover_full_server; use crate::backup::target::BackupTargetFS; +use crate::bins::set_locale; use crate::context::rpc::InitRpcContextPhases; use crate::context::setup::SetupResult; use crate::context::{RpcContext, SetupContext}; @@ -37,10 +37,11 @@ use crate::prelude::*; use crate::progress::{FullProgress, PhaseProgressTrackerHandle, ProgressUnits}; use crate::rpc_continuations::Guid; use crate::shutdown::Shutdown; -use crate::system::sync_kiosk; +use crate::system::{KeyboardOptions, SetLanguageParams, save_language, sync_kiosk}; use crate::util::Invoke; use crate::util::crypto::EncryptedWire; -use crate::util::io::{Counter, create_file, dir_copy, dir_size}; +use crate::util::io::{Counter, create_file, dir_copy, dir_size, read_file_to_string}; +use crate::util::serde::{IoFormat, Pem}; use crate::{DATA_DIR, Error, ErrorKind, MAIN_DATA, PACKAGE_DATA, PLATFORM, ResultExt}; pub fn setup() -> ParentHandler { @@ -53,6 +54,10 @@ pub fn setup() -> ParentHandler { ) .subcommand("disk", disk::()) .subcommand("attach", from_fn_async(attach).no_cli()) + .subcommand( + "install-os", + from_fn_async(crate::os_install::install_os).no_cli(), + ) .subcommand("execute", from_fn_async(execute).no_cli()) .subcommand("cifs", cifs::()) .subcommand("complete", from_fn_async(complete).no_cli()) @@ -66,9 +71,14 @@ pub fn setup() -> ParentHandler { .subcommand("logs", crate::system::logs::()) .subcommand( "logs", - from_fn_async(crate::logs::cli_logs::).no_display(), + from_fn_async(crate::logs::cli_logs::) + .no_display() + .with_about("about.display-os-logs"), ) .subcommand("restart", from_fn_async(restart).no_cli()) + .subcommand("shutdown", from_fn_async(shutdown).no_cli()) + .subcommand("set-language", from_fn_async(set_language).no_cli()) + .subcommand("set-keyboard", from_fn_async(set_keyboard).no_cli()) } pub fn disk() -> ParentHandler { @@ -80,8 +90,24 @@ pub fn disk() -> ParentHandler { ) } +const LIVE_MEDIUM_PATH: &str = "/run/live/medium"; + pub async fn list_disks(ctx: SetupContext) -> Result, Error> { - crate::disk::util::list(&ctx.os_partitions).await + let mut disks = crate::disk::util::list( + &ctx.config + .peek(|c| c.os_partitions.clone()) + .unwrap_or_default(), + ) + .await?; + + // Filter out the disk containing the live medium (installer USB) + if let Ok(Some(live_medium_source)) = + crate::disk::util::get_mount_source(LIVE_MEDIUM_PATH).await + { + disks.retain(|disk| disk.logicalname != live_medium_source); + } + + Ok(disks) } #[instrument(skip_all)] @@ -91,7 +117,9 @@ async fn setup_init( kiosk: Option, init_phases: InitPhases, ) -> Result<(AccountInfo, InitResult), Error> { - let init_result = init(&ctx.webserver, &ctx.config, init_phases).await?; + let init_result = init(&ctx.webserver, &ctx.config.peek(|c| c.clone()), init_phases).await?; + let language = ctx.language.peek(|a| a.clone()); + let keyboard = ctx.keyboard.peek(|a| a.clone()); let account = init_result .net_ctrl @@ -107,6 +135,12 @@ async fn setup_init( if let Some(kiosk) = kiosk { info.as_kiosk_mut().ser(&Some(kiosk))?; } + if let Some(language) = language.clone() { + info.as_language_mut().ser(&Some(language))?; + } + if let Some(keyboard) = keyboard.clone() { + info.as_keyboard_mut().ser(&Some(keyboard))?; + } Ok(account) }) @@ -115,6 +149,13 @@ async fn setup_init( sync_kiosk(kiosk).await?; + if let Some(language) = language { + save_language(&*language).await?; + } + if let Some(keyboard) = keyboard { + keyboard.save().await?; + } + if let Some(password) = &password { write_shadow(&password).await?; } @@ -126,11 +167,10 @@ async fn setup_init( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct AttachParams { - #[serde(rename = "startOsPassword")] - password: Option, - guid: Arc, + pub password: Option, + pub guid: InternedString, #[ts(optional)] - kiosk: Option, + pub kiosk: Option, } #[instrument(skip_all)] @@ -144,70 +184,95 @@ pub async fn attach( ) -> Result { let setup_ctx = ctx.clone(); ctx.run_setup(move || async move { - let progress = &setup_ctx.progress; - let mut disk_phase = progress.add_phase("Opening data drive".into(), Some(10)); - let init_phases = InitPhases::new(&progress); - let rpc_ctx_phases = InitRpcContextPhases::new(&progress); + let progress = &setup_ctx.progress; + let mut disk_phase = progress.add_phase(t!("setup.opening-data-drive").into(), Some(10)); + let init_phases = InitPhases::new(&progress); + let rpc_ctx_phases = InitRpcContextPhases::new(&progress); - let password: Option = match password { - Some(a) => match a.decrypt(&setup_ctx) { - a @ Some(_) => a, - None => { - return Err(Error::new( - color_eyre::eyre::eyre!("Couldn't decode password"), - crate::ErrorKind::Unknown, - )); - } - }, - None => None, - }; + let password: Option = match password { + Some(a) => match a.decrypt(&setup_ctx) { + a @ Some(_) => a, + None => { + return Err(Error::new( + color_eyre::eyre::eyre!("{}", t!("setup.couldnt-decode-password")), + crate::ErrorKind::Unknown, + )); + } + }, + None => None, + }; - disk_phase.start(); - let requires_reboot = crate::disk::main::import( - &*disk_guid, - DATA_DIR, - if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() { - RepairStrategy::Aggressive - } else { - RepairStrategy::Preen - }, - if disk_guid.ends_with("_UNENC") { None } else { Some(DEFAULT_PASSWORD) }, - ) - .await?; - let _ = setup_ctx.disk_guid.set(disk_guid.clone()); + disk_phase.start(); + let requires_reboot = crate::disk::main::import( + &*disk_guid, + DATA_DIR, if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() { - tokio::fs::remove_file(REPAIR_DISK_PATH) - .await - .with_ctx(|_| (ErrorKind::Filesystem, REPAIR_DISK_PATH))?; - } - if requires_reboot.0 { - crate::disk::main::export(&*disk_guid, DATA_DIR).await?; - return Err(Error::new( - eyre!( - "Errors were corrected with your disk, but the server must be restarted in order to proceed" - ), - ErrorKind::DiskManagement, - )); - } - disk_phase.complete(); + RepairStrategy::Aggressive + } else { + RepairStrategy::Preen + }, + if disk_guid.ends_with("_UNENC") { + None + } else { + Some(DEFAULT_PASSWORD) + }, + ) + .await?; + let _ = setup_ctx.disk_guid.set(disk_guid.clone()); + if tokio::fs::metadata(REPAIR_DISK_PATH).await.is_ok() { + tokio::fs::remove_file(REPAIR_DISK_PATH) + .await + .with_ctx(|_| (ErrorKind::Filesystem, REPAIR_DISK_PATH))?; + } + if requires_reboot.0 { + crate::disk::main::export(&*disk_guid, DATA_DIR).await?; + return Err(Error::new( + eyre!("{}", t!("setup.disk-errors-corrected-restart-required")), + ErrorKind::DiskManagement, + )); + } + disk_phase.complete(); - let (account, net_ctrl) = setup_init(&setup_ctx, password, kiosk, init_phases).await?; + let (account, net_ctrl) = setup_init(&setup_ctx, password, kiosk, init_phases).await?; - let rpc_ctx = RpcContext::init(&setup_ctx.webserver, &setup_ctx.config, disk_guid, Some(net_ctrl), rpc_ctx_phases).await?; + let rpc_ctx = RpcContext::init( + &setup_ctx.webserver, + &setup_ctx.config.peek(|c| c.clone()), + disk_guid, + Some(net_ctrl), + rpc_ctx_phases, + ) + .await?; - Ok(((&account).try_into()?, rpc_ctx)) - })?; + Ok(( + SetupResult { + hostname: account.hostname, + root_ca: Pem(account.root_ca_cert), + needs_restart: setup_ctx.install_rootfs.peek(|a| a.is_some()), + }, + rpc_ctx, + )) + })?; Ok(ctx.progress().await) } #[derive(Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "kebab-case")] #[ts(export)] #[serde(tag = "status")] pub enum SetupStatusRes { - Complete(SetupResult), + NeedsInstall, + Incomplete(SetupInfo), Running(SetupProgress), + Complete(SetupResult), +} + +#[derive(Default, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct SetupInfo { + pub guid: Option, + pub attach: bool, } #[derive(Debug, Deserialize, Serialize, TS)] @@ -218,17 +283,23 @@ pub struct SetupProgress { pub guid: Guid, } -pub async fn status(ctx: SetupContext) -> Result, Error> { +pub async fn status(ctx: SetupContext) -> Result { if let Some(res) = ctx.result.get() { match res { - Ok((res, _)) => Ok(Some(SetupStatusRes::Complete(res.clone()))), + Ok((res, _)) => Ok(SetupStatusRes::Complete(res.clone())), Err(e) => Err(e.clone_output()), } } else { if ctx.task.initialized() { - Ok(Some(SetupStatusRes::Running(ctx.progress().await))) + Ok(SetupStatusRes::Running(ctx.progress().await)) } else { - Ok(None) + let path = Path::new("/media/startos/config/setup.json"); + if tokio::fs::metadata(path).await.is_err() { + return Ok(SetupStatusRes::NeedsInstall); + } + IoFormat::Json + .from_slice(read_file_to_string(path).await?.as_bytes()) + .map(SetupStatusRes::Incomplete) } } } @@ -282,7 +353,7 @@ pub async fn verify_cifs( guard.unmount().await?; if start_os.is_empty() { return Err(Error::new( - eyre!("No Backup Found"), + eyre!("{}", t!("setup.no-backup-found")), crate::ErrorKind::NotFound, )); } @@ -304,12 +375,34 @@ pub enum RecoverySource { }, } +pub async fn setup_data_drive( + ctx: &SetupContext, + logicalname: &Path, +) -> Result { + let encryption_password = if ctx.disable_encryption { + None + } else { + Some(DEFAULT_PASSWORD) + }; + let guid = crate::disk::main::create( + &[logicalname], + &pvscan().await?, + DATA_DIR, + encryption_password, + ) + .await?; + let _ = crate::disk::main::import(&*guid, DATA_DIR, RepairStrategy::Preen, encryption_password) + .await?; + let _ = ctx.disk_guid.set(guid.clone()); + Ok(guid) +} + #[derive(Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct SetupExecuteParams { - start_os_logicalname: PathBuf, - start_os_password: EncryptedWire, + guid: InternedString, + password: EncryptedWire, recovery_source: Option>, #[ts(optional)] kiosk: Option, @@ -319,17 +412,17 @@ pub struct SetupExecuteParams { pub async fn execute( ctx: SetupContext, SetupExecuteParams { - start_os_logicalname, - start_os_password, + guid, + password, recovery_source, kiosk, }: SetupExecuteParams, ) -> Result { - let start_os_password = match start_os_password.decrypt(&ctx) { + let password = match password.decrypt(&ctx) { Some(a) => a, None => { return Err(Error::new( - color_eyre::eyre::eyre!("Couldn't decode startOsPassword"), + color_eyre::eyre::eyre!("{}", t!("setup.couldnt-decode-startos-password")), crate::ErrorKind::Unknown, )); } @@ -343,7 +436,7 @@ pub async fn execute( target, password: password.decrypt(&ctx).ok_or_else(|| { Error::new( - color_eyre::eyre::eyre!("Couldn't decode recoveryPassword"), + color_eyre::eyre::eyre!("{}", t!("setup.couldnt-decode-recovery-password")), crate::ErrorKind::Unknown, ) })?, @@ -354,15 +447,7 @@ pub async fn execute( }; let setup_ctx = ctx.clone(); - ctx.run_setup(move || { - execute_inner( - setup_ctx, - start_os_logicalname, - start_os_password, - recovery, - kiosk, - ) - })?; + ctx.run_setup(move || execute_inner(setup_ctx, guid, password, recovery, kiosk))?; Ok(ctx.progress().await) } @@ -386,7 +471,7 @@ pub async fn complete(ctx: SetupContext) -> Result { } Some(Err(e)) => Err(e.clone_output()), None => Err(Error::new( - eyre!("setup.execute has not completed successfully"), + eyre!("{}", t!("setup.execute-not-completed")), crate::ErrorKind::InvalidRequest, )), } @@ -394,63 +479,77 @@ pub async fn complete(ctx: SetupContext) -> Result { #[instrument(skip_all)] pub async fn exit(ctx: SetupContext) -> Result<(), Error> { - ctx.shutdown.send(None).expect("failed to shutdown"); + let shutdown = if let Some((rootfs, config)) = ctx.install_rootfs.replace(None) { + config.unmount(false).await?; + rootfs.unmount().await?; + Some(Shutdown { + disk_guid: ctx.disk_guid.get().cloned(), + restart: true, + }) + } else { + None + }; + + ctx.shutdown + .send(shutdown) + .map_err(|e| eyre!("failed to shutdown: {e}")) + .log_err(); Ok(()) } #[instrument(skip_all)] pub async fn restart(ctx: SetupContext) -> Result<(), Error> { + if let Some((rootfs, config)) = ctx.install_rootfs.replace(None) { + config.unmount(false).await?; + rootfs.unmount().await?; + } ctx.shutdown .send(Some(Shutdown { disk_guid: ctx.disk_guid.get().cloned(), restart: true, })) - .expect("failed to shutdown"); + .map_err(|e| eyre!("failed to shutdown: {e}")) + .log_err(); + Ok(()) +} + +#[instrument(skip_all)] +pub async fn shutdown(ctx: SetupContext) -> Result<(), Error> { + if let Some((rootfs, config)) = ctx.install_rootfs.replace(None) { + config.unmount(false).await?; + rootfs.unmount().await?; + } + ctx.shutdown + .send(Some(Shutdown { + disk_guid: ctx.disk_guid.get().cloned(), + restart: false, + })) + .map_err(|e| eyre!("failed to shutdown: {e}")) + .log_err(); Ok(()) } #[instrument(skip_all)] pub async fn execute_inner( ctx: SetupContext, - start_os_logicalname: PathBuf, - start_os_password: String, + guid: InternedString, + password: String, recovery_source: Option>, kiosk: Option, ) -> Result<(SetupResult, RpcContext), Error> { let progress = &ctx.progress; - let mut disk_phase = progress.add_phase("Formatting data drive".into(), Some(10)); let restore_phase = match recovery_source.as_ref() { Some(RecoverySource::Backup { .. }) => { - Some(progress.add_phase("Restoring backup".into(), Some(100))) + Some(progress.add_phase(t!("setup.restoring-backup").into(), Some(100))) } Some(RecoverySource::Migrate { .. }) => { - Some(progress.add_phase("Transferring data".into(), Some(100))) + Some(progress.add_phase(t!("setup.transferring-data").into(), Some(100))) } None => None, }; let init_phases = InitPhases::new(&progress); let rpc_ctx_phases = InitRpcContextPhases::new(&progress); - disk_phase.start(); - let encryption_password = if ctx.disable_encryption { - None - } else { - Some(DEFAULT_PASSWORD) - }; - let guid = Arc::new( - crate::disk::main::create( - &[start_os_logicalname], - &pvscan().await?, - DATA_DIR, - encryption_password, - ) - .await?, - ); - let _ = crate::disk::main::import(&*guid, DATA_DIR, RepairStrategy::Preen, encryption_password) - .await?; - let _ = ctx.disk_guid.set(guid.clone()); - disk_phase.complete(); - let progress = SetupExecuteProgress { init_phases, restore_phase, @@ -460,25 +559,25 @@ pub async fn execute_inner( match recovery_source { Some(RecoverySource::Backup { target, - password, + password: recovery_password, server_id, }) => { recover( &ctx, guid, - start_os_password, + password, target, server_id, - password, + recovery_password, kiosk, progress, ) .await } Some(RecoverySource::Migrate { guid: old_guid }) => { - migrate(&ctx, guid, &old_guid, start_os_password, kiosk, progress).await + migrate(&ctx, guid, &old_guid, password, kiosk, progress).await } - None => fresh_setup(&ctx, guid, &start_os_password, kiosk, progress).await, + None => fresh_setup(&ctx, guid, &password, kiosk, progress).await, } } @@ -490,8 +589,8 @@ pub struct SetupExecuteProgress { async fn fresh_setup( ctx: &SetupContext, - guid: Arc, - start_os_password: &str, + guid: InternedString, + password: &str, kiosk: Option, SetupExecuteProgress { init_phases, @@ -499,34 +598,56 @@ async fn fresh_setup( .. }: SetupExecuteProgress, ) -> Result<(SetupResult, RpcContext), Error> { - let account = AccountInfo::new(start_os_password, root_ca_start_time().await)?; + let account = AccountInfo::new(password, root_ca_start_time().await)?; let db = ctx.db().await?; let kiosk = Some(kiosk.unwrap_or(true)).filter(|_| &*PLATFORM != "raspberrypi"); sync_kiosk(kiosk).await?; - db.put(&ROOT, &Database::init(&account, kiosk)?).await?; + + let language = ctx.language.peek(|a| a.clone()); + let keyboard = ctx.keyboard.peek(|a| a.clone()); + + if let Some(language) = &language { + save_language(&**language).await?; + } + + if let Some(keyboard) = &keyboard { + keyboard.save().await?; + } + + db.put(&ROOT, &Database::init(&account, kiosk, language, keyboard)?) + .await?; drop(db); - let init_result = init(&ctx.webserver, &ctx.config, init_phases).await?; + let config = ctx.config.peek(|c| c.clone()); + + let init_result = init(&ctx.webserver, &config, init_phases).await?; let rpc_ctx = RpcContext::init( &ctx.webserver, - &ctx.config, + &config, guid, Some(init_result), rpc_ctx_phases, ) .await?; - write_shadow(start_os_password).await?; + write_shadow(password).await?; - Ok(((&account).try_into()?, rpc_ctx)) + Ok(( + SetupResult { + hostname: account.hostname, + root_ca: Pem(account.root_ca_cert), + needs_restart: ctx.install_rootfs.peek(|a| a.is_some()), + }, + rpc_ctx, + )) } #[instrument(skip_all)] async fn recover( ctx: &SetupContext, - guid: Arc, - start_os_password: String, + guid: InternedString, + password: String, recovery_source: BackupTargetFS, server_id: String, recovery_password: String, @@ -534,10 +655,10 @@ async fn recover( progress: SetupExecuteProgress, ) -> Result<(SetupResult, RpcContext), Error> { let recovery_source = TmpMountGuard::mount(&recovery_source, ReadWrite).await?; - recover_full_embassy( + recover_full_server( ctx, guid.clone(), - start_os_password, + password, recovery_source, &server_id, &recovery_password, @@ -550,9 +671,9 @@ async fn recover( #[instrument(skip_all)] async fn migrate( ctx: &SetupContext, - guid: Arc, + guid: InternedString, old_guid: &str, - start_os_password: String, + password: String, kiosk: Option, SetupExecuteProgress { init_phases, @@ -632,16 +753,38 @@ async fn migrate( crate::disk::main::export(&old_guid, "/media/startos/migrate").await?; restore_phase.complete(); - let (account, net_ctrl) = setup_init(&ctx, Some(start_os_password), kiosk, init_phases).await?; + let (account, net_ctrl) = setup_init(&ctx, Some(password), kiosk, init_phases).await?; let rpc_ctx = RpcContext::init( &ctx.webserver, - &ctx.config, + &ctx.config.peek(|c| c.clone()), guid, Some(net_ctrl), rpc_ctx_phases, ) .await?; - Ok(((&account).try_into()?, rpc_ctx)) + Ok(( + SetupResult { + hostname: account.hostname, + root_ca: Pem(account.root_ca_cert), + needs_restart: ctx.install_rootfs.peek(|a| a.is_some()), + }, + rpc_ctx, + )) +} + +pub async fn set_language( + ctx: SetupContext, + SetLanguageParams { language }: SetLanguageParams, +) -> Result<(), Error> { + set_locale(&*language); + ctx.language.replace(Some(language)); + Ok(()) +} + +pub async fn set_keyboard(ctx: SetupContext, options: KeyboardOptions) -> Result<(), Error> { + options.apply_to_session().await?; + ctx.keyboard.replace(Some(options)); + Ok(()) } diff --git a/core/src/shutdown.rs b/core/src/shutdown.rs index 5df2317a3..2b25ad9f9 100644 --- a/core/src/shutdown.rs +++ b/core/src/shutdown.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use crate::PLATFORM; use crate::context::RpcContext; @@ -10,7 +9,7 @@ use crate::util::Invoke; #[derive(Debug, Clone)] pub struct Shutdown { - pub disk_guid: Option>, + pub disk_guid: Option, pub restart: bool, } impl Shutdown { @@ -19,9 +18,9 @@ impl Shutdown { use std::process::Command; if self.restart { - tracing::info!("Beginning server restart"); + tracing::info!("{}", t!("shutdown.beginning-restart")); } else { - tracing::info!("Beginning server shutdown"); + tracing::info!("{}", t!("shutdown.beginning-shutdown")); } let rt = tokio::runtime::Builder::new_current_thread() @@ -37,18 +36,18 @@ impl Shutdown { .invoke(crate::ErrorKind::Journald) .await { - tracing::error!("Error Stopping Journald: {}", e); + tracing::error!("{}", t!("shutdown.error-stopping-journald", error = e.to_string())); tracing::debug!("{:?}", e); } if let Some(guid) = &self.disk_guid { if let Err(e) = export(guid, crate::DATA_DIR).await { - tracing::error!("Error Exporting Volume Group: {}", e); + tracing::error!("{}", t!("shutdown.error-exporting-volume-group", error = e.to_string())); tracing::debug!("{:?}", e); } } if &*PLATFORM != "raspberrypi" || self.restart { if let Err(e) = SHUTDOWN.play().await { - tracing::error!("Error Playing Shutdown Song: {}", e); + tracing::error!("{}", t!("shutdown.error-playing-shutdown-song", error = e.to_string())); tracing::debug!("{:?}", e); } } diff --git a/core/src/ssh.rs b/core/src/ssh.rs index cec4b0daf..7d8b073b4 100644 --- a/core/src/ssh.rs +++ b/core/src/ssh.rs @@ -92,14 +92,14 @@ pub fn ssh() -> ParentHandler { "add", from_fn_async(add) .no_display() - .with_about("Add ssh key") + .with_about("about.add-ssh-key") .with_call_remote::(), ) .subcommand( "remove", from_fn_async(remove) .no_display() - .with_about("Remove ssh key") + .with_about("about.remove-ssh-key") .with_call_remote::(), ) .subcommand( @@ -109,7 +109,7 @@ pub fn ssh() -> ParentHandler { .with_custom_display_fn(|handle, result| { display_all_ssh_keys(handle.params, result) }) - .with_about("List ssh keys") + .with_about("about.list-ssh-keys") .with_call_remote::(), ) } @@ -118,6 +118,7 @@ pub fn ssh() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct AddParams { + #[arg(help = "help.arg.ssh-public-key")] key: SshPubKey, } @@ -152,6 +153,7 @@ pub async fn add(ctx: RpcContext, AddParams { key }: AddParams) -> Result() -> ParentHandler { "zram", from_fn_async(zram) .no_display() - .with_about("Enable zram") + .with_about("about.enable-zram") .with_call_remote::(), ) .subcommand( @@ -44,7 +45,7 @@ pub fn experimental() -> ParentHandler { .with_custom_display_fn(|handle, result| { display_governor_info(handle.params, result) }) - .with_about("Show current and available CPU governors") + .with_about("about.show-cpu-governors") .with_call_remote::(), ) } @@ -81,6 +82,7 @@ pub async fn enable_zram() -> Result<(), Error> { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ZramParams { + #[arg(help = "help.arg.enable-zram")] enable: bool, } @@ -148,6 +150,7 @@ fn display_governor_info( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct GovernorParams { + #[arg(help = "help.arg.governor-name")] set: Option, } @@ -159,7 +162,10 @@ pub async fn governor( if let Some(set) = set { if !available.contains(&set) { return Err(Error::new( - eyre!("Governor {set} not available"), + eyre!( + "{}", + t!("system.governor-not-available", governor = set.to_string()) + ), ErrorKind::InvalidRequest, )); } @@ -295,7 +301,7 @@ pub fn kiosk() -> ParentHandler { enable_kiosk().await }) .no_display() - .with_about("Enable kiosk mode") + .with_about("about.enable-kiosk-mode") .with_call_remote::(), ) .subcommand( @@ -313,7 +319,7 @@ pub fn kiosk() -> ParentHandler { disable_kiosk().await }) .no_display() - .with_about("Disable kiosk mode") + .with_about("about.disable-kiosk-mode") .with_call_remote::(), ) } @@ -553,7 +559,13 @@ pub async fn launch_metrics_task Receiver>>( let init_temp = match get_temp().await { Ok(a) => Some(a), Err(e) => { - tracing::error!("Could not get initial temperature: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-initial-temperature", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); None } @@ -570,12 +582,24 @@ pub async fn launch_metrics_task Receiver>>( break; } Err(e) => { - tracing::error!("Could not get initial cpu info: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-initial-cpu-info", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } }, Err(e) => { - tracing::error!("Could not get initial proc stat: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-initial-proc-stat", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } } @@ -590,7 +614,13 @@ pub async fn launch_metrics_task Receiver>>( break; } Err(e) => { - tracing::error!("Could not get initial mem info: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-initial-mem-info", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } } @@ -605,7 +635,13 @@ pub async fn launch_metrics_task Receiver>>( break; } Err(e) => { - tracing::error!("Could not get initial disk info: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-initial-disk-info", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } } @@ -650,7 +686,13 @@ async fn launch_temp_task( }); } Err(e) => { - tracing::error!("Could not get new temperature: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-new-temperature", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } } @@ -673,7 +715,13 @@ async fn launch_cpu_task( cache.send_modify(|c| c.as_mut().unwrap().cpu = info); } Err(e) => { - tracing::error!("Could not get new CPU Metrics: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-new-cpu-metrics", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } } @@ -692,7 +740,13 @@ async fn launch_mem_task(cache: &Watch>, mut shutdown: Receiver< cache.send_modify(|c| c.as_mut().unwrap().memory = a); } Err(e) => { - tracing::error!("Could not get new Memory Metrics: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-new-memory-metrics", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } } @@ -721,7 +775,13 @@ async fn launch_disk_task( }); } Err(e) => { - tracing::error!("Could not get new Disk Metrics: {}", e); + tracing::error!( + "{}", + t!( + "system.could-not-get-new-disk-metrics", + error = e.to_string() + ) + ); tracing::debug!("{:?}", e); } } @@ -757,7 +817,12 @@ async fn get_temp() -> Result { } }) .reduce(f64::max) - .ok_or_else(|| Error::new(eyre!("No temperatures available"), ErrorKind::Filesystem))?; + .ok_or_else(|| { + Error::new( + eyre!("{}", t!("system.no-temperatures-available")), + ErrorKind::Filesystem, + ) + })?; Ok(Celsius(temp)) } @@ -803,7 +868,10 @@ async fn get_proc_stat() -> Result { .map(|s| { s.parse::().map_err(|e| { Error::new( - color_eyre::eyre::eyre!("Invalid /proc/stat column value: {}", e), + color_eyre::eyre::eyre!( + "{}", + t!("system.invalid-proc-stat-column", error = e.to_string()) + ), ErrorKind::ParseSysInfo, ) }) @@ -813,8 +881,8 @@ async fn get_proc_stat() -> Result { if stats.len() < 10 { Err(Error::new( eyre!( - "Columns missing from /proc/stat. Need 10, found {}", - stats.len() + "{}", + t!("system.columns-missing-from-proc-stat", count = stats.len()) ), ErrorKind::ParseSysInfo, )) @@ -872,16 +940,22 @@ pub async fn get_mem_info() -> Result { zram_free: None, }; fn get_num_kb(l: &str) -> Result { - let e = Error::new( - color_eyre::eyre::eyre!("Invalid meminfo line: {}", l), - ErrorKind::ParseSysInfo, - ); + let line = l.to_string(); + let e = || { + Error::new( + color_eyre::eyre::eyre!( + "{}", + t!("system.invalid-meminfo-line", line = line.clone()) + ), + ErrorKind::ParseSysInfo, + ) + }; match l.split_whitespace().skip(1).next() { Some(x) => match x.parse() { Ok(y) => Ok(y), - Err(_) => Err(e), + Err(_) => Err(e()), }, - None => Err(e), + None => Err(e()), } } for entry in contents.lines() { @@ -900,10 +974,19 @@ pub async fn get_mem_info() -> Result { } } fn ensure_present(a: Option, field: &str) -> Result { - a.ok_or(Error::new( - color_eyre::eyre::eyre!("{} missing from /proc/meminfo", field), - ErrorKind::ParseSysInfo, - )) + let field_str = field.to_string(); + a.ok_or_else(|| { + Error::new( + color_eyre::eyre::eyre!( + "{}", + t!( + "system.field-missing-from-meminfo", + field = field_str.clone() + ) + ), + ErrorKind::ParseSysInfo, + ) + }) } let mem_total = ensure_present(mem_info.mem_total, "MemTotal")?; let mem_free = ensure_present(mem_info.mem_free, "MemFree")?; @@ -963,15 +1046,15 @@ async fn get_disk_info() -> Result { #[ts(export)] #[serde(rename_all = "camelCase")] pub struct SmtpValue { - #[arg(long)] + #[arg(long, help = "help.arg.smtp-server")] pub server: String, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-port")] pub port: u16, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-from")] pub from: String, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-login")] pub login: String, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-password")] pub password: Option, } pub async fn set_system_smtp(ctx: RpcContext, smtp: SmtpValue) -> Result<(), Error> { @@ -1009,17 +1092,17 @@ pub async fn clear_system_smtp(ctx: RpcContext) -> Result<(), Error> { #[ts(export)] #[serde(rename_all = "camelCase")] pub struct TestSmtpParams { - #[arg(long)] + #[arg(long, help = "help.arg.smtp-server")] pub server: String, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-port")] pub port: u16, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-from")] pub from: String, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-to")] pub to: String, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-login")] pub login: String, - #[arg(long)] + #[arg(long, help = "help.arg.smtp-password")] pub password: String, } pub async fn test_smtp( @@ -1053,6 +1136,127 @@ pub async fn test_smtp( Ok(()) } +#[derive(Debug, Clone, Deserialize, Serialize, TS, Parser)] +#[serde(rename_all = "camelCase")] +pub struct KeyboardOptions { + #[arg(help = "help.arg.keyboard-layout")] + pub layout: InternedString, + #[arg(short, long, help = "help.arg.keyboard-keymap")] + pub keymap: Option, + #[arg(short, long, help = "help.arg.keyboard-model")] + pub model: Option, + #[arg(short, long, help = "help.arg.keyboard-variant")] + pub variant: Option, + #[arg(short, long = "option", help = "help.arg.keyboard-option")] + #[serde(default)] + pub options: Vec, +} +impl KeyboardOptions { + /// NOTE: will error if kiosk inactive + pub async fn apply_to_session(&self) -> Result<(), Error> { + let mut cmd = Command::new("setxkbmap"); + cmd.env("DISPLAY", ":0") + .env("XAUTHORITY", "/home/kiosk/.Xauthority"); + cmd.arg("-layout").arg(&*self.layout); + if let Some(variant) = self.variant.as_deref() { + cmd.arg("-variant").arg(variant); + } + for option in &self.options { + cmd.arg("-option").arg(&**option); + } + cmd.invoke(ErrorKind::SetSysInfo).await?; + Ok(()) + } + + pub async fn save(&self) -> Result<(), Error> { + write_file_atomic( + "/media/startos/config/overlay/etc/vconsole.conf", + format!( + include_str!("./vconsole.conf.template"), + model = self.model.as_deref().unwrap_or_default(), + layout = &*self.layout, + variant = self.variant.as_deref().unwrap_or_default(), + options = self.options.join(","), + keymap = self.keymap.as_deref().unwrap_or(&*self.layout), + ), + ) + .await?; + write_file_atomic( + "/media/startos/config/overlay/etc/X11/xorg.conf.d/00-keyboard.conf", + format!( + include_str!("./keyboard.conf.template"), + model = self.model.as_deref().unwrap_or_default(), + layout = &*self.layout, + variant = self.variant.as_deref().unwrap_or_default(), + options = self.options.join(","), + ), + ) + .await + } +} + +pub async fn set_keyboard(ctx: RpcContext, options: KeyboardOptions) -> Result<(), Error> { + options.apply_to_session().await.log_err(); + options.save().await?; + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_keyboard_mut() + .ser(&Some(options)) + }) + .await + .result?; + Ok(()) +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, Parser)] +#[serde(rename_all = "camelCase")] +pub struct SetLanguageParams { + #[arg(help = "help.arg.language-code")] + pub language: InternedString, +} + +pub async fn save_language(language: &str) -> Result<(), Error> { + write_file_atomic( + "/etc/locale.gen", + format!("{language}.UTF-8 UTF-8\n").as_bytes(), + ) + .await?; + Command::new("locale-gen") + .invoke(ErrorKind::SetSysInfo) + .await?; + copy_file( + "/usr/lib/locale/locale-archive", + "/media/startos/config/overlay/usr/lib/locale/locale-archive", + ) + .await?; + write_file_atomic( + "/media/startos/config/overlay/etc/default/locale", + format!("LANG={language}.UTF-8\n").as_bytes(), + ) + .await?; + Ok(()) +} + +pub async fn set_language( + ctx: RpcContext, + SetLanguageParams { language }: SetLanguageParams, +) -> Result<(), Error> { + set_locale(&*language); + save_language(&*language).await?; + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_language_mut() + .ser(&Some(language.clone())) + }) + .await + .result?; + Ok(()) +} + #[tokio::test] #[ignore] pub async fn test_get_temp() { diff --git a/core/src/system/vconsole.conf.template b/core/src/system/vconsole.conf.template new file mode 100644 index 000000000..8bf585b37 --- /dev/null +++ b/core/src/system/vconsole.conf.template @@ -0,0 +1,7 @@ +XKBMODEL="{model}" +XKBLAYOUT="{layout}" +XKBVARIANT="{variant}" +XKBOPTIONS="{options}" + +KEYMAP="{keymap}" + diff --git a/core/src/tunnel/api.rs b/core/src/tunnel/api.rs index 39cd10a38..5d7f53ff2 100644 --- a/core/src/tunnel/api.rs +++ b/core/src/tunnel/api.rs @@ -20,19 +20,19 @@ pub fn tunnel_api() -> ParentHandler { .subcommand( "db", super::db::db_api::() - .with_about("Commands to interact with the db i.e. dump and apply"), + .with_about("about.commands-interact-with-db-dump-apply"), ) .subcommand( "auth", - super::auth::auth_api::().with_about("Add or remove authorized clients"), + super::auth::auth_api::().with_about("about.add-or-remove-authorized-clients"), ) .subcommand( "subnet", - subnet_api::().with_about("Add, remove, or modify subnets"), + subnet_api::().with_about("about.add-remove-or-modify-subnets"), ) .subcommand( "device", - device_api::().with_about("Add, remove, or list devices in subnets"), + device_api::().with_about("about.add-remove-or-list-devices-in-subnets"), ) .subcommand( "port-forward", @@ -42,7 +42,7 @@ pub fn tunnel_api() -> ParentHandler { from_fn_async(add_forward) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Add a new port forward") + .with_about("about.add-new-port-forward") .with_call_remote::(), ) .subcommand( @@ -50,7 +50,7 @@ pub fn tunnel_api() -> ParentHandler { from_fn_async(remove_forward) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Remove a port forward") + .with_about("about.remove-port-forward") .with_call_remote::(), ), ) @@ -70,7 +70,7 @@ pub fn subnet_api() -> ParentHandler { .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|a, _| a) .no_display() - .with_about("Add a new subnet") + .with_about("about.add-new-subnet") .with_call_remote::(), ) .subcommand( @@ -79,7 +79,7 @@ pub fn subnet_api() -> ParentHandler { .with_metadata("sync_db", Value::Bool(true)) .with_inherited(|a, _| a) .no_display() - .with_about("Remove a subnet") + .with_about("about.remove-subnet") .with_call_remote::(), ) } @@ -91,7 +91,7 @@ pub fn device_api() -> ParentHandler { from_fn_async(add_device) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Add a device to a subnet") + .with_about("about.add-device-to-subnet") .with_call_remote::(), ) .subcommand( @@ -99,7 +99,7 @@ pub fn device_api() -> ParentHandler { from_fn_async(remove_device) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Remove a device from a subnet") + .with_about("about.remove-device-from-subnet") .with_call_remote::(), ) .subcommand( @@ -123,13 +123,13 @@ pub fn device_api() -> ParentHandler { Ok(()) }) - .with_about("List devices in a subnet") + .with_about("about.list-devices-in-subnet") .with_call_remote::(), ) .subcommand( "show-config", from_fn_async(show_config) - .with_about("Show the WireGuard configuration for a device") + .with_about("about.show-wireguard-configuration-for-device") .with_call_remote::(), ) } diff --git a/core/src/tunnel/auth.rs b/core/src/tunnel/auth.rs index 713d038a3..758617686 100644 --- a/core/src/tunnel/auth.rs +++ b/core/src/tunnel/auth.rs @@ -129,14 +129,14 @@ pub fn auth_api() -> ParentHandler { .subcommand( "set-password", from_fn_async(set_password_cli) - .with_about("Set user interface password") - .no_display(), + .no_display() + .with_about("about.set-user-interface-password"), ) .subcommand( "reset-password", from_fn_async(reset_password) - .with_about("Reset user interface password") - .no_display(), + .no_display() + .with_about("about.reset-user-interface-password"), ) .subcommand( "key", @@ -146,7 +146,7 @@ pub fn auth_api() -> ParentHandler { from_fn_async(add_key) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Add a new authorized key") + .with_about("about.add-new-authorized-key") .with_call_remote::(), ) .subcommand( @@ -154,7 +154,7 @@ pub fn auth_api() -> ParentHandler { from_fn_async(remove_key) .with_metadata("sync_db", Value::Bool(true)) .no_display() - .with_about("Remove an authorized key") + .with_about("about.remove-authorized-key") .with_call_remote::(), ) .subcommand( @@ -175,7 +175,7 @@ pub fn auth_api() -> ParentHandler { table.print_tty(false)?; Ok(()) }) - .with_about("List authorized keys") + .with_about("about.list-authorized-keys") .with_call_remote::(), ), ) diff --git a/core/src/tunnel/context.rs b/core/src/tunnel/context.rs index 6de360bf5..5afac62ab 100644 --- a/core/src/tunnel/context.rs +++ b/core/src/tunnel/context.rs @@ -43,11 +43,11 @@ use crate::util::sync::{SyncMutex, Watch}; #[serde(rename_all = "kebab-case")] #[command(rename_all = "kebab-case")] pub struct TunnelConfig { - #[arg(short = 'c', long = "config")] + #[arg(short = 'c', long = "config", help = "help.arg.config-file-path")] pub config: Option, - #[arg(short = 'l', long = "listen")] + #[arg(short = 'l', long = "listen", help = "help.arg.tunnel-listen-address")] pub tunnel_listen: Option, - #[arg(short = 'd', long = "datadir")] + #[arg(short = 'd', long = "datadir", help = "help.arg.data-directory")] pub datadir: Option, } impl ContextConfig for TunnelConfig { @@ -244,6 +244,7 @@ impl Deref for TunnelContext { #[derive(Debug, Deserialize, Serialize, Parser)] pub struct TunnelAddrParams { + #[arg(help = "help.arg.tunnel-ip-address")] pub tunnel: IpAddr, } @@ -310,6 +311,7 @@ impl CallRemote for CliContext { #[derive(Debug, Deserialize, Serialize, Parser)] pub struct TunnelUrlParams { + #[arg(help = "help.arg.tunnel-url")] pub tunnel: Url, } diff --git a/core/src/tunnel/db.rs b/core/src/tunnel/db.rs index 848e8481a..bd83305fd 100644 --- a/core/src/tunnel/db.rs +++ b/core/src/tunnel/db.rs @@ -89,7 +89,7 @@ pub fn db_api() -> ParentHandler { "dump", from_fn_async(cli_dump) .with_display_serializable() - .with_about("Filter/query db to display tables and records"), + .with_about("about.filter-query-db-display-tables-records"), ) .subcommand( "dump", @@ -107,7 +107,7 @@ pub fn db_api() -> ParentHandler { "apply", from_fn_async(cli_apply) .no_display() - .with_about("Update a db record"), + .with_about("about.update-db-record"), ) .subcommand( "apply", @@ -121,8 +121,9 @@ pub fn db_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CliDumpParams { - #[arg(long = "pointer", short = 'p')] + #[arg(long = "pointer", short = 'p', help = "help.arg.json-pointer")] pointer: Option, + #[arg(help = "help.arg.database-path")] path: Option, } @@ -154,7 +155,7 @@ async fn cli_dump( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct DumpParams { - #[arg(long = "pointer", short = 'p')] + #[arg(long = "pointer", short = 'p', help = "help.arg.json-pointer")] #[ts(type = "string | null")] pointer: Option, } @@ -170,7 +171,9 @@ pub async fn dump(ctx: TunnelContext, DumpParams { pointer }: DumpParams) -> Res #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct CliApplyParams { + #[arg(help = "help.arg.db-apply-expr")] expr: String, + #[arg(help = "help.arg.database-path")] path: Option, } @@ -225,7 +228,9 @@ async fn cli_apply( #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct ApplyParams { + #[arg(help = "help.arg.db-apply-expr")] expr: String, + #[arg(help = "help.arg.database-path")] path: Option, } diff --git a/core/src/tunnel/web.rs b/core/src/tunnel/web.rs index 35d18f643..ba4b5c7d4 100644 --- a/core/src/tunnel/web.rs +++ b/core/src/tunnel/web.rs @@ -98,27 +98,27 @@ pub fn web_api() -> ParentHandler { "init", from_fn_async_local(init_web) .no_display() - .with_about("Initialize the webserver"), + .with_about("about.initialize-webserver"), ) .subcommand( "set-listen", from_fn_async(set_listen) .no_display() - .with_about("Set the listen address for the webserver") + .with_about("about.set-listen-address-for-webserver") .with_call_remote::(), ) .subcommand( "get-listen", from_fn_async(get_listen) .with_display_serializable() - .with_about("Get the listen address for the webserver") + .with_about("about.get-listen-address-for-webserver") .with_call_remote::(), ) .subcommand( "get-available-ips", from_fn_async(get_available_ips) .with_display_serializable() - .with_about("Get available IP addresses to bind to") + .with_about("about.get-available-ip-addresses-to-bind") .with_call_remote::(), ) .subcommand( @@ -129,12 +129,12 @@ pub fn web_api() -> ParentHandler { "import-certificate", from_fn_async_local(import_certificate_cli) .no_display() - .with_about("Import a certificate to use for the webserver"), + .with_about("about.import-certificate-for-webserver"), ) .subcommand( "generate-certificate", from_fn_async(generate_certificate) - .with_about("Generate a certificate to use for the webserver") + .with_about("about.generate-certificate-for-webserver") .with_call_remote::(), ) .subcommand( @@ -150,28 +150,28 @@ pub fn web_api() -> ParentHandler { } Ok(()) }) - .with_about("Get the certificate for the webserver") + .with_about("about.get-certificate-for-webserver") .with_call_remote::(), ) .subcommand( "enable", from_fn_async(enable_web) - .with_about("Enable the webserver") .no_display() + .with_about("about.enable-webserver") .with_call_remote::(), ) .subcommand( "disable", from_fn_async(disable_web) .no_display() - .with_about("Disable the webserver") + .with_about("about.disable-webserver") .with_call_remote::(), ) .subcommand( "reset", from_fn_async(reset_web) .no_display() - .with_about("Reset the webserver") + .with_about("about.reset-webserver") .with_call_remote::(), ) } @@ -279,7 +279,7 @@ pub async fn import_certificate_cli( #[derive(Debug, Deserialize, Serialize, Parser)] pub struct GenerateCertParams { - #[arg(help = "Subject Alternative Name(s)")] + #[arg(help = "help.arg.cert-subject-alt-names")] pub subject: Vec, } @@ -331,6 +331,7 @@ pub async fn get_certificate(ctx: TunnelContext) -> Result> #[derive(Debug, Deserialize, Serialize, Parser)] #[serde(rename_all = "camelCase")] pub struct SetListenParams { + #[arg(help = "help.arg.listen-address")] pub listen: SocketAddr, } @@ -466,7 +467,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> { println!("✅ Success! ✅"); println!( - "The webserver is running. Below is your URL{} and Root Certificate Authority (Root CA).", + "StartTunnel installed successfully. Below is your Web URL{} and Root Certificate Authority (Root CA).", if password.is_some() { ", password," } else { @@ -474,7 +475,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> { } ); println!(); - println!("🌐 URL"); + println!("🌐 Web URL"); println!("https://{listen}"); if listen.ip().is_unspecified() { println!(concat!( @@ -517,21 +518,32 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> { .map(Pem) .or_not_found("certificate in chain")?; println!("📝 Root CA:"); - print!("{cert}"); + print!("{cert}\n"); println!(concat!( - "To trust your StartTunnel Root CA (above):\n", - " 1. Copy the Root CA ", - "(starting with -----BEGIN CERTIFICATE----- and ending with -----END CERTIFICATE-----).\n", - " 2. Open a text editor: \n", - " - Linux: gedit, nano, or any editor\n", - " - Mac: TextEdit\n", - " - Windows: Notepad\n", - " 3. Paste the contents of your Root CA.\n", - " 4. Save the file with a `.crt` extension ", - "(e.g. `start-tunnel.crt`) (make sure it saves as plain text, not rich text).\n", - " 5. Follow instructions to trust you StartTunnel Root CA: ", - "https://staging.docs.start9.com/user-manual/trust-ca.html#2-trust-your-servers-root-ca." + "To access your Web URL securely, trust your Root CA (displayed above) on your client device(s):\n", + " - MacOS\n", + " 1. Open the Terminal app\n", + " 2. Paste the following command (**DO NOTt** click Return): pbcopy < ~/Desktop/ca.crt\n", + " 3. Copy your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", + " 4. Back in Terminal, click Return. ca.crt is saved to your Desktop\n", + " 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/mac/ca.html\n", + " - Linux\n", + " 1. Open gedit, nano, or any editor\n", + " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", + " 3. Name the file ca.crt and save as plaintext\n", + " 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/linux/ca.html\n", + " - Windows\n", + " 1. Open the Notepad app\n", + " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", + " 3. Name the file ca.crt and save as plaintext\n", + " 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/windows/ca.html\n", + " - Android/Graphene\n", + " 1. Send the ca.crt file (created above) to yourself\n", + " 2. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/android/ca.html\n", + " - iOS\n", + " 1. Send the ca.crt file (created above) to yourself\n", + " 2. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/ios/ca.html\n", )); return Ok(()); diff --git a/core/src/tunnel/wg.rs b/core/src/tunnel/wg.rs index 8696ba293..eb4aceba7 100644 --- a/core/src/tunnel/wg.rs +++ b/core/src/tunnel/wg.rs @@ -41,6 +41,7 @@ impl WgServer { Command::new("wg-quick") .arg("down") .arg(WIREGUARD_INTERFACE_NAME) + .env("LANG", "C.UTF-8") .invoke(ErrorKind::Network) .await .or_else(|e| { diff --git a/core/src/update/mod.rs b/core/src/update/mod.rs index 3595cb42a..d1f7bb217 100644 --- a/core/src/update/mod.rs +++ b/core/src/update/mod.rs @@ -42,12 +42,13 @@ use crate::util::io::AtomicFile; #[serde(rename_all = "camelCase")] #[command(rename_all = "kebab-case")] pub struct UpdateSystemParams { + #[arg(help = "help.arg.registry-url")] #[ts(type = "string")] registry: Url, #[ts(type = "string | null")] - #[arg(long = "to")] + #[arg(long = "to", help = "help.arg.update-target-version")] target: Option, - #[arg(long = "no-progress", action = ArgAction::SetFalse)] + #[arg(long = "no-progress", action = ArgAction::SetFalse, help = "help.arg.no-progress")] #[serde(default)] progress: bool, } @@ -82,9 +83,7 @@ pub async fn update_system( .de()? { return Err(Error::new( - eyre!( - "Server was already updated. Please restart your device before attempting to update again." - ), + eyre!("{}", t!("update.already-updated-restart-required")), ErrorKind::InvalidRequest, )); } @@ -143,7 +142,7 @@ pub async fn update_system( } .await { - tracing::error!("Error returning progress of update: {e}"); + tracing::error!("{}", t!("update.error-returning-progress", error = e.to_string())); tracing::debug!("{e:?}") } }, @@ -176,11 +175,11 @@ pub async fn cli_update_system( .await?, )?; match res.target { - None => println!("No updates available"), + None => println!("{}", t!("update.no-updates-available")), Some(v) => { if let Some(progress) = res.progress { let mut ws = context.ws_continuation(progress).await?; - let mut progress = PhasedProgressBar::new(&format!("Updating to v{v}...")); + let mut progress = PhasedProgressBar::new(&t!("update.updating-to-version", version = v.to_string())); let mut prev = None; while let Some(msg) = ws.try_next().await.with_kind(ErrorKind::Network)? { if let tokio_tungstenite::tungstenite::Message::Text(msg) = msg { @@ -201,9 +200,9 @@ pub async fn cli_update_system( prev.overall.set_complete(); progress.update(&prev); } - println!("Update complete. Restart your server to apply the update.") + println!("{}", t!("update.complete-restart-to-apply")) } else { - println!("Updating to v{v}...") + println!("{}", t!("update.updating-to-version", version = v.to_string())) } } } @@ -279,7 +278,7 @@ async fn maybe_do_update( let mut status = peeked.as_public().as_server_info().as_status_info().de()?; if status.update_progress.is_some() { return Err(Error::new( - eyre!("Server is already updating!"), + eyre!("{}", t!("update.already-updating")), crate::ErrorKind::InvalidRequest, )); } @@ -296,9 +295,7 @@ async fn maybe_do_update( if status.updated { return Err(Error::new( - eyre!( - "Server was already updated. Please restart your device before attempting to update again." - ), + eyre!("{}", t!("update.already-updated-restart-required")), crate::ErrorKind::InvalidRequest, )); } @@ -343,7 +340,7 @@ async fn maybe_do_update( CIRCLE_OF_5THS_SHORT.play().await.log_err(); } Err(e) => { - let err_string = format!("Update was not successful because of {}", e); + let err_string = t!("update.not-successful", error = e.to_string()).to_string(); ctx.db .mutate(|db| { db.as_public_mut() @@ -355,7 +352,7 @@ async fn maybe_do_update( db, None, NotificationLevel::Error, - "StartOS Update Failed".to_owned(), + t!("update.failed-title").to_string(), err_string, (), ) diff --git a/core/src/util/cpupower.rs b/core/src/util/cpupower.rs index db625f90e..fbd0a6188 100644 --- a/core/src/util/cpupower.rs +++ b/core/src/util/cpupower.rs @@ -67,7 +67,7 @@ pub async fn get_available_governors() -> Result, Error> { for_cpu .entry(current_cpu.ok_or_else(|| { Error::new( - eyre!("governors listed before cpu"), + eyre!("{}", t!("util.cpupower.governors-listed-before-cpu")), ErrorKind::ParseSysInfo, ) })?) @@ -95,6 +95,7 @@ pub async fn current_governor() -> Result, Error> { let Some(raw) = Command::new("cpupower") .arg("frequency-info") .arg("-p") + .env("LANG", "C.UTF-8") .invoke(ErrorKind::CpuSettings) .await .and_then(|s| Ok(Some(String::from_utf8(s)?))) @@ -122,7 +123,10 @@ pub async fn current_governor() -> Result, Error> { } } Err(Error::new( - eyre!("Failed to parse cpupower output:\n{raw}"), + eyre!( + "{}", + t!("util.cpupower.failed-to-parse-output", output = raw) + ), ErrorKind::ParseSysInfo, )) } diff --git a/core/src/util/io.rs b/core/src/util/io.rs index fbcd9f840..99940c373 100644 --- a/core/src/util/io.rs +++ b/core/src/util/io.rs @@ -775,6 +775,23 @@ pub fn dir_copy<'a, P0: AsRef + 'a + Send + Sync, P1: AsRef + 'a + S .boxed() } +pub async fn copy_file(src: impl AsRef, dst: impl AsRef) -> Result<(), Error> { + let src = src.as_ref(); + tokio::fs::metadata(src) + .await + .with_ctx(|_| (ErrorKind::Filesystem, src.display()))?; + let dst = dst.as_ref(); + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("mkdir -p {parent:?}")))?; + } + tokio::fs::copy(src, dst) + .await + .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("cp {src:?} -> {dst:?}")))?; + Ok(()) +} + #[pin_project::pin_project] pub struct TimeoutStream { timeout: Duration, diff --git a/core/src/util/lshw.rs b/core/src/util/lshw.rs index 2f61741b9..8b8a8654c 100644 --- a/core/src/util/lshw.rs +++ b/core/src/util/lshw.rs @@ -25,6 +25,7 @@ impl LshwDevice { Self::Display(_) => "display", } } + #[instrument(skip_all)] pub fn from_value(value: &Value) -> Option { match value["class"].as_str() { Some("processor") => Some(LshwDevice::Processor(LshwProcessor::from_value(value))), @@ -41,6 +42,7 @@ pub struct LshwProcessor { pub capabilities: BTreeSet, } impl LshwProcessor { + #[instrument(skip_all)] fn from_value(value: &Value) -> Self { Self { product: value["product"].as_str().map(From::from), @@ -63,6 +65,7 @@ pub struct LshwDisplay { pub driver: Option, } impl LshwDisplay { + #[instrument(skip_all)] fn from_value(value: &Value) -> Self { Self { product: value["product"].as_str().map(From::from), diff --git a/core/src/util/mod.rs b/core/src/util/mod.rs index 3a351bd87..6aaaf86dd 100644 --- a/core/src/util/mod.rs +++ b/core/src/util/mod.rs @@ -666,6 +666,27 @@ pub fn new_guid() -> InternedString { )) } +/// A utility for lazily computing a Display value. This is useful for i18n +/// where you want to defer the translation until the value is actually displayed, +/// avoiding allocations in the common case where the message is not rendered. +pub struct LazyDisplay(F); +impl D, D: fmt::Display> LazyDisplay { + pub fn new(f: F) -> Self { + LazyDisplay(f) + } +} +impl D, D: fmt::Display> fmt::Display for LazyDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", (self.0)()) + } +} + +/// Creates a lazily-evaluated Display implementation. +/// The closure is only called when the value is actually displayed. +pub fn lazy_display D, D: fmt::Display>(f: F) -> LazyDisplay { + LazyDisplay::new(f) +} + #[derive(Debug, Clone, TS)] #[ts(type = "string")] pub enum PathOrUrl { diff --git a/core/src/util/net.rs b/core/src/util/net.rs index 53168d310..70275c735 100644 --- a/core/src/util/net.rs +++ b/core/src/util/net.rs @@ -97,7 +97,7 @@ impl WebSocket { if self.ping_state.is_some() { self.fused = true; break Poll::Ready(Some(Err(axum::Error::new(eyre!( - "Timeout: WebSocket did not respond to ping within {PING_TIMEOUT:?}" + "{}", t!("util.net.websocket-ping-timeout", timeout = format!("{PING_TIMEOUT:?}")) ))))); } self.ping_state = Some((false, rand::random())); diff --git a/core/src/util/rpc.rs b/core/src/util/rpc.rs index 165632c5d..5376dfe35 100644 --- a/core/src/util/rpc.rs +++ b/core/src/util/rpc.rs @@ -17,13 +17,13 @@ use crate::util::{Apply, PathOrUrl}; pub fn util() -> ParentHandler { ParentHandler::new().subcommand( "b3sum", - from_fn_async(b3sum).with_about("Calculate blake3 hash for a file"), + from_fn_async(b3sum).with_about("about.calculate-blake3-hash-for-file"), ) } #[derive(Debug, Deserialize, Serialize, Parser)] pub struct B3sumParams { - #[arg(long = "no-mmap", action = clap::ArgAction::SetFalse)] + #[arg(long = "no-mmap", action = clap::ArgAction::SetFalse, help = "help.arg.no-mmap")] allow_mmap: bool, file: String, } @@ -57,7 +57,7 @@ pub async fn b3sum( .await } else { Err(Error::new( - eyre!("unknown scheme: {}", url.scheme()), + eyre!("{}", t!("util.rpc.unknown-scheme", scheme = url.scheme())), ErrorKind::InvalidRequest, )) } diff --git a/core/src/util/serde.rs b/core/src/util/serde.rs index ace93738e..aadcd3166 100644 --- a/core/src/util/serde.rs +++ b/core/src/util/serde.rs @@ -652,7 +652,7 @@ impl std::str::FromStr for Duration { fn from_str(s: &str) -> Result { let units_idx = s.find(|c: char| c.is_alphabetic()).ok_or_else(|| { Error::new( - eyre!("Must specify units for duration"), + eyre!("{}", t!("util.serde.must-specify-units-for-duration")), crate::ErrorKind::Deserialization, ) })?; @@ -691,7 +691,7 @@ impl std::str::FromStr for Duration { } _ => { return Err(Error::new( - eyre!("Invalid units for duration"), + eyre!("{}", t!("util.serde.invalid-units-for-duration")), crate::ErrorKind::Deserialization, )); } @@ -1050,7 +1050,7 @@ impl>> FromStr for Base64 { .map(Self) .map_err(|_| { Error::new( - eyre!("failed to create from buffer"), + eyre!("{}", t!("util.serde.failed-to-create-from-buffer")), ErrorKind::Deserialization, ) }) @@ -1151,7 +1151,7 @@ pub fn apply_expr(input: jaq_core::Val, expr: &str) -> Result Result Result Error { } fn noninteractive_err() -> Error { Error::new( - eyre!("Terminal must be in interactive mode for this wizard"), + eyre!("{}", t!("util.tui.terminal-must-be-interactive")), ErrorKind::Filesystem, ) } @@ -21,7 +21,7 @@ where { move |s| { s.parse::() - .map_err(|_| format!("Please enter a valid {what}.")) + .map_err(|_| t!("util.tui.enter-valid-value", what = what).to_string()) } } @@ -50,7 +50,7 @@ pub async fn prompt Result> } } ReadlineEvent::Eof | ReadlineEvent::Interrupted => { - return Err(Error::new(eyre!("Aborted"), ErrorKind::Cancelled)); + return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled)); } _ => (), } @@ -83,7 +83,7 @@ pub async fn prompt_multiline< Err(e) => writeln!(&mut rl_ctx.shared_writer, "{e}")?, }, ReadlineEvent::Eof | ReadlineEvent::Interrupted => { - return Err(Error::new(eyre!("Aborted"), ErrorKind::Cancelled)); + return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled)); } _ => (), } @@ -119,7 +119,7 @@ pub async fn choose_custom_display<'t, T>( .await .map_err(map_miette)?; if choice.len() < 1 { - return Err(Error::new(eyre!("Aborted"), ErrorKind::Cancelled)); + return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled)); } let (idx, choice_str) = string_choices .iter() @@ -127,7 +127,7 @@ pub async fn choose_custom_display<'t, T>( .find(|(_, s)| s.as_str() == choice[0].as_str()) .ok_or_else(|| { Error::new( - eyre!("selected choice does not appear in input"), + eyre!("{}", t!("util.tui.selected-choice-not-in-input")), ErrorKind::Incoherent, ) })?; diff --git a/core/src/version/mod.rs b/core/src/version/mod.rs index 08ca80f4a..6176dcb8d 100644 --- a/core/src/version/mod.rs +++ b/core/src/version/mod.rs @@ -57,8 +57,9 @@ mod v0_4_0_alpha_14; mod v0_4_0_alpha_15; mod v0_4_0_alpha_16; mod v0_4_0_alpha_17; +mod v0_4_0_alpha_18; -pub type Current = v0_4_0_alpha_17::Version; // VERSION_BUMP +pub type Current = v0_4_0_alpha_18::Version; // VERSION_BUMP impl Current { #[instrument(skip(self, db))] @@ -177,7 +178,8 @@ enum Version { V0_4_0_alpha_14(Wrapper), V0_4_0_alpha_15(Wrapper), V0_4_0_alpha_16(Wrapper), - V0_4_0_alpha_17(Wrapper), // VERSION_BUMP + V0_4_0_alpha_17(Wrapper), + V0_4_0_alpha_18(Wrapper), // VERSION_BUMP Other(exver::Version), } @@ -237,7 +239,8 @@ impl Version { Self::V0_4_0_alpha_14(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)), - Self::V0_4_0_alpha_17(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP + Self::V0_4_0_alpha_17(v) => DynVersion(Box::new(v.0)), + Self::V0_4_0_alpha_18(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP Self::Other(v) => { return Err(Error::new( eyre!("unknown version {v}"), @@ -289,7 +292,8 @@ impl Version { Version::V0_4_0_alpha_14(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(), - Version::V0_4_0_alpha_17(Wrapper(x)) => x.semver(), // VERSION_BUMP + Version::V0_4_0_alpha_17(Wrapper(x)) => x.semver(), + Version::V0_4_0_alpha_18(Wrapper(x)) => x.semver(), // VERSION_BUMP Version::Other(x) => x.clone(), } } diff --git a/core/src/version/v0_3_6_alpha_0.rs b/core/src/version/v0_3_6_alpha_0.rs index cae4e917e..c048c97b2 100644 --- a/core/src/version/v0_3_6_alpha_0.rs +++ b/core/src/version/v0_3_6_alpha_0.rs @@ -168,6 +168,12 @@ impl VersionT for Version { let tor_keys = previous_tor_keys(&pg).await?; + Command::new("systemctl") + .arg("stop") + .arg("postgresql@*.service") + .invoke(crate::ErrorKind::Database) + .await?; + Ok((account, ssh_keys, cifs, tor_keys)) } fn up( @@ -277,7 +283,12 @@ impl VersionT for Version { /// MUST be idempotent, and is run after *all* db migrations async fn post_up(self, ctx: &RpcContext, input: Value) -> Result<(), Error> { let path = Path::new(formatcp!("{PACKAGE_DATA}/archive/")); - if !path.is_dir() { + let metadata = tokio::fs::metadata(path).await; + if metadata.is_err() { + // Treat non-existent archive directory as empty + return Ok(()); + } + if !metadata.unwrap().is_dir() { return Err(Error::new( eyre!( "expected path ({}) to be a directory", diff --git a/core/src/version/v0_4_0_alpha_18.rs b/core/src/version/v0_4_0_alpha_18.rs new file mode 100644 index 000000000..2e0b3ba40 --- /dev/null +++ b/core/src/version/v0_4_0_alpha_18.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use exver::{PreReleaseSegment, VersionRange}; + +use super::v0_3_5::V0_3_0_COMPAT; +use super::{VersionT, v0_4_0_alpha_17}; +use crate::context::RpcContext; +use crate::prelude::*; + +lazy_static::lazy_static! { + static ref V0_4_0_alpha_18: exver::Version = exver::Version::new( + [0, 4, 0], + [PreReleaseSegment::String("alpha".into()), 18.into()] + ); +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Version; + +impl VersionT for Version { + type Previous = v0_4_0_alpha_17::Version; + type PreUpRes = (); + + async fn pre_up(self) -> Result { + Ok(()) + } + fn semver(self) -> exver::Version { + V0_4_0_alpha_18.clone() + } + fn compat(self) -> &'static VersionRange { + &V0_3_0_COMPAT + } + #[instrument(skip_all)] + fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result { + db["public"]["serverInfo"]["devices"] = Value::Array(Default::default()); + + let lang = db["public"]["ui"] + .as_object_mut() + .map_or(Value::Null, |m| m.remove("language").unwrap_or_default()); + if let Some(lang) = lang.as_str() { + let lang = match lang { + "en" => "en_US", + "de" => "de_DE", + "es" => "es_ES", + "fr" => "fr_FR", + "pl" => "pl_PL", + _ => return Ok(Value::Null), + }; + + let lang = Value::String(Arc::new(lang.into())); + + db["public"]["serverInfo"]["language"] = lang.clone(); + + return Ok(lang); + } + Ok(Value::Null) + } + async fn post_up(self, _: &RpcContext, input: Value) -> Result<(), Error> { + if let Some(language) = input.as_str() { + crate::system::save_language(language).await?; + } + Ok(()) + } + fn down(self, _db: &mut Value) -> Result<(), Error> { + Ok(()) + } +} diff --git a/debian/startos/postinst b/debian/startos/postinst index 7ed9a5f19..246589f57 100755 --- a/debian/startos/postinst +++ b/debian/startos/postinst @@ -23,11 +23,31 @@ if [ -f /etc/default/grub ]; then sed -i '/\(^\|#\)GRUB_CMDLINE_LINUX=/c\GRUB_CMDLINE_LINUX="boot=startos console=ttyS0,115200n8 console=tty0"' /etc/default/grub sed -i '/\(^\|#\)GRUB_CMDLINE_LINUX_DEFAULT=/c\GRUB_CMDLINE_LINUX_DEFAULT=""' /etc/default/grub sed -i '/\(^\|#\)GRUB_DISTRIBUTOR=/c\GRUB_DISTRIBUTOR="StartOS v$(cat /usr/lib/startos/VERSION.txt)"' /etc/default/grub - sed -i '/\(^\|#\)GRUB_TERMINAL=/c\GRUB_TERMINAL="serial"\nGRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1"' /etc/default/grub - if grep '^GRUB_SERIAL_COMMAND=' /etc/default/grub > /dev/null; then - sed -i '/\(^\|#\)GRUB_SERIAL_COMMAND=/c\GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1"' /etc/default/grub - else - echo 'GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1"' >> /etc/default/grub + # Set a GRUB variable, replacing if it exists (even commented) or appending if not + grub_set() { + sed -i '/\(^\|#\)'"$1"'=/d' /etc/default/grub + printf '%s="%s"\n' "$1" "$2" >> /etc/default/grub + } + # Enable both graphical and serial terminal output + grub_set GRUB_TERMINAL_INPUT 'console serial' + grub_set GRUB_TERMINAL_OUTPUT 'gfxterm serial' + # Remove GRUB_TERMINAL if present (replaced by INPUT/OUTPUT above) + sed -i '/^\(#\|\)GRUB_TERMINAL=/d' /etc/default/grub + # Serial console settings + grub_set GRUB_SERIAL_COMMAND 'serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1' + # Graphics mode and splash background + grub_set GRUB_GFXMODE 800x600 + grub_set GRUB_GFXPAYLOAD_LINUX keep + grub_set GRUB_BACKGROUND '/boot/grub/splash.png' + grub_set GRUB_THEME '/boot/grub/startos-theme/theme.txt' + # Copy splash image and theme to boot partition + if [ -f /usr/lib/startos/splash.png ]; then + mkdir -p /boot/grub + cp /usr/lib/startos/splash.png /boot/grub/splash.png + fi + if [ -d /usr/lib/startos/grub-theme ]; then + mkdir -p /boot/grub/startos-theme + cp -r /usr/lib/startos/grub-theme/* /boot/grub/startos-theme/ fi fi diff --git a/sdk/base/lib/osBindings/AddCategoryParams.ts b/sdk/base/lib/osBindings/AddCategoryParams.ts index 62b04e6e2..b6dfa3fac 100644 --- a/sdk/base/lib/osBindings/AddCategoryParams.ts +++ b/sdk/base/lib/osBindings/AddCategoryParams.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LocaleString } from "./LocaleString" -export type AddCategoryParams = { id: string; name: string } +export type AddCategoryParams = { id: string; name: LocaleString } diff --git a/sdk/base/lib/osBindings/Alerts.ts b/sdk/base/lib/osBindings/Alerts.ts index 819d1c407..9103c1582 100644 --- a/sdk/base/lib/osBindings/Alerts.ts +++ b/sdk/base/lib/osBindings/Alerts.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LocaleString } from "./LocaleString" export type Alerts = { - install: string | null - uninstall: string | null - restore: string | null - start: string | null - stop: string | null + install: LocaleString | null + uninstall: LocaleString | null + restore: LocaleString | null + start: LocaleString | null + stop: LocaleString | null } diff --git a/sdk/base/lib/osBindings/AttachParams.ts b/sdk/base/lib/osBindings/AttachParams.ts index e1de943a5..36ba7f958 100644 --- a/sdk/base/lib/osBindings/AttachParams.ts +++ b/sdk/base/lib/osBindings/AttachParams.ts @@ -2,7 +2,7 @@ import type { EncryptedWire } from "./EncryptedWire" export type AttachParams = { - startOsPassword: EncryptedWire | null + password: EncryptedWire | null guid: string kiosk?: boolean } diff --git a/sdk/base/lib/osBindings/Category.ts b/sdk/base/lib/osBindings/Category.ts index 615094527..cc2bc35ac 100644 --- a/sdk/base/lib/osBindings/Category.ts +++ b/sdk/base/lib/osBindings/Category.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LocaleString } from "./LocaleString" -export type Category = { name: string } +export type Category = { name: LocaleString } diff --git a/sdk/base/lib/osBindings/CurrentDependencyInfo.ts b/sdk/base/lib/osBindings/CurrentDependencyInfo.ts index e56e2be7a..aaabea857 100644 --- a/sdk/base/lib/osBindings/CurrentDependencyInfo.ts +++ b/sdk/base/lib/osBindings/CurrentDependencyInfo.ts @@ -1,8 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DataUrl } from "./DataUrl" +import type { LocaleString } from "./LocaleString" export type CurrentDependencyInfo = { - title: string | null + title: LocaleString | null icon: DataUrl | null versionRange: string } & ({ kind: "exists" } | { kind: "running"; healthChecks: string[] }) diff --git a/sdk/base/lib/osBindings/DependencyMetadata.ts b/sdk/base/lib/osBindings/DependencyMetadata.ts index 3d56ef052..1af1c2ba8 100644 --- a/sdk/base/lib/osBindings/DependencyMetadata.ts +++ b/sdk/base/lib/osBindings/DependencyMetadata.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DataUrl } from "./DataUrl" +import type { LocaleString } from "./LocaleString" export type DependencyMetadata = { - title: string | null + title: LocaleString | null icon: DataUrl | null - description: string | null + description: LocaleString | null optional: boolean } diff --git a/sdk/base/lib/osBindings/Description.ts b/sdk/base/lib/osBindings/Description.ts index bcb92071f..e69b2e90c 100644 --- a/sdk/base/lib/osBindings/Description.ts +++ b/sdk/base/lib/osBindings/Description.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LocaleString } from "./LocaleString" -export type Description = { short: string; long: string } +export type Description = { short: LocaleString; long: LocaleString } diff --git a/sdk/base/lib/osBindings/KeyboardOptions.ts b/sdk/base/lib/osBindings/KeyboardOptions.ts new file mode 100644 index 000000000..b54144de9 --- /dev/null +++ b/sdk/base/lib/osBindings/KeyboardOptions.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type KeyboardOptions = { + layout: string + keymap: string | null + model: string | null + variant: string | null + options: Array +} diff --git a/sdk/base/lib/osBindings/LocaleString.ts b/sdk/base/lib/osBindings/LocaleString.ts new file mode 100644 index 000000000..60a66a331 --- /dev/null +++ b/sdk/base/lib/osBindings/LocaleString.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LocaleString = string | Record diff --git a/sdk/base/lib/osBindings/Manifest.ts b/sdk/base/lib/osBindings/Manifest.ts index 1d0dbfe0a..30b7068f2 100644 --- a/sdk/base/lib/osBindings/Manifest.ts +++ b/sdk/base/lib/osBindings/Manifest.ts @@ -6,6 +6,7 @@ import type { GitHash } from "./GitHash" import type { HardwareRequirements } from "./HardwareRequirements" import type { ImageConfig } from "./ImageConfig" import type { ImageId } from "./ImageId" +import type { LocaleString } from "./LocaleString" import type { PackageId } from "./PackageId" import type { Version } from "./Version" import type { VolumeId } from "./VolumeId" @@ -15,7 +16,7 @@ export type Manifest = { title: string version: Version satisfies: Array - releaseNotes: string + releaseNotes: LocaleString canMigrateTo: string canMigrateFrom: string license: string diff --git a/sdk/base/lib/osBindings/Metadata.ts b/sdk/base/lib/osBindings/Metadata.ts index 0ea43923e..842fa17b6 100644 --- a/sdk/base/lib/osBindings/Metadata.ts +++ b/sdk/base/lib/osBindings/Metadata.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LocaleString } from "./LocaleString" import type { PathOrUrl } from "./PathOrUrl" -export type Metadata = { title: string; icon: PathOrUrl } +export type Metadata = { title: LocaleString; icon: PathOrUrl } diff --git a/sdk/base/lib/osBindings/PackageVersionInfo.ts b/sdk/base/lib/osBindings/PackageVersionInfo.ts index e19cf7d01..f11249acc 100644 --- a/sdk/base/lib/osBindings/PackageVersionInfo.ts +++ b/sdk/base/lib/osBindings/PackageVersionInfo.ts @@ -5,6 +5,7 @@ import type { DependencyMetadata } from "./DependencyMetadata" import type { Description } from "./Description" import type { GitHash } from "./GitHash" import type { HardwareRequirements } from "./HardwareRequirements" +import type { LocaleString } from "./LocaleString" import type { MerkleArchiveCommitment } from "./MerkleArchiveCommitment" import type { PackageId } from "./PackageId" import type { RegistryAsset } from "./RegistryAsset" @@ -15,7 +16,7 @@ export type PackageVersionInfo = { title: string icon: DataUrl description: Description - releaseNotes: string + releaseNotes: LocaleString gitHash: GitHash | null license: string wrapperRepo: string diff --git a/sdk/base/lib/osBindings/Pem.ts b/sdk/base/lib/osBindings/Pem.ts new file mode 100644 index 000000000..1ec1cd375 --- /dev/null +++ b/sdk/base/lib/osBindings/Pem.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Pem = string diff --git a/sdk/base/lib/osBindings/ServerInfo.ts b/sdk/base/lib/osBindings/ServerInfo.ts index 7fc8718ff..1beef03de 100644 --- a/sdk/base/lib/osBindings/ServerInfo.ts +++ b/sdk/base/lib/osBindings/ServerInfo.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Governor } from "./Governor" +import type { KeyboardOptions } from "./KeyboardOptions" import type { LshwDevice } from "./LshwDevice" import type { NetworkInfo } from "./NetworkInfo" import type { ServerStatus } from "./ServerStatus" @@ -27,4 +28,6 @@ export type ServerInfo = { ram: number devices: Array kiosk: boolean | null + language: string | null + keyboard: KeyboardOptions | null } diff --git a/sdk/base/lib/osBindings/SetupExecuteParams.ts b/sdk/base/lib/osBindings/SetupExecuteParams.ts index 289836d78..a8e1e4ae1 100644 --- a/sdk/base/lib/osBindings/SetupExecuteParams.ts +++ b/sdk/base/lib/osBindings/SetupExecuteParams.ts @@ -3,8 +3,8 @@ import type { EncryptedWire } from "./EncryptedWire" import type { RecoverySource } from "./RecoverySource" export type SetupExecuteParams = { - startOsLogicalname: string - startOsPassword: EncryptedWire + guid: string + password: EncryptedWire recoverySource: RecoverySource | null kiosk?: boolean } diff --git a/sdk/base/lib/osBindings/SetupInfo.ts b/sdk/base/lib/osBindings/SetupInfo.ts new file mode 100644 index 000000000..06b6447e6 --- /dev/null +++ b/sdk/base/lib/osBindings/SetupInfo.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupInfo = { guid: string | null; attach: boolean } diff --git a/sdk/base/lib/osBindings/SetupResult.ts b/sdk/base/lib/osBindings/SetupResult.ts index 3147187c1..4d7f51bce 100644 --- a/sdk/base/lib/osBindings/SetupResult.ts +++ b/sdk/base/lib/osBindings/SetupResult.ts @@ -1,8 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Pem } from "./Pem" export type SetupResult = { - torAddresses: Array hostname: string - lanAddress: string - rootCa: string + rootCa: Pem + needsRestart: boolean } diff --git a/sdk/base/lib/osBindings/SetupStatusRes.ts b/sdk/base/lib/osBindings/SetupStatusRes.ts index 93d10c59b..a7f0342ec 100644 --- a/sdk/base/lib/osBindings/SetupStatusRes.ts +++ b/sdk/base/lib/osBindings/SetupStatusRes.ts @@ -1,7 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SetupInfo } from "./SetupInfo" import type { SetupProgress } from "./SetupProgress" import type { SetupResult } from "./SetupResult" export type SetupStatusRes = - | ({ status: "complete" } & SetupResult) + | { status: "needs-install" } + | ({ status: "incomplete" } & SetupInfo) | ({ status: "running" } & SetupProgress) + | ({ status: "complete" } & SetupResult) diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index f0da142fe..32d67a358 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -121,9 +121,11 @@ export { InstallingState } from "./InstallingState" export { InstallParams } from "./InstallParams" export { IpHostname } from "./IpHostname" export { IpInfo } from "./IpInfo" +export { KeyboardOptions } from "./KeyboardOptions" export { ListPackageSignersParams } from "./ListPackageSignersParams" export { ListServiceInterfacesParams } from "./ListServiceInterfacesParams" export { ListVersionSignersParams } from "./ListVersionSignersParams" +export { LocaleString } from "./LocaleString" export { LoginParams } from "./LoginParams" export { LshwDevice } from "./LshwDevice" export { LshwDisplay } from "./LshwDisplay" @@ -161,6 +163,7 @@ export { PackageState } from "./PackageState" export { PackageVersionInfo } from "./PackageVersionInfo" export { PasswordType } from "./PasswordType" export { PathOrUrl } from "./PathOrUrl" +export { Pem } from "./Pem" export { Percentage } from "./Percentage" export { Progress } from "./Progress" export { ProgressUnits } from "./ProgressUnits" @@ -199,6 +202,7 @@ export { SetMainStatusStatus } from "./SetMainStatusStatus" export { SetMainStatus } from "./SetMainStatus" export { SetNameParams } from "./SetNameParams" export { SetupExecuteParams } from "./SetupExecuteParams" +export { SetupInfo } from "./SetupInfo" export { SetupProgress } from "./SetupProgress" export { SetupResult } from "./SetupResult" export { SetupStatusRes } from "./SetupStatusRes" diff --git a/sdk/base/lib/types/ManifestTypes.ts b/sdk/base/lib/types/ManifestTypes.ts index fb9c74425..3ca2ae0cb 100644 --- a/sdk/base/lib/types/ManifestTypes.ts +++ b/sdk/base/lib/types/ManifestTypes.ts @@ -48,9 +48,9 @@ export type SDKManifest = { readonly docsUrl: string readonly description: { /** Short description to display on the marketplace list page. Max length 80 chars. */ - readonly short: string + readonly short: T.LocaleString /** Long description to display on the marketplace details page for this service. Max length 500 chars. */ - readonly long: string + readonly long: T.LocaleString } /** * override the StartOS version this package was made for @@ -96,17 +96,17 @@ export type SDKManifest = { readonly alerts?: { /** An warning alert requiring user confirmation before proceeding with initial installation of this service. */ - readonly install?: string | null + readonly install?: T.LocaleString | null /** An warning alert requiring user confirmation before updating this service. */ - readonly update?: string | null + readonly update?: T.LocaleString | null /** An warning alert requiring user confirmation before uninstalling this service. */ - readonly uninstall?: string | null + readonly uninstall?: T.LocaleString | null /** An warning alert requiring user confirmation before restoring this service from backup. */ - readonly restore?: string | null + readonly restore?: T.LocaleString | null /** An warning alert requiring user confirmation before starting this service. */ - readonly start?: string | null + readonly start?: T.LocaleString | null /** An warning alert requiring user confirmation before stopping this service. */ - readonly stop?: string | null + readonly stop?: T.LocaleString | null } /** * @description A mapping of service dependencies to be displayed to users when viewing the Marketplace diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 2fab5c7cf..48d883edb 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -355,10 +355,13 @@ export class GetServiceInterface { const watch = this.watch(abort.signal) const res = await watch.next() if (this.effects.constRetry) { - watch.next().then(() => { - abort.abort() - this.effects.constRetry && this.effects.constRetry() - }) + watch + .next() + .then(() => { + abort.abort() + this.effects.constRetry && this.effects.constRetry() + }) + .catch() } return res.value } diff --git a/sdk/base/lib/util/getServiceInterfaces.ts b/sdk/base/lib/util/getServiceInterfaces.ts index 0155b3114..e2eb6131a 100644 --- a/sdk/base/lib/util/getServiceInterfaces.ts +++ b/sdk/base/lib/util/getServiceInterfaces.ts @@ -55,10 +55,13 @@ export class GetServiceInterfaces { const watch = this.watch(abort.signal) const res = await watch.next() if (this.effects.constRetry) { - watch.next().then(() => { - abort.abort() - this.effects.constRetry && this.effects.constRetry() - }) + watch + .next() + .then(() => { + abort.abort() + this.effects.constRetry && this.effects.constRetry() + }) + .catch() } return res.value } diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index d3702d1c8..a2a8ed65d 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -67,7 +67,7 @@ import { import { getOwnServiceInterfaces } from "../../base/lib/util/getServiceInterfaces" import { Volumes, createVolumes } from "./util/Volume" -export const OSVersion = testTypeVersion("0.4.0-alpha.17") +export const OSVersion = testTypeVersion("0.4.0-alpha.18") // prettier-ignore type AnyNeverCond = diff --git a/sdk/package/lib/i18n/index.ts b/sdk/package/lib/i18n/index.ts new file mode 100644 index 000000000..39b3c9d22 --- /dev/null +++ b/sdk/package/lib/i18n/index.ts @@ -0,0 +1,74 @@ +/** + * Internationalization (i18n) utilities for StartOS packages. + * + * @example + * ```typescript + * // In package's i18n/index.ts: + * import { setupI18n } from '@start9labs/start-sdk' + * import defaultDict, { DEFAULT_LANG } from './dictionaries/default' + * import translations from './dictionaries/translations' + * + * export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) + * ``` + */ + +type ParamValue = string | number | Date + +/** + * Creates a typed i18n function for a package. + * + * @param defaultDict - The default language dictionary mapping strings to numeric indices + * @param translations - Translation dictionaries for each supported locale + * @param defaultLang - The default language code (e.g., 'en_US') + * @returns A typed i18n function that accepts dictionary keys and optional parameters + */ +export function setupI18n< + Dict extends Record, + Translations extends Record>, +>(defaultDict: Dict, translations: Translations, defaultLang: string) { + const lang = process.env.LANG?.replace(/\.UTF-8$/, "") || defaultLang + + // Convert locale format from en_US to en-US for Intl APIs + const intlLocale = lang.replace("_", "-") + + function getTranslation(): Record | null { + if (lang === defaultLang) return null + + const availableLangs = Object.keys(translations) as (keyof Translations)[] + + const match = + availableLangs.find((l) => l === lang) ?? + availableLangs.find((l) => String(l).startsWith(lang.split("_")[0] + "_")) + + return match ? (translations[match] as Record) : null + } + + const translation = getTranslation() + + function formatValue(value: ParamValue): string { + if (typeof value === "number") { + return new Intl.NumberFormat(intlLocale).format(value) + } + if (value instanceof Date) { + return new Intl.DateTimeFormat(intlLocale).format(value) + } + return value + } + + return function i18n( + key: keyof Dict, + params?: Record, + ): string { + let result = translation + ? translation[defaultDict[key as string]] + : (key as string) + + if (params) { + for (const [paramName, value] of Object.entries(params)) { + result = result.replace(`\${${paramName}}`, formatValue(value)) + } + } + + return result + } +} diff --git a/sdk/package/lib/index.ts b/sdk/package/lib/index.ts index d367368af..f7a9bcb4c 100644 --- a/sdk/package/lib/index.ts +++ b/sdk/package/lib/index.ts @@ -23,6 +23,7 @@ export { matches, utils, } +export { setupI18n } from "./i18n" export * as T from "./types" export { Daemons } from "./mainFn/Daemons" export { SubContainer } from "./util/SubContainer" diff --git a/sdk/package/lib/util/GetServiceManifest.ts b/sdk/package/lib/util/GetServiceManifest.ts index 62c661c82..5965721bf 100644 --- a/sdk/package/lib/util/GetServiceManifest.ts +++ b/sdk/package/lib/util/GetServiceManifest.ts @@ -19,10 +19,13 @@ export class GetServiceManifest { const watch = this.watch(abort.signal) const res = await watch.next() if (this.effects.constRetry) { - watch.next().then(() => { - abort.abort() - this.effects.constRetry && this.effects.constRetry() - }) + watch + .next() + .then(() => { + abort.abort() + this.effects.constRetry && this.effects.constRetry() + }) + .catch() } return res.value } diff --git a/sdk/package/lib/util/fileHelper.ts b/sdk/package/lib/util/fileHelper.ts index e48dd6be9..e7a4c0341 100644 --- a/sdk/package/lib/util/fileHelper.ts +++ b/sdk/package/lib/util/fileHelper.ts @@ -230,10 +230,13 @@ export class FileHelper { eq, ] this.consts.push(record) - watch.next().then(() => { - this.consts = this.consts.filter((r) => r !== record) - effects.constRetry && effects.constRetry() - }) + watch + .next() + .then(() => { + this.consts = this.consts.filter((r) => r !== record) + effects.constRetry && effects.constRetry() + }) + .catch() } return res.value } @@ -263,6 +266,7 @@ export class FileHelper { }) .catch((e) => console.error(asError(e))) if (!prev || !eq(prev.value, newRes)) { + console.error("yielding", JSON.stringify({ prev: prev, newRes })) yield newRes } prev = { value: newRes } diff --git a/sdk/package/lib/version/VersionInfo.ts b/sdk/package/lib/version/VersionInfo.ts index 0d6bd1c62..35d6c0544 100644 --- a/sdk/package/lib/version/VersionInfo.ts +++ b/sdk/package/lib/version/VersionInfo.ts @@ -7,7 +7,7 @@ export type VersionOptions = { /** The exver-compliant version number */ version: Version & ValidateExVer /** The release notes for this version */ - releaseNotes: string + releaseNotes: T.LocaleString /** Data migrations for this version */ migrations: { /** diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index 4d0307622..e89921bdc 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.47", + "version": "0.4.0-beta.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.47", + "version": "0.4.0-beta.48", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package.json b/sdk/package/package.json index ef70c4077..ffeb9f7c2 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.47", + "version": "0.4.0-beta.48", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./package/lib/index.js", "types": "./package/lib/index.d.ts", diff --git a/web/README.md b/web/README.md index 2636e1e5d..7341d2907 100644 --- a/web/README.md +++ b/web/README.md @@ -4,7 +4,6 @@ StartOS web UIs are written in [Angular/Typescript](https://angular.io/docs) and StartOS conditionally serves one of three Web UIs, depending on the state of the system and user choice. -- **install-wizard** - UI for installing StartOS, served on localhost. - **setup-wizard** - UI for setting up StartOS, served on start.local. - **ui** - primary UI for administering StartOS, served on various hosts unique to the instance. @@ -69,7 +68,6 @@ You can develop using mocks (recommended to start) or against a live server. Eit #### Start the standard development server ```sh -npm run start:install npm run start:setup npm run start:ui ``` @@ -122,7 +120,6 @@ Translate the English dictionary below into ``. Format the result as a #### Adding to StartOS - In the `shared` project: - 1. Create a new file (`language.ts`) in `src/i18n/dictionaries` 2. Update the `I18N_PROVIDERS` array in `src/i18n/i18n.providers.ts` (2 places) 3. Update the `languages` array in `/src/i18n/i18n.service.ts` @@ -131,7 +128,6 @@ Translate the English dictionary below into ``. Format the result as a If you have any doubt about the above steps, check the [French example PR](https://github.com/Start9Labs/start-os/pull/2945/files) for reference. - Here in this README: - 1. Add the language to the list of supported languages below ### Updating the English dictionary diff --git a/web/angular.json b/web/angular.json index 862ea8cf1..4d3c0060c 100644 --- a/web/angular.json +++ b/web/angular.json @@ -121,114 +121,6 @@ } } }, - "install-wizard": { - "projectType": "application", - "schematics": {}, - "root": "projects/install-wizard", - "sourceRoot": "projects/install-wizard/src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "outputPath": { - "base": "dist/raw/install-wizard", - "browser": "" - }, - "index": "projects/install-wizard/src/index.html", - "browser": "projects/install-wizard/src/main.ts", - "polyfills": ["zone.js"], - "tsConfig": "projects/install-wizard/tsconfig.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "projects/shared/assets", - "output": "assets" - }, - { - "glob": "**/*.svg", - "input": "node_modules/ionicons/dist/ionicons/svg", - "output": "./svg" - }, - { - "glob": "**/*", - "input": "node_modules/@taiga-ui/icons/src", - "output": "assets/taiga-ui/icons" - } - ], - "styles": [ - "node_modules/@taiga-ui/core/styles/taiga-ui-theme.less" - ], - "scripts": [] - }, - "configurations": { - "production": { - "fileReplacements": [ - { - "replace": "projects/install-wizard/src/environments/environment.ts", - "with": "projects/install-wizard/src/environments/environment.prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "namedChunks": false, - "aot": true, - "extractLicenses": true, - "budgets": [ - { - "type": "initial", - "maximumWarning": "2mb", - "maximumError": "5mb" - } - ] - }, - "ci": { - "progress": false - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true, - "namedChunks": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "options": { - "buildTarget": "install-wizard:build", - "port": 8100 - }, - "configurations": { - "production": { - "buildTarget": "install-wizard:build:production" - }, - "development": { - "buildTarget": "install-wizard:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular/build:extract-i18n", - "options": { - "buildTarget": "install-wizard:build" - } - }, - "lint": { - "builder": "@angular-eslint/builder:lint", - "options": { - "lintFilePatterns": [ - "projects/install-wizard/src/**/*.ts", - "projects/install-wizard/src/**/*.html" - ] - } - } - } - }, "setup-wizard": { "projectType": "application", "schematics": {}, diff --git a/web/lint-staged.config.js b/web/lint-staged.config.js index e351c9ce0..d7097bf6d 100644 --- a/web/lint-staged.config.js +++ b/web/lint-staged.config.js @@ -4,7 +4,6 @@ module.exports = { 'projects/ui/**/*.ts': () => 'npm run check:ui', 'projects/shared/**/*.ts': () => 'npm run check:shared', 'projects/marketplace/**/*.ts': () => 'npm run check:marketplace', - 'projects/install-wizard/**/*.ts': () => 'npm run check:install', 'projects/setup-wizard/**/*.ts': () => 'npm run check:setup', 'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel', } diff --git a/web/package-lock.json b/web/package-lock.json index a23c11ed2..fcaae73ec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "startos-ui", - "version": "0.4.0-alpha.17", + "version": "0.4.0-alpha.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "startos-ui", - "version": "0.4.0-alpha.17", + "version": "0.4.0-alpha.18", "license": "MIT", "dependencies": { "@angular/animations": "^20.3.0", diff --git a/web/package.json b/web/package.json index d6fa9c02f..27e3c0f73 100644 --- a/web/package.json +++ b/web/package.json @@ -1,31 +1,28 @@ { "name": "startos-ui", - "version": "0.4.0-alpha.17", + "version": "0.4.0-alpha.18", "author": "Start9 Labs, Inc", "homepage": "https://start9.com/", "license": "MIT", "scripts": { "ng": "ng", - "check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install && npm run check:setup", + "check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup", "check:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck", "check:marketplace": "tsc --project projects/marketplace/tsconfig.json --noEmit --skipLibCheck", - "check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck", "check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck", "check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck", "check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck", "build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)", - "build:install": "ng run install-wizard:build", "build:setup": "ng run setup-wizard:build", "build:ui": "ng run ui:build", "build:ui:dev": "ng run ui:build:development", "build:tunnel": "ng run start-tunnel:build", - "build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install", + "build:all": "npm run build:deps && npm run build:setup && npm run build:ui", "build:shared": "ng build shared", "build:marketplace": "npm run build:shared && ng build marketplace", "analyze:ui": "ng build ui --stats-json --named-chunks && npx -y @angular-experts/hawkeye dist/raw/ui/stats.json", "publish:shared": "npm run build:shared && npm publish ./dist/shared --access public", "publish:marketplace": "npm run build:marketplace && npm publish ./dist/marketplace --access public", - "start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0", "start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0", "start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0", "start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0", diff --git a/web/projects/install-wizard/src/app/app.component.html b/web/projects/install-wizard/src/app/app.component.html deleted file mode 100644 index 3b06469f9..000000000 --- a/web/projects/install-wizard/src/app/app.component.html +++ /dev/null @@ -1,63 +0,0 @@ - -
- -
-
- @if (selected) { - - } -

{{ selected ? 'Install Type' : 'StartOS Install' }}

- @if (!selected) { -

Select Disk

- } -
{{ error }}
-
-
-
- @for (drive of disks$ | async; track $index) { - - } -
-
- @if (guid) { - - } - - -
-
-
-
-
diff --git a/web/projects/install-wizard/src/app/app.component.scss b/web/projects/install-wizard/src/app/app.component.scss deleted file mode 100644 index 67be9a2db..000000000 --- a/web/projects/install-wizard/src/app/app.component.scss +++ /dev/null @@ -1,71 +0,0 @@ -@use '@taiga-ui/core/styles/taiga-ui-local' as taiga; - -::ng-deep html, -::ng-deep body, -tui-root { - height: 100%; - margin: 0; - color: var(--tui-text-primary); -} - -main { - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background: var(--tui-background-accent-opposite-hover); -} - -.logo { - width: 6rem; - margin-bottom: -2rem; - z-index: 1; -} - -.card { - max-width: min(30rem, 90vw); -} - -.header { - position: relative; - display: flex; - flex-direction: column; - text-align: center; - padding-top: 0.25rem; - margin-bottom: -2rem; - h2 { - margin-top: 0; - } -} - -.back { - position: absolute; - top: 1rem; -} - -.pages { - display: flex; - align-items: center; - overflow: hidden; -} - -.options { - @include taiga.transition(margin); - - min-width: 100%; - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1rem; - box-sizing: border-box; - - &_selected { - margin-left: -100%; - } -} - -[tuiCell]:not(:last-of-type) { - box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal); -} - diff --git a/web/projects/install-wizard/src/app/app.component.ts b/web/projects/install-wizard/src/app/app.component.ts deleted file mode 100644 index 37f360b09..000000000 --- a/web/projects/install-wizard/src/app/app.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { TUI_CONFIRM } from '@taiga-ui/kit' -import { Component, inject } from '@angular/core' -import { DiskInfo, i18nKey, LoadingService, toGuid } from '@start9labs/shared' -import { TuiDialogService } from '@taiga-ui/core' -import { filter, from } from 'rxjs' -import { SUCCESS, toWarning } from 'src/app/app.utils' -import { ApiService } from 'src/app/services/api.service' - -@Component({ - selector: 'app-root', - templateUrl: 'app.component.html', - styleUrls: ['app.component.scss'], - standalone: false, -}) -export class AppComponent { - private readonly loader = inject(LoadingService) - private readonly api = inject(ApiService) - private readonly dialogs = inject(TuiDialogService) - - readonly disks$ = from(this.api.getDisks()) - selected: DiskInfo | null = null - error = '' - - get guid() { - return toGuid(this.selected) - } - - async install(overwrite = false) { - const loader = this.loader.open('Installing StartOS' as i18nKey).subscribe() - const logicalname = this.selected?.logicalname || '' - - try { - await this.api.install({ logicalname, overwrite }) - this.reboot() - } catch (e: any) { - this.error = e.message - } finally { - loader.unsubscribe() - } - } - - warn() { - this.dialogs - .open(TUI_CONFIRM, toWarning(this.selected)) - .pipe(filter(Boolean)) - .subscribe(() => { - this.install(true) - }) - } - - private async reboot() { - this.dialogs - .open('1. Remove the USB stick
2. Click "Reboot" below', SUCCESS) - .subscribe({ - complete: async () => { - const loader = this.loader.open().subscribe() - - try { - await this.api.reboot() - this.dialogs - .open( - window.location.host === 'localhost' - ? 'Please wait 1-2 minutes for your server to restart' - : 'Please wait 1-2 minutes, then refresh this page to access the StartOS setup wizard.', - { - label: 'Rebooting', - size: 's', - closeable: false, - dismissible: false, - }, - ) - .subscribe() - } catch (e: any) { - this.error = e.message - } finally { - loader.unsubscribe() - } - }, - }) - } -} diff --git a/web/projects/install-wizard/src/app/app.module.ts b/web/projects/install-wizard/src/app/app.module.ts deleted file mode 100644 index 8af5922e4..000000000 --- a/web/projects/install-wizard/src/app/app.module.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - provideHttpClient, - withFetch, - withInterceptorsFromDi, -} from '@angular/common/http' -import { NgModule } from '@angular/core' -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' -import { - DriveComponent, - i18nPipe, - RELATIVE_URL, - WorkspaceConfig, -} from '@start9labs/shared' -import { - TuiButton, - TuiIcon, - TuiRoot, - TuiSurface, - TuiTitle, -} from '@taiga-ui/core' -import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { ApiService } from 'src/app/services/api.service' -import { LiveApiService } from 'src/app/services/live-api.service' -import { MockApiService } from 'src/app/services/mock-api.service' -import { AppComponent } from './app.component' - -const { - useMocks, - ui: { api }, -} = require('../../../../config.json') as WorkspaceConfig - -@NgModule({ - declarations: [AppComponent], - imports: [ - BrowserAnimationsModule, - TuiRoot, - DriveComponent, - TuiButton, - TuiCardLarge, - TuiCell, - TuiIcon, - TuiSurface, - TuiTitle, - i18nPipe, - ], - providers: [ - NG_EVENT_PLUGINS, - { - provide: ApiService, - useClass: useMocks ? MockApiService : LiveApiService, - }, - { - provide: RELATIVE_URL, - useValue: `/${api.url}/${api.version}`, - }, - provideHttpClient(withInterceptorsFromDi(), withFetch()), - ], - bootstrap: [AppComponent], -}) -export class AppModule {} diff --git a/web/projects/install-wizard/src/app/app.utils.ts b/web/projects/install-wizard/src/app/app.utils.ts deleted file mode 100644 index a8e66e816..000000000 --- a/web/projects/install-wizard/src/app/app.utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DiskInfo } from '@start9labs/shared' -import { TuiDialogOptions } from '@taiga-ui/core' -import { TuiConfirmData } from '@taiga-ui/kit' - -export const SUCCESS: Partial> = { - label: 'Install Success!', - closeable: false, - size: 's', - data: { button: 'Reboot' }, -} - -export function toWarning( - disk: DiskInfo | null, -): Partial> { - return { - label: 'Warning', - size: 's', - data: { - content: `This action will COMPLETELY erase the disk ${ - disk?.vendor || 'Unknown Vendor' - } - ${disk?.model || 'Unknown Model'} and install StartOS in its place`, - yes: 'Continue', - no: 'Cancel', - }, - } -} diff --git a/web/projects/install-wizard/src/app/services/api.service.ts b/web/projects/install-wizard/src/app/services/api.service.ts deleted file mode 100644 index 2114ef2f4..000000000 --- a/web/projects/install-wizard/src/app/services/api.service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DiskInfo } from '@start9labs/shared' - -export abstract class ApiService { - abstract getDisks(): Promise // install.disk.list - abstract install(params: InstallReq): Promise // install.execute - abstract reboot(): Promise // install.reboot -} - -export type GetDisksRes = DiskInfo[] - -export type InstallReq = { - logicalname: string - overwrite: boolean -} diff --git a/web/projects/install-wizard/src/app/services/live-api.service.ts b/web/projects/install-wizard/src/app/services/live-api.service.ts deleted file mode 100644 index dc9b92488..000000000 --- a/web/projects/install-wizard/src/app/services/live-api.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable } from '@angular/core' -import { - HttpService, - isRpcError, - RpcError, - RPCOptions, -} from '@start9labs/shared' -import { ApiService, GetDisksRes, InstallReq } from './api.service' - -@Injectable() -export class LiveApiService implements ApiService { - constructor(private readonly http: HttpService) {} - - async getDisks(): Promise { - return this.rpcRequest({ - method: 'install.disk.list', - params: {}, - }) - } - - async install(params: InstallReq): Promise { - return this.rpcRequest({ - method: 'install.execute', - params, - }) - } - - async reboot(): Promise { - return this.rpcRequest({ - method: 'install.reboot', - params: {}, - }) - } - - private async rpcRequest(opts: RPCOptions): Promise { - const res = await this.http.rpcRequest(opts) - - const rpcRes = res.body - - if (isRpcError(rpcRes)) { - throw new RpcError(rpcRes.error) - } - - return rpcRes.result - } -} diff --git a/web/projects/install-wizard/src/app/services/mock-api.service.ts b/web/projects/install-wizard/src/app/services/mock-api.service.ts deleted file mode 100644 index 6b94f18db..000000000 --- a/web/projects/install-wizard/src/app/services/mock-api.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Injectable } from '@angular/core' -import { pauseFor } from '@start9labs/shared' -import { ApiService, GetDisksRes, InstallReq } from './api.service' - -@Injectable() -export class MockApiService implements ApiService { - async getDisks(): Promise { - await pauseFor(500) - return [ - { - logicalname: 'abcd', - vendor: 'Samsung', - model: 'T5', - partitions: [ - { - logicalname: 'pabcd', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - timestamp: new Date().toISOString(), - version: '0.2.17', - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: null, - }, - ], - capacity: 123456789123, - guid: 'uuid-uuid-uuid-uuid', - }, - { - logicalname: 'dcba', - vendor: 'Crucial', - model: 'MX500', - partitions: [ - { - logicalname: 'pbcba', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - timestamp: new Date().toISOString(), - version: '0.2.17', - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: null, - }, - ], - capacity: 124456789123, - guid: null, - }, - { - logicalname: 'wxyz', - vendor: 'SanDisk', - model: 'Specialness', - partitions: [ - { - logicalname: 'pbcba', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - timestamp: new Date().toISOString(), - version: '0.2.17', - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: 'guid-guid-guid-guid', - }, - ], - capacity: 123459789123, - guid: null, - }, - ] - } - - async install(params: InstallReq): Promise { - await pauseFor(1000) - } - - async reboot(): Promise { - await pauseFor(1000) - } -} diff --git a/web/projects/install-wizard/src/environments/environment.prod.ts b/web/projects/install-wizard/src/environments/environment.prod.ts deleted file mode 100644 index 970e25bd7..000000000 --- a/web/projects/install-wizard/src/environments/environment.prod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true, -} diff --git a/web/projects/install-wizard/src/environments/environment.ts b/web/projects/install-wizard/src/environments/environment.ts deleted file mode 100644 index 5c68c17ab..000000000 --- a/web/projects/install-wizard/src/environments/environment.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file can be replaced during build by using the `fileReplacements` array. -// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. -// The list of file replacements can be found in `angular.json`. - -export const environment = { - production: false, -} - -/* - * For easier debugging in development mode, you can import the following file - * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. - * - * This import should be commented out in production mode because it will have a negative impact - * on performance if an error is thrown. - */ -// import 'zone.js/dist/zone-error'; // Included with Angular CLI. diff --git a/web/projects/install-wizard/src/index.html b/web/projects/install-wizard/src/index.html deleted file mode 100644 index 8e7a2fd52..000000000 --- a/web/projects/install-wizard/src/index.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - StartOS Install Wizard - - - - - - - - - - - - - - - - - diff --git a/web/projects/install-wizard/src/main.ts b/web/projects/install-wizard/src/main.ts deleted file mode 100644 index 21499c3cd..000000000 --- a/web/projects/install-wizard/src/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { enableProdMode } from '@angular/core' -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' -import { AppModule } from './app/app.module' -import { environment } from './environments/environment' - -if (environment.production) { - enableProdMode() -} - -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch(err => console.error(err)) diff --git a/web/projects/install-wizard/tsconfig.json b/web/projects/install-wizard/tsconfig.json deleted file mode 100644 index c1643dea3..000000000 --- a/web/projects/install-wizard/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "baseUrl": "./" - }, - "files": ["src/main.ts"], - "include": ["src/**/*.d.ts"] -} diff --git a/web/projects/marketplace/src/pages/list/categories/categories.component.html b/web/projects/marketplace/src/pages/list/categories/categories.component.html index cdb2b2657..2bb5017cd 100644 --- a/web/projects/marketplace/src/pages/list/categories/categories.component.html +++ b/web/projects/marketplace/src/pages/list/categories/categories.component.html @@ -8,7 +8,7 @@ {{ - cat.key === 'ai' ? (cat.key | uppercase) : (cat.value.name | titlecase) + cat.key === 'ai' ? (cat.key | uppercase) : (cat.value.name | localize | titlecase) }} diff --git a/web/projects/marketplace/src/pages/list/categories/categories.module.ts b/web/projects/marketplace/src/pages/list/categories/categories.module.ts index 15a779c8a..2d67d8aca 100644 --- a/web/projects/marketplace/src/pages/list/categories/categories.module.ts +++ b/web/projects/marketplace/src/pages/list/categories/categories.module.ts @@ -2,12 +2,13 @@ import { TuiIcon, TuiAppearance } from '@taiga-ui/core' import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' import { TuiSkeleton } from '@taiga-ui/kit' +import { LocalizePipe } from '@start9labs/shared' import { CategoriesComponent } from './categories.component' import { RouterModule } from '@angular/router' @NgModule({ - imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton], + imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton, LocalizePipe], declarations: [CategoriesComponent], exports: [CategoriesComponent], }) diff --git a/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts b/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts index 60e4302d7..216dc8d7c 100644 --- a/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts +++ b/web/projects/marketplace/src/pages/show/dependencies/dependency-item.component.ts @@ -1,7 +1,7 @@ import { KeyValue } from '@angular/common' -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core' import { RouterModule } from '@angular/router' -import { ExverPipesModule, i18nPipe } from '@start9labs/shared' +import { ExverPipesModule, i18nPipe, i18nService } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit' import { MarketplacePkgBase } from '../../../types' @@ -97,6 +97,8 @@ import { MarketplacePkgBase } from '../../../types' imports: [RouterModule, TuiAvatar, ExverPipesModule, TuiLineClamp, i18nPipe], }) export class MarketplaceDepItemComponent { + private readonly i18nService = inject(i18nService) + @Input({ required: true }) pkg!: MarketplacePkgBase @@ -109,6 +111,7 @@ export class MarketplaceDepItemComponent { } getTitle(key: string): string { - return this.pkg.dependencyMetadata[key]?.title || key + const title = this.pkg.dependencyMetadata[key]?.title + return title ? this.i18nService.localize(title) : key } } diff --git a/web/projects/marketplace/src/pages/show/hero.component.ts b/web/projects/marketplace/src/pages/show/hero.component.ts index 488773d0f..8579ddaa2 100644 --- a/web/projects/marketplace/src/pages/show/hero.component.ts +++ b/web/projects/marketplace/src/pages/show/hero.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { SharedPipesModule, TickerComponent } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' @Component({ selector: 'marketplace-package-hero', @@ -125,7 +126,7 @@ export class MarketplacePackageHeroComponent { id: string title: string version: string - description: { short: string } + description: { short: T.LocaleString } icon: string } diff --git a/web/projects/marketplace/src/pages/show/release-notes.component.ts b/web/projects/marketplace/src/pages/show/release-notes.component.ts index a8d69552d..15a8a7a61 100644 --- a/web/projects/marketplace/src/pages/show/release-notes.component.ts +++ b/web/projects/marketplace/src/pages/show/release-notes.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core' -import { MarkdownPipe } from '@start9labs/shared' +import { LocalizePipe, MarkdownPipe } from '@start9labs/shared' import { NgDompurifyPipe } from '@taiga-ui/dompurify' import { MarketplacePkgBase } from '../../types' @@ -9,7 +9,7 @@ import { MarketplacePkgBase } from '../../types'

New in {{ pkg().version }}

-

+

`, @@ -21,7 +21,7 @@ import { MarketplacePkgBase } from '../../types' } `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgDompurifyPipe, MarkdownPipe], + imports: [NgDompurifyPipe, MarkdownPipe, LocalizePipe], }) export class MarketplaceReleaseNotesComponent { readonly pkg = input.required() diff --git a/web/projects/setup-wizard/src/app/app.component.ts b/web/projects/setup-wizard/src/app/app.component.ts index bb05f5154..b6bf0b3e5 100644 --- a/web/projects/setup-wizard/src/app/app.component.ts +++ b/web/projects/setup-wizard/src/app/app.component.ts @@ -1,7 +1,7 @@ import { Component, inject, DOCUMENT } from '@angular/core' import { Router } from '@angular/router' import { ErrorService } from '@start9labs/shared' -import { ApiService } from 'src/app/services/api.service' +import { ApiService } from './services/api.service' import { StateService } from './services/state.service' @Component({ @@ -18,19 +18,49 @@ export class AppComponent { async ngOnInit() { try { + // Determine if we're in kiosk mode this.stateService.kiosk = ['localhost', '127.0.0.1'].includes( this.document.location.hostname, ) - const inProgress = await this.api.getStatus() + // Get pubkey for encryption + await this.api.getPubKey() - let route = 'home' + // Check setup status to determine initial route + const status = await this.api.getStatus() - if (inProgress) { - route = inProgress.status === 'complete' ? '/success' : '/loading' + switch (status.status) { + case 'needs-install': + // Restore keyboard from status if it was previously set + if (status.keyboard) { + this.stateService.keyboard = status.keyboard.layout + } + // Start the install flow + await this.router.navigate(['/language']) + break + + case 'incomplete': + // Store the data drive info from status + this.stateService.dataDriveGuid = status.guid + this.stateService.attach = status.attach + // Restore keyboard from status if it was previously set + if (status.keyboard) { + this.stateService.keyboard = status.keyboard.layout + } + + await this.router.navigate(['/language']) + break + + case 'running': + // Setup is in progress, show loading page + await this.router.navigate(['/loading']) + break + + case 'complete': + // Setup execution finished, show success page + await this.router.navigate(['/success']) + break } - - await this.router.navigate([route]) } catch (e: any) { this.errorService.handleError(e) } diff --git a/web/projects/setup-wizard/src/app/app.module.ts b/web/projects/setup-wizard/src/app/app.module.ts index c94c21700..c6a815065 100644 --- a/web/projects/setup-wizard/src/app/app.module.ts +++ b/web/projects/setup-wizard/src/app/app.module.ts @@ -9,6 +9,7 @@ import { PreloadAllModules, RouterModule } from '@angular/router' import { WA_LOCATION } from '@ng-web-apis/common' import initArgon from '@start9labs/argon2' import { + I18N_PROVIDERS, provideSetupLogsService, RELATIVE_URL, VERSION, @@ -16,9 +17,9 @@ import { } from '@start9labs/shared' import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core' import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins' -import { ApiService } from 'src/app/services/api.service' -import { LiveApiService } from 'src/app/services/live-api.service' -import { MockApiService } from 'src/app/services/mock-api.service' +import { ApiService } from './services/api.service' +import { LiveApiService } from './services/live-api.service' +import { MockApiService } from './services/mock-api.service' import { AppComponent } from './app.component' import { ROUTES } from './app.routes' @@ -41,6 +42,7 @@ const version = require('../../../../package.json').version ], providers: [ NG_EVENT_PLUGINS, + I18N_PROVIDERS, provideSetupLogsService(ApiService), tuiButtonOptionsProvider({ size: 'm' }), { diff --git a/web/projects/setup-wizard/src/app/app.routes.ts b/web/projects/setup-wizard/src/app/app.routes.ts index 388ebc740..0219eadb3 100644 --- a/web/projects/setup-wizard/src/app/app.routes.ts +++ b/web/projects/setup-wizard/src/app/app.routes.ts @@ -1,33 +1,48 @@ import { Routes } from '@angular/router' export const ROUTES: Routes = [ - { path: '', redirectTo: '/home', pathMatch: 'full' }, + // Entry point - app.component handles initial routing based on setup.status + { path: '', redirectTo: '/language', pathMatch: 'full' }, + + // Install flow + { + path: 'language', + loadComponent: () => import('./pages/language.page'), + }, + { + path: 'keyboard', + loadComponent: () => import('./pages/keyboard.page'), + }, + { + path: 'drives', + loadComponent: () => import('./pages/drives.page'), + }, + + // Setup flow (after install or for pre-installed devices) { path: 'home', - loadComponent: () => import('src/app/pages/home.page'), + loadComponent: () => import('./pages/home.page'), }, { - path: 'attach', - loadComponent: () => import('src/app/pages/attach.page'), - }, - { - path: 'recover', - loadComponent: () => import('src/app/pages/recover.page'), + path: 'restore', + loadComponent: () => import('./pages/restore.page'), }, { path: 'transfer', - loadComponent: () => import('src/app/pages/transfer.page'), + loadComponent: () => import('./pages/transfer.page'), }, { - path: 'storage', - loadComponent: () => import('src/app/pages/storage.page'), + path: 'password', + loadComponent: () => import('./pages/password.page'), }, + + // Shared { path: 'loading', - loadComponent: () => import('src/app/pages/loading.page'), + loadComponent: () => import('./pages/loading.page'), }, { path: 'success', - loadComponent: () => import('src/app/pages/success.page'), + loadComponent: () => import('./pages/success.page'), }, ] diff --git a/web/projects/setup-wizard/src/app/components/cifs.component.ts b/web/projects/setup-wizard/src/app/components/cifs.component.ts index e59d6052a..ccd97ea49 100644 --- a/web/projects/setup-wizard/src/app/components/cifs.component.ts +++ b/web/projects/setup-wizard/src/app/components/cifs.component.ts @@ -3,39 +3,38 @@ import { Component, inject } from '@angular/core' import { FormControl, FormGroup, - FormsModule, ReactiveFormsModule, Validators, } from '@angular/forms' -import { i18nKey, LoadingService, StartOSDiskInfo } from '@start9labs/shared' +import { DialogService, i18nPipe } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import { TuiButton, TuiDialogContext, - TuiDialogService, TuiError, + TuiIcon, TuiTextfield, } from '@taiga-ui/core' import { TUI_VALIDATION_ERRORS, + TuiButtonLoading, TuiFieldErrorPipe, TuiPassword, } from '@taiga-ui/kit' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { SERVERS, ServersResponse } from 'src/app/components/servers.component' -import { ApiService } from 'src/app/services/api.service' +import { ApiService } from '../services/api.service' +import { StartOSDiskInfoWithId } from '../types' -export interface CifsResponse { +export interface CifsResult { cifs: T.Cifs - serverId: string - password: string + servers: StartOSDiskInfoWithId[] } @Component({ template: `
- + - + - + - + @@ -81,11 +80,14 @@ export interface CifsResponse { tuiButton appearance="secondary" type="button" + [disabled]="connecting" (click)="cancel()" > - Cancel + {{ 'Cancel' | i18n }} + + - `, @@ -97,18 +99,20 @@ export interface CifsResponse { footer { display: flex; gap: 1rem; - margin-top: 1rem; + margin-top: 1.5rem; } `, imports: [ CommonModule, - FormsModule, ReactiveFormsModule, TuiButton, + TuiButtonLoading, TuiTextfield, TuiPassword, TuiError, TuiFieldErrorPipe, + TuiIcon, + i18nPipe, ], providers: [ { @@ -120,10 +124,11 @@ export interface CifsResponse { ], }) export class CifsComponent { - private readonly dialogs = inject(TuiDialogService) + private readonly dialogs = inject(DialogService) private readonly api = inject(ApiService) - private readonly loader = inject(LoadingService) - private readonly context = injectContext>() + private readonly context = injectContext>() + + connecting = false readonly form = new FormGroup({ hostname: new FormControl('', { @@ -149,9 +154,7 @@ export class CifsComponent { } async submit(): Promise { - const loader = this.loader - .open('Connecting to shared folder' as i18nKey) - .subscribe() + this.connecting = true try { const diskInfo = await this.api.verifyCifs({ @@ -161,36 +164,25 @@ export class CifsComponent { : null, }) - loader.unsubscribe() + const servers = Object.keys(diskInfo).map(id => ({ + id, + ...diskInfo[id]!, + })) - this.selectServer(diskInfo) + this.context.completeWith({ + cifs: { ...this.form.getRawValue() }, + servers, + }) } catch (e) { - loader.unsubscribe() + this.connecting = false this.onFail() } } - private selectServer(servers: Record) { - this.dialogs - .open(SERVERS, { - label: 'Select Server to Restore', - data: { - servers: Object.keys(servers).map(id => ({ id, ...servers[id] })), - }, - }) - .subscribe(({ password, serverId }) => { - this.context.completeWith({ - cifs: { ...this.form.getRawValue() }, - serverId, - password, - }) - }) - } - private onFail() { this.dialogs - .open( - 'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.', + .openAlert( + 'Unable to connect to network folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.', { label: 'Connection Failed', size: 's', diff --git a/web/projects/setup-wizard/src/app/components/password.component.ts b/web/projects/setup-wizard/src/app/components/password.component.ts deleted file mode 100644 index f31f8e2d7..000000000 --- a/web/projects/setup-wizard/src/app/components/password.component.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { AsyncPipe } from '@angular/common' -import { Component, inject } from '@angular/core' -import { - AbstractControl, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms' -import { verify } from '@start9labs/argon2' -import { ErrorService } from '@start9labs/shared' -import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk' -import { - TuiButton, - TuiDialogContext, - TuiError, - TuiIcon, - TuiTextfield, -} from '@taiga-ui/core' -import { - TuiFieldErrorPipe, - TuiPassword, - tuiValidationErrorsProvider, -} from '@taiga-ui/kit' -import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' - -interface DialogData { - passwordHash?: string - storageDrive?: boolean -} - -@Component({ - template: ` - @if (storageDrive) { - Choose a password for your server. - Make it good. Write it down. - } @else { - Enter the password that was used to encrypt this drive. - } - -
- - - - - - - @if (storageDrive) { - - - - - - - } -
- - -
- - `, - styles: ` - footer { - display: flex; - gap: 1rem; - margin-top: 1rem; - justify-content: flex-end; - } - `, - imports: [ - AsyncPipe, - ReactiveFormsModule, - TuiButton, - TuiError, - TuiAutoFocus, - TuiFieldErrorPipe, - TuiTextfield, - TuiPassword, - TuiValidator, - TuiIcon, - TuiMapperPipe, - ], - providers: [ - tuiValidationErrorsProvider({ - required: 'Required', - minlength: 'Must be 12 characters or greater', - }), - ], -}) -export class PasswordComponent { - private readonly errorService = inject(ErrorService) - private readonly context = - injectContext>() - - readonly storageDrive = this.context.data.storageDrive - readonly form = new FormGroup({ - password: new FormControl('', [ - Validators.required, - Validators.minLength(12), - ]), - confirm: new FormControl('', this.storageDrive ? Validators.required : []), - }) - - readonly validator = (value: any) => (control: AbstractControl) => - value === control.value ? null : { match: 'Passwords do not match' } - - submit() { - const password = this.form.controls.password.value || '' - - if (this.storageDrive) { - this.context.completeWith(password) - - return - } - - try { - verify(this.context.data.passwordHash || '', password) - this.context.completeWith(password) - } catch (e) { - this.errorService.handleError('Incorrect password provided') - } - } - - cancel() { - this.context.$implicit.complete() - } -} - -export const PASSWORD = new PolymorpheusComponent(PasswordComponent) diff --git a/web/projects/setup-wizard/src/app/components/password.directive.ts b/web/projects/setup-wizard/src/app/components/password.directive.ts deleted file mode 100644 index 979a8b99e..000000000 --- a/web/projects/setup-wizard/src/app/components/password.directive.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Directive, ElementRef, inject, input, Output } from '@angular/core' -import { StartOSDiskInfo } from '@start9labs/shared' -import { TuiDialogService } from '@taiga-ui/core' -import { filter, fromEvent, switchMap } from 'rxjs' -import { PASSWORD } from 'src/app/components/password.component' - -@Directive({ - selector: 'button[server][password]', -}) -export class PasswordDirective { - private readonly dialogs = inject(TuiDialogService) - - readonly server = input.required() - - @Output() - readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe( - switchMap(() => - this.dialogs.open(PASSWORD, { - label: 'Unlock Drive', - size: 's', - data: { passwordHash: this.server().passwordHash }, - }), - ), - filter(Boolean), - ) -} diff --git a/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts b/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts new file mode 100644 index 000000000..2b5ed04cb --- /dev/null +++ b/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts @@ -0,0 +1,58 @@ +import { Component, inject } from '@angular/core' +import { i18nPipe } from '@start9labs/shared' +import { TuiButton } from '@taiga-ui/core' +import { TuiDialogContext } from '@taiga-ui/core' +import { injectContext } from '@taiga-ui/polymorpheus' + +@Component({ + standalone: true, + imports: [TuiButton, i18nPipe], + template: ` +

{{ 'This drive contains existing StartOS data.' | i18n }}

+
    +
  • + {{ 'Preserve' | i18n }} + {{ 'to keep your data.' | i18n }} +
  • +
  • + {{ 'Overwrite' | i18n }} + {{ 'to discard' | i18n }} +
  • +
+
+ + +
+ `, + styles: ` + p { + margin: 0 0 0.75rem; + } + + footer { + display: flex; + margin-top: 2rem; + gap: 0.5rem; + flex-direction: column-reverse; + } + + .preserve-btn { + background: var(--tui-status-positive) !important; + } + `, +}) +export class PreserveOverwriteDialog { + protected readonly context = injectContext>() +} diff --git a/web/projects/setup-wizard/src/app/components/recover.component.ts b/web/projects/setup-wizard/src/app/components/recover.component.ts deleted file mode 100644 index cf53b9c97..000000000 --- a/web/projects/setup-wizard/src/app/components/recover.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { TuiCell } from '@taiga-ui/layout' -import { TuiIcon, TuiTitle } from '@taiga-ui/core' -import { Component, Input } from '@angular/core' -import { RouterModule } from '@angular/router' - -@Component({ - selector: 'app-recover', - template: ` -
- - - Use Existing Drive - - Attach an existing StartOS data drive (not a backup) - - - - - - - Transfer - - Transfer data from an existing StartOS data drive (not a backup) to a - new, preferred drive - - - - - - - Restore From Backup (Disaster Recovery) - Restore StartOS data from an encrypted backup - - - `, - imports: [RouterModule, TuiIcon, TuiCell, TuiTitle], -}) -export class RecoverComponent { - @Input() disabled = false -} diff --git a/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts b/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts new file mode 100644 index 000000000..d9c9c1166 --- /dev/null +++ b/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts @@ -0,0 +1,72 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { i18nPipe } from '@start9labs/shared' +import { TuiDialogContext, TuiTextfield } from '@taiga-ui/core' +import { TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { StartOSDiskInfoWithId } from '../types' + +interface Data { + servers: StartOSDiskInfoWithId[] +} + +@Component({ + standalone: true, + imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper, i18nPipe], + template: ` +

{{ 'Multiple backups found. Select which one to restore.' | i18n }}

+ + + + + + + +
+ {{ server.id }} + + {{ server['eos-version'] }} +
+
+ `, + styles: ` + .server-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + `, +}) +export class SelectNetworkBackupDialog { + protected readonly context = + injectContext>() + + private _selectedServer: StartOSDiskInfoWithId | null = null + + get selectedServer(): StartOSDiskInfoWithId | null { + return this._selectedServer + } + + set selectedServer(value: StartOSDiskInfoWithId | null) { + this._selectedServer = value + + if (value) { + this.context.completeWith(value) + } + } + + readonly stringify = (server: StartOSDiskInfoWithId | null) => + server ? server.id : '' +} + +export const SELECT_NETWORK_BACKUP = new PolymorpheusComponent( + SelectNetworkBackupDialog, +) diff --git a/web/projects/setup-wizard/src/app/components/servers.component.ts b/web/projects/setup-wizard/src/app/components/servers.component.ts deleted file mode 100644 index 1dc37f5fa..000000000 --- a/web/projects/setup-wizard/src/app/components/servers.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component } from '@angular/core' -import { ServerComponent } from '@start9labs/shared' -import { TuiDialogContext } from '@taiga-ui/core' -import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { PasswordDirective } from 'src/app/components/password.directive' -import { StartOSDiskInfoWithId } from 'src/app/services/api.service' - -interface Data { - servers: StartOSDiskInfoWithId[] -} - -export interface ServersResponse { - password: string - serverId: string -} - -@Component({ - template: ` - @for (server of context.data.servers; track $index) { - - } - `, - imports: [ServerComponent, PasswordDirective], -}) -export class ServersComponent { - readonly context = injectContext>() - - select(password: string, serverId: string) { - this.context.completeWith({ serverId, password }) - } -} - -export const SERVERS = new PolymorpheusComponent(ServersComponent) diff --git a/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts b/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts new file mode 100644 index 000000000..88191bff6 --- /dev/null +++ b/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts @@ -0,0 +1,65 @@ +import { Component } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { i18nPipe } from '@start9labs/shared' +import { + TuiButton, + TuiDialogContext, + TuiIcon, + TuiTextfield, +} from '@taiga-ui/core' +import { TuiPassword } from '@taiga-ui/kit' +import { injectContext } from '@taiga-ui/polymorpheus' + +@Component({ + standalone: true, + imports: [ + FormsModule, + TuiButton, + TuiTextfield, + TuiPassword, + TuiIcon, + i18nPipe, + ], + template: ` +

+ {{ 'Enter the password that was used to encrypt this backup.' | i18n }} +

+ + + + + +
+ + +
+ `, + styles: ` + footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.5rem; + } + `, +}) +export class UnlockPasswordDialog { + protected readonly context = injectContext>() + + password = '' + + unlock() { + if (this.password) { + this.context.completeWith(this.password) + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/attach.page.ts b/web/projects/setup-wizard/src/app/pages/attach.page.ts deleted file mode 100644 index c906e8347..000000000 --- a/web/projects/setup-wizard/src/app/pages/attach.page.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Component, inject } from '@angular/core' -import { Router } from '@angular/router' -import { - DiskInfo, - DriveComponent, - ErrorService, - i18nKey, - LoadingService, - toGuid, -} from '@start9labs/shared' -import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { PASSWORD } from 'src/app/components/password.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' - -@Component({ - template: ` -
-
Use existing drive
-
Select the physical drive containing your StartOS data
- - @if (loading) { - - } @else { - @for (drive of drives; track drive) { - - } @empty { - No valid StartOS data drives found. Please make sure the drive is a - valid StartOS data drive (not a backup) and is firmly connected, then - refresh the page. - } -
- -
- } -
- `, - imports: [TuiButton, TuiCardLarge, TuiCell, TuiLoader, DriveComponent], -}) -export default class AttachPage { - private readonly apiService = inject(ApiService) - private readonly router = inject(Router) - private readonly errorService = inject(ErrorService) - private readonly stateService = inject(StateService) - private readonly dialogs = inject(TuiDialogService) - private readonly loader = inject(LoadingService) - - loading = true - drives: DiskInfo[] = [] - - async ngOnInit() { - this.stateService.setupType = 'attach' - await this.getDrives() - } - - async refresh() { - this.loading = true - await this.getDrives() - } - - async getDrives() { - try { - this.drives = await this.apiService - .getDrives() - .then(drives => drives.filter(toGuid)) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - this.loading = false - } - } - - select(disk: DiskInfo) { - this.dialogs - .open(PASSWORD, { - label: 'Set Password', - size: 's', - data: { storageDrive: true }, - }) - .subscribe(password => { - this.attachDrive(toGuid(disk) || '', password) - }) - } - - private async attachDrive(guid: string, password: string) { - const loader = this.loader - .open('Connecting to drive' as i18nKey) - .subscribe() - - try { - await this.stateService.importDrive(guid, password) - await this.router.navigate([`loading`]) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/setup-wizard/src/app/pages/drives.page.ts b/web/projects/setup-wizard/src/app/pages/drives.page.ts new file mode 100644 index 000000000..f3c7ded42 --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/drives.page.ts @@ -0,0 +1,395 @@ +import { ChangeDetectorRef, Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { FormsModule } from '@angular/forms' +import { + DialogService, + DiskInfo, + ErrorService, + i18nKey, + i18nPipe, + LoadingService, + toGuid, +} from '@start9labs/shared' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { + TuiButton, + TuiIcon, + TuiLoader, + TuiTextfield, + TuiTitle, +} from '@taiga-ui/core' +import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { filter } from 'rxjs' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' +import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog' + +@Component({ + template: ` +
+
+

{{ 'Select Drives' | i18n }}

+
+ + @if (loading) { + + } @else if (drives.length === 0) { +

+ {{ + 'No drives found. Please connect a drive and click Refresh.' | i18n + }} +

+ } @else { + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + @if (preserveData === true) { + + } + @if (preserveData === false) { + + } + + + + +
+ + {{ drive.vendor || ('Unknown' | i18n) }} + {{ drive.model || ('Drive' | i18n) }} + + + {{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }} + +
+
+ } + +
+ @if (drives.length === 0) { + + } @else { + + } +
+
+ `, + styles: ` + .no-drives { + text-align: center; + color: var(--tui-text-secondary); + padding: 2rem; + } + + .drive-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + `, + imports: [ + FormsModule, + TuiCardLarge, + TuiButton, + TuiIcon, + TuiLoader, + TuiTextfield, + TuiSelect, + TuiDataListWrapper, + TuiTooltip, + TuiHeader, + TuiTitle, + i18nPipe, + ], +}) +export default class DrivesPage { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + private readonly dialogs = inject(DialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly stateService = inject(StateService) + private readonly cdr = inject(ChangeDetectorRef) + private readonly i18n = inject(i18nPipe) + + protected readonly mobile = inject(TUI_IS_MOBILE) + + readonly osDriveTooltip = this.i18n.transform( + 'The drive where the StartOS operating system will be installed.', + ) + readonly dataDriveTooltip = this.i18n.transform( + 'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.', + ) + + drives: DiskInfo[] = [] + loading = true + selectedOsDrive: DiskInfo | null = null + selectedDataDrive: DiskInfo | null = null + preserveData: boolean | null = null + + readonly stringify = (drive: DiskInfo | null) => + drive + ? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}` + : '' + + formatCapacity(bytes: number): string { + const gb = bytes / 1e9 + if (gb >= 1000) { + return `${(gb / 1000).toFixed(1)} TB` + } + return `${gb.toFixed(0)} GB` + } + + async ngOnInit() { + await this.loadDrives() + } + + async refresh() { + this.loading = true + this.selectedOsDrive = null + this.selectedDataDrive = null + this.preserveData = null + await this.loadDrives() + } + + onDataDriveChange(drive: DiskInfo | null) { + this.preserveData = null + + if (!drive) { + return + } + + const hasStartOSData = !!toGuid(drive) + if (hasStartOSData) { + this.showPreserveOverwriteDialog() + } + } + + continue() { + if (!this.selectedOsDrive || !this.selectedDataDrive) return + + const sameDevice = + this.selectedOsDrive.logicalname === this.selectedDataDrive.logicalname + const dataHasStartOS = !!toGuid(this.selectedDataDrive) + + // Scenario 1: Same drive, has StartOS data, preserving → no warning + if (sameDevice && dataHasStartOS && this.preserveData) { + this.installOs(false) + return + } + + // Scenario 2: Different drives, preserving data → warn OS only + if (!sameDevice && this.preserveData) { + this.showOsDriveWarning() + return + } + + // Scenario 3: All other cases → warn about overwriting + this.showFullWarning(sameDevice) + } + + private showPreserveOverwriteDialog() { + let selectionMade = false + + this.dialogs + .openComponent( + new PolymorpheusComponent(PreserveOverwriteDialog), + { + label: 'StartOS Data Detected', + size: 's', + dismissible: true, + closeable: true, + }, + ) + .subscribe({ + next: preserve => { + selectionMade = true + this.preserveData = preserve + this.cdr.markForCheck() + }, + complete: () => { + if (!selectionMade) { + // Dialog was dismissed without selection - clear the data drive + this.selectedDataDrive = null + this.preserveData = null + this.cdr.markForCheck() + } + }, + }) + } + + private showOsDriveWarning() { + this.dialogs + .openConfirm({ + label: 'Warning', + size: 's', + data: { + content: `
    +
  • ${this.i18n.transform('Data on the OS drive may be overwritten.')}
  • +
  • ${this.i18n.transform('your StartOS data on the data drive will be preserved.')}
  • +
` as i18nKey, + yes: 'Continue', + no: 'Cancel', + }, + }) + .pipe(filter(Boolean)) + .subscribe(() => { + this.installOs(false) + }) + } + + private showFullWarning(sameDevice: boolean) { + const message = sameDevice + ? `

${this.i18n.transform('Data on this drive will be overwritten.')}

` + : `

${this.i18n.transform('Data on both drives will be overwritten.')}

` + + this.dialogs + .openConfirm({ + label: 'Warning', + size: 's', + data: { + content: message as i18nKey, + yes: 'Continue', + no: 'Cancel', + }, + }) + .pipe(filter(Boolean)) + .subscribe(() => { + this.installOs(true) + }) + } + + private async installOs(wipe: boolean) { + const loader = this.loader.open('Installing StartOS').subscribe() + + try { + const result = await this.api.installOs({ + osDrive: this.selectedOsDrive!.logicalname, + dataDrive: { + logicalname: this.selectedDataDrive!.logicalname, + wipe, + }, + }) + + this.stateService.dataDriveGuid = result.guid + this.stateService.attach = result.attach + + loader.unsubscribe() + + // Show success dialog + this.dialogs + .openConfirm({ + label: 'Installation Complete!', + size: 's', + data: { + content: 'StartOS has been installed successfully.', + yes: 'Continue to Setup', + no: 'Shutdown', + }, + }) + .subscribe(continueSetup => { + if (continueSetup) { + this.navigateToNextStep(result.attach) + } else { + this.shutdownServer() + } + }) + } catch (e: any) { + loader.unsubscribe() + this.errorService.handleError(e) + } + } + + private async navigateToNextStep(attach: boolean) { + if (attach) { + this.stateService.setupType = 'attach' + await this.router.navigate(['/password']) + } else { + await this.router.navigate(['/home']) + } + } + + private async shutdownServer() { + const loader = this.loader.open('Beginning shutdown').subscribe() + + try { + await this.api.shutdown() + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } + + private async loadDrives() { + try { + this.drives = await this.api.getDisks() + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading = false + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/home.page.ts b/web/projects/setup-wizard/src/app/pages/home.page.ts index 3312402c2..43cc1ed21 100644 --- a/web/projects/setup-wizard/src/app/pages/home.page.ts +++ b/web/projects/setup-wizard/src/app/pages/home.page.ts @@ -1,134 +1,74 @@ -import { Component, inject, OnInit } from '@angular/core' -import { RouterModule } from '@angular/router' -import { ErrorService } from '@start9labs/shared' -import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { RecoverComponent } from 'src/app/components/recover.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { i18nPipe } from '@start9labs/shared' +import { TuiAppearance, TuiTitle } from '@taiga-ui/core' +import { TuiAvatar } from '@taiga-ui/kit' +import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout' +import { StateService } from '../services/state.service' @Component({ template: ` - - @if (!loading) { -
-
- @if (recover) { - - } - {{ recover ? 'Recover Options' : 'StartOS Setup' }} -
-
-
- - -
- +
+
+

{{ 'Select Setup Flow' | i18n }}

+
+ +
- } - `, - styles: ` - @use '@taiga-ui/core/styles/taiga-ui-local' as taiga; + - .logo { - width: 6rem; - margin: auto auto -2rem; - z-index: 1; + - &:only-child { - margin: auto; - } - - + * { - margin-top: 0; - } - } - - .back { - position: absolute; - top: 1rem; - border-radius: 10rem; - } - - .pages { - display: flex; - align-items: center; - overflow: hidden; - } - - .options { - @include taiga.transition(margin); - - min-width: 100%; - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1rem; - box-sizing: border-box; - - &_recover { - margin-left: -100%; - } - } + + `, imports: [ - RouterModule, + TuiAppearance, TuiCardLarge, - TuiButton, + TuiHeader, TuiCell, - TuiIcon, TuiTitle, - RecoverComponent, + TuiAvatar, + i18nPipe, ], }) -export default class HomePage implements OnInit { - private readonly api = inject(ApiService) - private readonly errorService = inject(ErrorService) +export default class HomePage { + private readonly router = inject(Router) private readonly stateService = inject(StateService) - error = false - loading = true - recover = false - - async ngOnInit() { + async startFresh() { this.stateService.setupType = 'fresh' + this.stateService.recoverySource = undefined + await this.router.navigate(['/password']) + } - try { - await this.api.getPubKey() - } catch (e: any) { - this.error = true - this.errorService.handleError(e) - } finally { - this.loading = false - } + async restore() { + this.stateService.setupType = 'restore' + await this.router.navigate(['/restore']) + } + + async transfer() { + this.stateService.setupType = 'transfer' + await this.router.navigate(['/transfer']) } } diff --git a/web/projects/setup-wizard/src/app/pages/keyboard.page.ts b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts new file mode 100644 index 000000000..fbd4e5c11 --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts @@ -0,0 +1,124 @@ +import { Component, inject, signal } from '@angular/core' +import { Router } from '@angular/router' +import { FormsModule } from '@angular/forms' +import { + getAllKeyboardsSorted, + i18nPipe, + Keyboard, + LanguageCode, +} from '@start9labs/shared' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core' +import { + TuiButtonLoading, + TuiChevron, + TuiDataListWrapper, + TuiSelect, +} from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' + +@Component({ + template: ` +
+
+

{{ 'Select Keyboard Layout' | i18n }}

+
+ + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + +
+ +
+
+ `, + imports: [ + FormsModule, + TuiCardLarge, + TuiButton, + TuiButtonLoading, + TuiTextfield, + TuiChevron, + TuiSelect, + TuiDataListWrapper, + TuiHeader, + TuiTitle, + i18nPipe, + ], +}) +export default class KeyboardPage { + private readonly router = inject(Router) + private readonly api = inject(ApiService) + private readonly stateService = inject(StateService) + + protected readonly mobile = inject(TUI_IS_MOBILE) + // All keyboards, with language-specific keyboards at the top + readonly keyboards = getAllKeyboardsSorted( + this.stateService.language as LanguageCode, + ) + selected = + this.keyboards.find(k => k.layout === this.stateService.keyboard) || + this.keyboards[0]! + + readonly saving = signal(false) + + readonly stringify = (kb: Keyboard) => kb.name + + async continue() { + this.saving.set(true) + + try { + // Send keyboard to backend + await this.api.setKeyboard({ + layout: this.selected.layout, + keymap: this.selected.keymap, + model: null, + variant: null, + options: [], + }) + + this.stateService.keyboard = this.selected.layout + await this.navigateToNextStep() + } finally { + this.saving.set(false) + } + } + + private async navigateToNextStep() { + if (this.stateService.dataDriveGuid) { + if (this.stateService.attach) { + this.stateService.setupType = 'attach' + await this.router.navigate(['/password']) + } else { + await this.router.navigate(['/home']) + } + } else { + await this.router.navigate(['/drives']) + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/language.page.ts b/web/projects/setup-wizard/src/app/pages/language.page.ts new file mode 100644 index 000000000..f30953d1e --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/language.page.ts @@ -0,0 +1,151 @@ +import { Component, computed, inject, signal } from '@angular/core' +import { Router } from '@angular/router' +import { FormsModule } from '@angular/forms' +import { i18nPipe, i18nService, Language, LANGUAGES } from '@start9labs/shared' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core' +import { + TuiButtonLoading, + TuiChevron, + TuiDataListWrapper, + TuiSelect, +} from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' + +@Component({ + template: ` +
+
+

+ + Start9 + {{ 'Welcome to' | i18n }} StartOS + + {{ 'Select your language' | i18n }} +

+
+ + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + @let lang = asLanguage(item); +
+ {{ lang.nativeName }} + {{ lang.name | i18n }} +
+
+ +
+ +
+
+ `, + styles: ` + .language-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + `, + imports: [ + FormsModule, + TuiCardLarge, + TuiButton, + TuiButtonLoading, + TuiTextfield, + TuiChevron, + TuiSelect, + TuiDataListWrapper, + TuiHeader, + TuiTitle, + i18nPipe, + ], +}) +export default class LanguagePage { + private readonly router = inject(Router) + private readonly api = inject(ApiService) + private readonly stateService = inject(StateService) + private readonly i18nService = inject(i18nService) + + protected readonly mobile = inject(TUI_IS_MOBILE) + readonly languages = LANGUAGES + + selected = + LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0] + + private readonly saving = signal(false) + + // Show loading when either language is loading or saving is in progress + readonly loading = computed(() => this.i18nService.loading() || this.saving()) + + readonly stringify = (lang: Language) => lang.nativeName + readonly asLanguage = (item: unknown): Language => item as Language + + constructor() { + if (this.selected) { + this.i18nService.setLang(this.selected.name) + } + } + + onLanguageChange(language: Language) { + if (language) { + this.i18nService.setLang(language.name) + } + } + + async continue() { + if (this.selected) { + this.stateService.language = this.selected.code + + // Save language to backend + this.saving.set(true) + + try { + await this.api.setLanguage({ language: this.selected.name }) + // Always go to keyboard selection + await this.router.navigate(['/keyboard']) + } finally { + this.saving.set(false) + } + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/loading.page.ts b/web/projects/setup-wizard/src/app/pages/loading.page.ts index 20d532b96..a00355776 100644 --- a/web/projects/setup-wizard/src/app/pages/loading.page.ts +++ b/web/projects/setup-wizard/src/app/pages/loading.page.ts @@ -10,7 +10,7 @@ import { DialogService, formatProgress, getErrorMessage, - i18nKey, + i18nPipe, InitializingComponent, LoadingService, } from '@start9labs/shared' @@ -26,17 +26,17 @@ import { tap, timer, } from 'rxjs' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' @Component({ template: ` @if (error(); as err) {
-

{{ 'Error initializing server' }}

+

{{ 'Error initializing server' | i18n }}

{{ err }}

} @else { @@ -54,22 +54,21 @@ import { StateService } from 'src/app/services/state.service' padding: 1rem; margin: 1.5rem; text-align: center; - // @TODO Theme background: #e0e0e0; color: #333; --tui-background-neutral-1: rgba(0, 0, 0, 0.1); } `, - imports: [InitializingComponent, TuiButton], + imports: [InitializingComponent, TuiButton, i18nPipe], changeDetection: ChangeDetectionStrategy.OnPush, }) export default class LoadingPage { private readonly api = inject(ApiService) private readonly loader = inject(LoadingService) private readonly dialog = inject(DialogService) + private readonly router = inject(Router) readonly type = inject(StateService).setupType - readonly router = inject(Router) readonly progress = toSignal( from(this.getStatus()).pipe( filter(Boolean), @@ -99,12 +98,13 @@ export default class LoadingPage { try { const res = await this.api.getStatus() - if (!res) { - this.router.navigate(['home']) - } else if (res.status === 'complete') { - this.router.navigate(['success']) - } else { + if (res.status === 'running') { return res + } else if (res.status === 'complete') { + this.router.navigate(['/success']) + } else { + // incomplete or needs-install - shouldn't happen on loading page + this.router.navigate(['/language']) } } catch (e: any) { this.error.set(getErrorMessage(e)) @@ -119,7 +119,7 @@ export default class LoadingPage { try { await this.api.restart() this.dialog - .openAlert('Wait 1-2 minutes and refresh the page' as i18nKey, { + .openAlert('Wait 1-2 minutes and refresh the page', { label: 'Server is restarting', }) .subscribe() diff --git a/web/projects/setup-wizard/src/app/pages/password.page.ts b/web/projects/setup-wizard/src/app/pages/password.page.ts new file mode 100644 index 000000000..adef97f3a --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/password.page.ts @@ -0,0 +1,196 @@ +import { AsyncPipe } from '@angular/common' +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { + AbstractControl, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms' +import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared' +import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk' +import { + TuiButton, + TuiError, + TuiIcon, + TuiTextfield, + TuiTitle, +} from '@taiga-ui/core' +import { + TuiFieldErrorPipe, + TuiPassword, + tuiValidationErrorsProvider, +} from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { StateService } from '../services/state.service' + +@Component({ + template: ` +
+
+

+ {{ + isRequired + ? ('Set Master Password' | i18n) + : ('Set New Password (Optional)' | i18n) + }} + + {{ + isRequired + ? ('Make it good. Write it down.' | i18n) + : ('Skip to keep your existing password.' | i18n) + }} + +

+
+ +
+ + + + + + + + + + + + + + +
+ + @if (!isRequired) { + + } +
+ +
+ `, + styles: ` + footer { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.5rem; + } + `, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiCardLarge, + TuiButton, + TuiError, + TuiAutoFocus, + TuiFieldErrorPipe, + TuiTextfield, + TuiPassword, + TuiValidator, + TuiIcon, + TuiMapperPipe, + TuiHeader, + TuiTitle, + i18nPipe, + ], + providers: [ + tuiValidationErrorsProvider({ + required: 'Required', + minlength: 'Must be 12 characters or greater', + maxlength: 'Must be 64 character or less', + match: 'Passwords do not match', + }), + ], +}) +export default class PasswordPage { + private readonly router = inject(Router) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly stateService = inject(StateService) + private readonly i18n = inject(i18nPipe) + + // Password is required only for fresh install + readonly isRequired = this.stateService.setupType === 'fresh' + + readonly form = new FormGroup({ + password: new FormControl('', [ + ...(this.isRequired ? [Validators.required] : []), + Validators.minLength(12), + Validators.maxLength(64), + ]), + confirm: new FormControl(''), + }) + + readonly validator = (value: string) => (control: AbstractControl) => + value === control.value + ? null + : { match: this.i18n.transform('Passwords do not match') } + + async skip() { + // Skip means no new password - pass null + await this.executeSetup(null) + } + + async submit() { + await this.executeSetup(this.form.controls.password.value) + } + + private async executeSetup(password: string | null) { + const loader = this.loader.open('Starting setup').subscribe() + + try { + if (this.stateService.setupType === 'attach') { + await this.stateService.attachDrive(password) + } else { + // fresh, restore, or transfer - all use execute + await this.stateService.executeSetup(password) + } + + await this.router.navigate(['/loading']) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/recover.page.ts b/web/projects/setup-wizard/src/app/pages/recover.page.ts deleted file mode 100644 index b5316f118..000000000 --- a/web/projects/setup-wizard/src/app/pages/recover.page.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Component, inject } from '@angular/core' -import { Router } from '@angular/router' -import { ErrorService, ServerComponent } from '@start9labs/shared' -import { - TuiButton, - TuiDialogService, - TuiIcon, - TuiLoader, - TuiTitle, -} from '@taiga-ui/core' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { CIFS, CifsResponse } from 'src/app/components/cifs.component' -import { PasswordDirective } from 'src/app/components/password.directive' -import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' - -@Component({ - template: ` -
-
Restore from Backup
- @if (loading) { - - } @else { -

Network Folder

- Restore StartOS data from a folder on another computer that is connected - to the same network as your server. - - - -

Physical Drive

-
- Restore StartOS data from a physical drive that is plugged directly - into your server. -
- - Warning. Do not use this option if you are using a Raspberry Pi with - an external SSD as your main data drive. The Raspberry Pi cannot not - support more than one external drive without additional power and can - cause data corruption. - - - @for (server of servers; track $index) { - - } -
- -
- } -
- `, - imports: [ - TuiCardLarge, - TuiLoader, - TuiButton, - TuiCell, - TuiIcon, - TuiTitle, - ServerComponent, - PasswordDirective, - ], -}) -export default class RecoverPage { - private readonly api = inject(ApiService) - private readonly router = inject(Router) - private readonly dialogs = inject(TuiDialogService) - private readonly errorService = inject(ErrorService) - private readonly stateService = inject(StateService) - - loading = true - servers: StartOSDiskInfoFull[] = [] - - async ngOnInit() { - this.stateService.setupType = 'restore' - await this.getDrives() - } - - async refresh() { - this.loading = true - await this.getDrives() - } - - async getDrives() { - this.servers = [] - - try { - const drives = await this.api.getDrives() - - this.servers = drives.flatMap(drive => - drive.partitions.flatMap(partition => - Object.entries(partition.startOs).map(([id, val]) => ({ - id, - ...val, - partition, - drive, - })), - ), - ) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - this.loading = false - } - } - - select(password: string, server: StartOSDiskInfoFull) { - this.stateService.recoverySource = { - type: 'backup', - target: { - type: 'disk', - logicalname: server.partition.logicalname, - }, - serverId: server.id, - password, - } - this.router.navigate(['storage']) - } - - onCifs() { - this.dialogs - .open(CIFS, { - label: 'Connect Network Folder', - }) - .subscribe(({ cifs, serverId, password }) => { - this.stateService.recoverySource = { - type: 'backup', - target: { - type: 'cifs', - ...cifs, - }, - serverId, - password, - } - this.router.navigate(['storage']) - }) - } -} diff --git a/web/projects/setup-wizard/src/app/pages/restore.page.ts b/web/projects/setup-wizard/src/app/pages/restore.page.ts new file mode 100644 index 000000000..616d13487 --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/restore.page.ts @@ -0,0 +1,239 @@ +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { DialogService, ErrorService, i18nPipe } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiIcon, + TuiLoader, + TuiOptGroup, + TuiTitle, +} from '@taiga-ui/core' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' +import { StartOSDiskInfoFull, StartOSDiskInfoWithId } from '../types' +import { CIFS, CifsResult } from '../components/cifs.component' +import { SELECT_NETWORK_BACKUP } from '../components/select-network-backup.dialog' +import { UnlockPasswordDialog } from '../components/unlock-password.dialog' + +@Component({ + template: ` +
+
+

+ {{ 'Select Backup' | i18n }} + + {{ 'Select the StartOS backup you want to restore' | i18n }} + + + {{ 'Refresh' | i18n }} + + +

+
+ + @if (loading) { + + } @else { + + + + + + + + + @for (server of physicalServers; track server.id) { + + } @empty { +
{{ 'No physical backups' | i18n }}
+ } +
+
+
+ } +
+ `, + styles: ` + .refresh { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + color: var(--tui-text-action); + + tui-icon { + font-size: 0.875rem; + } + } + + .server-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + + .no-items { + padding: 0.5rem 0.75rem; + color: var(--tui-text-secondary); + font-style: italic; + } + `, + imports: [ + TuiButton, + TuiCardLarge, + TuiDataList, + TuiDropdown, + TuiLoader, + TuiIcon, + TuiOptGroup, + TuiTitle, + TuiHeader, + i18nPipe, + ], +}) +export default class RestorePage { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + private readonly dialogs = inject(DialogService) + private readonly errorService = inject(ErrorService) + private readonly stateService = inject(StateService) + + loading = true + open = false + physicalServers: StartOSDiskInfoFull[] = [] + + async ngOnInit() { + await this.loadDrives() + } + + async refresh() { + this.loading = true + await this.loadDrives() + } + + openCifs() { + this.open = false + this.dialogs + .openComponent(CIFS, { + label: 'Connect Network Folder', + size: 's', + }) + .subscribe(result => { + if (result) { + this.handleCifsResult(result) + } + }) + } + + selectPhysicalBackup(server: StartOSDiskInfoFull) { + this.open = false + this.showUnlockDialog(server.id, { + type: 'disk', + logicalname: server.partition.logicalname, + }) + } + + private handleCifsResult(result: CifsResult) { + if (result.servers.length === 1) { + this.showUnlockDialog(result.servers[0]!.id, { + type: 'cifs', + ...result.cifs, + }) + } else if (result.servers.length > 1) { + this.showSelectNetworkBackupDialog(result.cifs, result.servers) + } + } + + private showSelectNetworkBackupDialog( + cifs: T.Cifs, + servers: StartOSDiskInfoWithId[], + ) { + this.dialogs + .openComponent(SELECT_NETWORK_BACKUP, { + label: 'Select Network Backup', + size: 's', + data: { servers }, + }) + .subscribe(server => { + if (server) { + this.showUnlockDialog(server.id, { type: 'cifs', ...cifs }) + } + }) + } + + private showUnlockDialog( + serverId: string, + target: { type: 'disk'; logicalname: string } | ({ type: 'cifs' } & T.Cifs), + ) { + this.dialogs + .openComponent( + new PolymorpheusComponent(UnlockPasswordDialog), + { + label: 'Unlock Backup', + size: 's', + }, + ) + .subscribe(password => { + if (password) { + this.stateService.recoverySource = { + type: 'backup', + target, + serverId, + password, + } + this.router.navigate(['/password']) + } + }) + } + + private async loadDrives() { + this.physicalServers = [] + + try { + const drives = await this.api.getDisks() + + this.physicalServers = drives.flatMap(drive => + drive.partitions.flatMap(partition => + Object.entries(partition.startOs).map(([id, val]) => ({ + id, + ...val, + partition, + drive, + })), + ), + ) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading = false + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/storage.page.ts b/web/projects/setup-wizard/src/app/pages/storage.page.ts deleted file mode 100644 index e8d7f857b..000000000 --- a/web/projects/setup-wizard/src/app/pages/storage.page.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Component, inject } from '@angular/core' -import { Router } from '@angular/router' -import { - DiskInfo, - DriveComponent, - ErrorService, - i18nKey, - LoadingService, - toGuid, -} from '@start9labs/shared' -import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core' -import { TUI_CONFIRM } from '@taiga-ui/kit' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { filter, of, switchMap } from 'rxjs' -import { PASSWORD } from 'src/app/components/password.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' - -@Component({ - template: ` -
- @if (loading || drives.length) { -
Select storage drive
- This is the drive where your StartOS data will be stored. - } @else { -
No drives found
- Please connect a storage drive to your server. Then click "Refresh". - } - - @if (loading) { - - } - - @for (d of drives; track d) { - - } -
- -
-
- `, - imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent], -}) -export default class StoragePage { - private readonly api = inject(ApiService) - private readonly router = inject(Router) - private readonly dialogs = inject(TuiDialogService) - private readonly stateService = inject(StateService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - - drives: DiskInfo[] = [] - loading = true - - async ngOnInit() { - await this.getDrives() - } - - isSmall({ capacity }: DiskInfo) { - return capacity < 34359738368 - } - - async refresh() { - this.loading = true - await this.getDrives() - } - - async getDrives() { - this.loading = true - try { - const disks = await this.api.getDrives() - if (this.stateService.setupType === 'fresh') { - this.drives = disks - } else if ( - this.stateService.setupType === 'restore' && - this.stateService.recoverySource?.type === 'backup' - ) { - if (this.stateService.recoverySource.target.type === 'disk') { - const logicalname = - this.stateService.recoverySource.target.logicalname - this.drives = disks.filter( - d => !d.partitions.map(p => p.logicalname).includes(logicalname), - ) - } else { - this.drives = disks - } - } else if ( - this.stateService.setupType === 'transfer' && - this.stateService.recoverySource?.type === 'migrate' - ) { - const guid = this.stateService.recoverySource.guid - this.drives = disks.filter(d => { - return ( - d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid) - ) - }) - } - } catch (e: any) { - this.errorService.handleError(e) - } finally { - this.loading = false - } - } - - select(drive: DiskInfo) { - of(!toGuid(drive) && !drive.partitions.some(p => p.used)) - .pipe( - switchMap(unused => - unused - ? of(true) - : this.dialogs.open(TUI_CONFIRM, { - label: 'Warning', - size: 's', - data: { - content: - 'Drive contains data!

All data stored on this drive will be permanently deleted.

', - yes: 'Continue', - no: 'Cancel', - }, - }), - ), - ) - .pipe(filter(Boolean)) - .subscribe(() => { - // for backup recoveries - if (this.stateService.recoverySource?.type === 'backup') { - this.setupEmbassy( - drive.logicalname, - this.stateService.recoverySource.password, - ) - } else { - // for migrations and fresh setups - this.promptPassword(drive.logicalname) - } - }) - } - - private promptPassword(logicalname: string) { - this.dialogs - .open(PASSWORD, { - label: 'Set Password', - size: 's', - data: { storageDrive: true }, - }) - .subscribe(password => { - this.setupEmbassy(logicalname, password) - }) - } - - private async setupEmbassy( - logicalname: string, - password: string, - ): Promise { - const loader = this.loader - .open('Connecting to drive' as i18nKey) - .subscribe() - - try { - await this.stateService.setupEmbassy(logicalname, password) - await this.router.navigate(['loading']) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/setup-wizard/src/app/pages/success.page.ts b/web/projects/setup-wizard/src/app/pages/success.page.ts index fb68f0a0d..e83e756b6 100644 --- a/web/projects/setup-wizard/src/app/pages/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success.page.ts @@ -6,155 +6,224 @@ import { ViewChild, DOCUMENT, } from '@angular/core' -import { DownloadHTMLService, ErrorService } from '@start9labs/shared' -import { TuiButton, TuiIcon, TuiLoader, TuiSurface } from '@taiga-ui/core' -import { TuiCardLarge } from '@taiga-ui/layout' -import { DocumentationComponent } from 'src/app/components/documentation.component' -import { MatrixComponent } from 'src/app/components/matrix.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared' +import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core' +import { TuiAvatar } from '@taiga-ui/kit' +import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' +import { DocumentationComponent } from '../components/documentation.component' +import { MatrixComponent } from '../components/matrix.component' +import { SetupCompleteRes } from '../types' @Component({ template: `
-

- - Setup Complete! -

- @if (stateService.kiosk) { - - } @else if (lanAddress) { - @if (stateService.setupType === 'restore') { -

You can now safely unplug your backup drive

- } @else if (stateService.setupType === 'transfer') { -

You can now safely unplug your old StartOS data drive

+
+

+ + + {{ 'Setup Complete!' | i18n }} + + @if (!stateService.kiosk) { + + {{ + stateService.setupType === 'restore' + ? ('You can unplug your backup drive' | i18n) + : stateService.setupType === 'transfer' + ? ('You can unplug your transfer drive' | i18n) + : ('http://start.local was for setup only. It will no longer work.' + | i18n) + }} + + } +

+
+ + @if (!result) { + + } @else { + + @if (!stateService.kiosk) { + } -

- http://start.local was for setup purposes only. It will no longer - work. -

+ + @if (result.needsRestart) { + - + + + } - - - In the new tab, follow instructions to trust your server's Root CA - and log in. - - - Open Local Address - - - -
`, styles: ` - .heading { - display: flex; - gap: 1rem; + .inline-title { + display: inline-flex; align-items: center; - margin: 0; - font: var(--tui-font-heading-4); - } - - .caps { - display: flex; - align-items: center; - justify-content: center; gap: 0.5rem; - text-transform: uppercase; } - [tuiCardLarge] { - color: var(--tui-text-primary); - text-decoration: none; - text-align: center; - - &[data-appearance='floating'] { - background: var(--tui-background-neutral-1); - - &:hover { - background: var(--tui-background-neutral-1-hover) !important; - } - } - } - - a[tuiCardLarge]:not([href]) { + [tuiCell].disabled { opacity: var(--tui-disabled-opacity); pointer-events: none; } - - h3 { - text-align: left; - } `, imports: [ TuiCardLarge, + TuiCell, TuiIcon, - TuiButton, - TuiSurface, + TuiLoader, + TuiAvatar, MatrixComponent, DocumentationComponent, - TuiLoader, + TuiHeader, + TuiTitle, + i18nPipe, ], }) export default class SuccessPage implements AfterViewInit { @ViewChild(DocumentationComponent, { read: ElementRef }) private readonly documentation?: ElementRef + private readonly document = inject(DOCUMENT) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) private readonly downloadHtml = inject(DownloadHTMLService) + private readonly i18n = inject(i18nPipe) readonly stateService = inject(StateService) - lanAddress?: string - cert?: string - disableLogin = this.stateService.setupType === 'fresh' + result?: SetupCompleteRes + lanAddress = '' + downloaded = false + usbRemoved = false + rebooting = false + rebooted = false + + get canOpenAddress(): boolean { + if (!this.downloaded) return false + if (this.result?.needsRestart && !this.rebooted) return false + return true + } ngAfterViewInit() { - setTimeout(() => this.complete(), 1000) + setTimeout(() => this.complete(), 500) } download() { - const lanElem = this.document.getElementById('lan-addr') + if (this.downloaded) return - if (lanElem) lanElem.innerHTML = this.lanAddress || '' + const lanElem = this.document.getElementById('lan-addr') + if (lanElem) lanElem.innerHTML = this.lanAddress this.document .getElementById('cert') ?.setAttribute( 'href', URL.createObjectURL( - new Blob([this.cert!], { type: 'application/octet-stream' }), + new Blob([this.result!.rootCa], { type: 'application/octet-stream' }), ), ) const html = this.documentation?.nativeElement.innerHTML || '' - this.downloadHtml.download('StartOS-info.html', html).then(_ => { - this.disableLogin = false + this.downloadHtml.download('StartOS-info.html', html).then(() => { + this.downloaded = true }) } @@ -162,17 +231,58 @@ export default class SuccessPage implements AfterViewInit { this.api.exit() } + openLocalAddress() { + window.open(this.lanAddress, '_blank') + } + + async reboot() { + this.rebooting = true + + try { + await this.api.exit() + await this.pollForServer() + this.rebooted = true + this.rebooting = false + } catch (e: any) { + this.errorService.handleError(e) + this.rebooting = false + } + } + private async complete() { try { - const ret = await this.api.complete() - if (!this.stateService.kiosk) { - this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:') - this.cert = ret.rootCa + this.result = await this.api.complete() - await this.api.exit() + if (!this.stateService.kiosk) { + this.lanAddress = `http://${this.result.hostname}.local` + + if (!this.result.needsRestart) { + await this.api.exit() + } } } catch (e: any) { this.errorService.handleError(e) } } + + private async pollForServer(): Promise { + const maxAttempts = 60 + let attempts = 0 + + while (attempts < maxAttempts) { + try { + await this.api.echo({ message: 'ping' }, this.lanAddress) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 5000)) + attempts++ + } + } + + throw new Error( + this.i18n.transform( + 'Server did not come back online. Please check your server and try accessing it manually.', + ), + ) + } } diff --git a/web/projects/setup-wizard/src/app/pages/transfer.page.ts b/web/projects/setup-wizard/src/app/pages/transfer.page.ts index 3082b0662..f5e6aa2c9 100644 --- a/web/projects/setup-wizard/src/app/pages/transfer.page.ts +++ b/web/projects/setup-wizard/src/app/pages/transfer.page.ts @@ -1,97 +1,172 @@ import { Component, inject } from '@angular/core' import { Router } from '@angular/router' import { + DialogService, DiskInfo, - DriveComponent, ErrorService, + i18nPipe, toGuid, } from '@start9labs/shared' import { TuiButton, - TuiDialogOptions, - TuiDialogService, + TuiDataList, + TuiDropdown, + TuiIcon, TuiLoader, + TuiTitle, } from '@taiga-ui/core' -import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { filter } from 'rxjs' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' @Component({ template: `
-
Transfer
- Select the physical drive containing your StartOS data +
+

+ {{ 'Transfer Data' | i18n }} + + {{ + 'Select the drive containing your existing StartOS data' | i18n + }} + + + {{ 'Refresh' | i18n }} + + +

+
+ @if (loading) { - } - @for (drive of drives; track drive) { - - } -
- -
+ + + + @for (drive of drives; track drive.logicalname) { + + } @empty { +
+ {{ 'No StartOS data drives found' | i18n }} +
+ } +
+
+ }
`, - imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent], + styles: ` + .refresh { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + color: var(--tui-text-action); + + tui-icon { + font-size: 0.875rem; + } + } + + .drive-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + + .no-items { + padding: 0.5rem 0.75rem; + color: var(--tui-text-secondary); + font-style: italic; + } + `, + imports: [ + TuiButton, + TuiCardLarge, + TuiDataList, + TuiDropdown, + TuiIcon, + TuiLoader, + TuiTitle, + TuiHeader, + i18nPipe, + ], }) export default class TransferPage { - private readonly apiService = inject(ApiService) + private readonly api = inject(ApiService) private readonly router = inject(Router) - private readonly dialogs = inject(TuiDialogService) + private readonly dialogs = inject(DialogService) private readonly errorService = inject(ErrorService) private readonly stateService = inject(StateService) loading = true + open = false drives: DiskInfo[] = [] async ngOnInit() { - this.stateService.setupType = 'transfer' - await this.getDrives() + await this.loadDrives() } async refresh() { - await this.getDrives() + this.loading = true + await this.loadDrives() } - async getDrives() { - this.loading = true + select(drive: DiskInfo) { + this.open = false + this.dialogs + .openConfirm({ + label: 'Warning', + size: 's', + data: { + content: + 'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.', + yes: 'Continue', + no: 'Cancel', + }, + }) + .pipe(filter(Boolean)) + .subscribe(() => { + const guid = toGuid(drive) + if (guid) { + this.stateService.recoverySource = { + type: 'migrate', + guid, + } + this.router.navigate(['/password']) + } + }) + } + + private async loadDrives() { try { - this.drives = await this.apiService - .getDrives() - .then(drives => drives.filter(toGuid)) + const allDrives = await this.api.getDisks() + // Filter to only drives with StartOS data (guid) + this.drives = allDrives.filter(toGuid) } catch (e: any) { this.errorService.handleError(e) } finally { this.loading = false } } - - select(drive: DiskInfo) { - this.dialogs - .open(TUI_CONFIRM, OPTIONS) - .pipe(filter(Boolean)) - .subscribe(() => { - this.stateService.recoverySource = { - type: 'migrate', - guid: toGuid(drive) || '', - } - this.router.navigate([`storage`]) - }) - } -} - -const OPTIONS: Partial> = { - label: 'Warning', - size: 's', - data: { - content: - 'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.', - yes: 'Continue', - no: 'Cancel', - }, } diff --git a/web/projects/setup-wizard/src/app/services/api.service.ts b/web/projects/setup-wizard/src/app/services/api.service.ts index 03604b7aa..b11aa86a5 100644 --- a/web/projects/setup-wizard/src/app/services/api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api.service.ts @@ -1,50 +1,65 @@ import * as jose from 'node-jose' import { DiskInfo, - DiskListResponse, FollowLogsRes, - PartitionInfo, + FullKeyboard, + SetLanguageParams, StartOSDiskInfo, } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' -import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { Observable } from 'rxjs' +import { + SetupStatusRes, + InstallOsParams, + InstallOsRes, + AttachParams, + SetupExecuteParams, + SetupCompleteRes, + EchoReq, +} from '../types' export abstract class ApiService { pubkey?: jose.JWK.Key - abstract getStatus(): Promise // setup.status + // echo + abstract echo(params: EchoReq, url: string): Promise + + // Status & Setup + abstract getStatus(): Promise // setup.status abstract getPubKey(): Promise // setup.get-pubkey - abstract getDrives(): Promise // setup.disk.list + abstract setKeyboard(params: FullKeyboard): Promise // setup.set-keyboard + abstract setLanguage(params: SetLanguageParams): Promise // setup.set-language + + // Install + abstract getDisks(): Promise // setup.disk.list + abstract installOs(params: InstallOsParams): Promise // setup.install-os + + // Setup execution + abstract attach(params: AttachParams): Promise // setup.attach + abstract execute(params: SetupExecuteParams): Promise // setup.execute + + // Recovery helpers abstract verifyCifs( cifs: T.VerifyCifsParams, ): Promise> // setup.cifs.verify - abstract attach(importInfo: T.AttachParams): Promise // setup.attach - abstract execute(setupInfo: T.SetupExecuteParams): Promise // setup.execute - abstract complete(): Promise // setup.complete + + // Completion + abstract complete(): Promise // setup.complete abstract exit(): Promise // setup.exit + abstract shutdown(): Promise // setup.shutdown + + // Logs & Progress abstract initFollowLogs(): Promise // setup.logs.follow - abstract restart(): Promise // setup.restart abstract openWebsocket$(guid: string): Observable + // Restart (for error recovery) + abstract restart(): Promise // setup.restart + async encrypt(toEncrypt: string): Promise { if (!this.pubkey) throw new Error('No pubkey found!') const encrypted = await jose.JWE.createEncrypt(this.pubkey!) .update(toEncrypt) .final() - return { - encrypted, - } + return { encrypted } } } - -export type WebsocketConfig = Omit, 'url'> - -export type StartOSDiskInfoWithId = StartOSDiskInfo & { - id: string -} - -export type StartOSDiskInfoFull = StartOSDiskInfoWithId & { - partition: PartitionInfo - drive: DiskInfo -} diff --git a/web/projects/setup-wizard/src/app/services/live-api.service.ts b/web/projects/setup-wizard/src/app/services/live-api.service.ts index 43fc7016a..42c306d50 100644 --- a/web/projects/setup-wizard/src/app/services/live-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/live-api.service.ts @@ -1,12 +1,14 @@ import { Inject, Injectable, DOCUMENT } from '@angular/core' import { - DiskListResponse, + DiskInfo, encodeBase64, FollowLogsRes, + FullKeyboard, HttpService, isRpcError, RpcError, RPCOptions, + SetLanguageParams, StartOSDiskInfo, } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' @@ -14,6 +16,15 @@ import * as jose from 'node-jose' import { Observable } from 'rxjs' import { webSocket } from 'rxjs/webSocket' import { ApiService } from './api.service' +import { + SetupStatusRes, + InstallOsParams, + InstallOsRes, + AttachParams, + SetupExecuteParams, + SetupCompleteRes, + EchoReq, +} from '../types' @Injectable({ providedIn: 'root', @@ -36,39 +47,54 @@ export class LiveApiService extends ApiService { }) } - async getStatus(): Promise { - return this.rpcRequest({ + async echo(params: EchoReq, url: string): Promise { + return this.rpcRequest({ method: 'echo', params }, url) + } + + async getStatus() { + return this.rpcRequest({ method: 'setup.status', params: {}, }) } - /** - * We want to update the pubkey, which means that we will call in clearnet the - * getPubKey, and all the information is never in the clear, and only public - * information is sent across the network. We don't want to expose that we do - * this wil all public/private key, which means that there is no information loss - * through the network. - */ - async getPubKey(): Promise { + async getPubKey() { const response: jose.JWK.Key = await this.rpcRequest({ method: 'setup.get-pubkey', params: {}, }) - this.pubkey = response } - async getDrives(): Promise { - return this.rpcRequest({ + async setKeyboard(params: FullKeyboard): Promise { + return this.rpcRequest({ + method: 'setup.set-keyboard', + params, + }) + } + + async setLanguage(params: SetLanguageParams): Promise { + return this.rpcRequest({ + method: 'setup.set-language', + params, + }) + } + + async getDisks() { + return this.rpcRequest({ method: 'setup.disk.list', params: {}, }) } - async verifyCifs( - source: T.VerifyCifsParams, - ): Promise> { + async installOs(params: InstallOsParams) { + return this.rpcRequest({ + method: 'setup.install-os', + params, + }) + } + + async verifyCifs(source: T.VerifyCifsParams) { source.path = source.path.replace('/\\/g', '/') return this.rpcRequest>({ method: 'setup.cifs.verify', @@ -76,33 +102,36 @@ export class LiveApiService extends ApiService { }) } - async attach(params: T.AttachParams): Promise { + async attach(params: AttachParams) { return this.rpcRequest({ method: 'setup.attach', params, }) } - async execute(setupInfo: T.SetupExecuteParams): Promise { - if (setupInfo.recoverySource?.type === 'backup') { - if (isCifsSource(setupInfo.recoverySource.target)) { - setupInfo.recoverySource.target.path = - setupInfo.recoverySource.target.path.replace('/\\/g', '/') + async execute(params: SetupExecuteParams) { + if (params.recoverySource?.type === 'backup') { + const target = params.recoverySource.target + if (target.type === 'cifs') { + target.path = target.path.replace('/\\/g', '/') } } return this.rpcRequest({ method: 'setup.execute', - params: setupInfo, + params, }) } - async initFollowLogs(): Promise { - return this.rpcRequest({ method: 'setup.logs.follow', params: {} }) + async initFollowLogs() { + return this.rpcRequest({ + method: 'setup.logs.follow', + params: {}, + }) } - async complete(): Promise { - const res = await this.rpcRequest({ + async complete() { + const res = await this.rpcRequest({ method: 'setup.complete', params: {}, }) @@ -113,23 +142,29 @@ export class LiveApiService extends ApiService { } } - async exit(): Promise { + async exit() { await this.rpcRequest({ method: 'setup.exit', params: {}, }) } - async restart(): Promise { + async shutdown() { + await this.rpcRequest({ + method: 'setup.shutdown', + params: {}, + }) + } + + async restart() { await this.rpcRequest({ method: 'setup.restart', params: {}, }) } - private async rpcRequest(opts: RPCOptions): Promise { - const res = await this.http.rpcRequest(opts) - + private async rpcRequest(opts: RPCOptions, url?: string): Promise { + const res = await this.http.rpcRequest(opts, url) const rpcRes = res.body if (isRpcError(rpcRes)) { @@ -139,9 +174,3 @@ export class LiveApiService extends ApiService { return rpcRes.result } } - -function isCifsSource( - source: T.BackupTargetFS | null, -): source is T.Cifs & { type: 'cifs' } { - return !!(source as T.Cifs)?.hostname -} diff --git a/web/projects/setup-wizard/src/app/services/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/mock-api.service.ts index e62597bfe..b94f9fda3 100644 --- a/web/projects/setup-wizard/src/app/services/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/mock-api.service.ts @@ -1,111 +1,33 @@ import { Injectable } from '@angular/core' import { - DiskListResponse, + DiskInfo, encodeBase64, FollowLogsRes, + FullKeyboard, pauseFor, + SetLanguageParams, StartOSDiskInfo, } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import * as jose from 'node-jose' -import { first, interval, map, Observable } from 'rxjs' +import { interval, map, Observable } from 'rxjs' import { ApiService } from './api.service' +import { + SetupStatusRes, + InstallOsParams, + InstallOsRes, + AttachParams, + SetupExecuteParams, + SetupCompleteRes, + EchoReq, +} from '../types' @Injectable({ providedIn: 'root', }) export class MockApiService extends ApiService { - // fullProgress$(): Observable { - // const phases = [ - // { - // name: 'Preparing Data', - // progress: null, - // }, - // { - // name: 'Transferring Data', - // progress: null, - // }, - // { - // name: 'Finalizing Setup', - // progress: null, - // }, - // ] - - // return from(phases).pipe( - // switchScan((acc, val, i) => {}, { overall: null, phases }), - // ) - // } - - // namedProgress$(namedProgress: T.NamedProgress): Observable { - // return of(namedProgress).pipe(startWith(namedProgress)) - // } - - // progress$(progress: T.Progress): Observable {} - - // websocket - - // oldMockProgress$(): Promise { - // const numPhases = PROGRESS.phases.length - - // return of(PROGRESS).pipe( - // switchMap(full => - // from(PROGRESS.phases).pipe( - // mergeScan((full, phase, i) => { - // if ( - // !phase.progress || - // typeof phase.progress !== 'object' || - // !phase.progress.total - // ) { - // full.phases[i].progress = true - - // if ( - // full.overall && - // typeof full.overall === 'object' && - // full.overall.total - // ) { - // const step = full.overall.total / numPhases - // full.overall.done += step - // } - - // return of(full).pipe(delay(2000)) - // } else { - // const total = phase.progress.total - // const step = total / 4 - // let done = phase.progress.done - - // return interval(1000).pipe( - // takeWhile(() => done < total), - // map(() => { - // done += step - - // console.error(done) - - // if ( - // full.overall && - // typeof full.overall === 'object' && - // full.overall.total - // ) { - // const step = full.overall.total / numPhases / 4 - - // full.overall.done += step - // } - - // if (done === total) { - // full.phases[i].progress = true - - // if (i === numPhases - 1) { - // full.overall = true - // } - // } - // return full - // }), - // ) - // } - // }, full), - // ), - // ), - // ) - // } + private statusIndex = 0 + private installCompleted = false openWebsocket$(guid: string): Observable { if (guid === 'logs-guid') { @@ -117,24 +39,13 @@ export class MockApiService extends ApiService { })), ) as Observable } else if (guid === 'progress-guid') { - // @TODO Matt mock progress return interval(1000).pipe( - first(), map(() => ({ overall: true, phases: [ - { - name: 'Preparing Data', - progress: true, - }, - { - name: 'Transferring Data', - progress: true, - }, - { - name: 'Finalizing Setup', - progress: true, - }, + { name: 'Preparing Data', progress: true }, + { name: 'Transferring Data', progress: true }, + { name: 'Finalizing Setup', progress: true }, ], })), ) as Observable @@ -143,40 +54,44 @@ export class MockApiService extends ApiService { } } - private statusIndex = 0 - async getStatus(): Promise { - await pauseFor(1000) + async echo(params: EchoReq, url: string): Promise { + if (url) { + const num = Math.floor(Math.random() * 10) + 1 + if (num > 8) return params.message + throw new Error() + } + await pauseFor(500) + return params.message + } + + async getStatus(): Promise { + await pauseFor(500) this.statusIndex++ - switch (this.statusIndex) { - case 2: - return { - status: 'running', - progress: PROGRESS, - guid: 'progress-guid', - } - case 3: - return { - status: 'complete', - torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'], - hostname: 'adjective-noun', - lanAddress: 'https://adjective-noun.local', - rootCa: encodeBase64(rootCA), - } - default: - return null + if (this.statusIndex === 1) { + return { status: 'needs-install', keyboard: null } + // return { + // status: 'incomplete', + // attach: false, + // guid: 'mock-data-guid', + // keyboard: null, + // } + } + + if (this.statusIndex > 3) { + return { status: 'complete' } + } + + return { + status: 'running', + progress: PROGRESS, + guid: 'progress-guid', } } async getPubKey(): Promise { - await pauseFor(1000) - - // randomly generated - // const keystore = jose.JWK.createKeyStore() - // this.pubkey = await keystore.generate('EC', 'P-256') - - // generated from backend + await pauseFor(300) this.pubkey = await jose.JWK.asKey({ kty: 'EC', crv: 'P-256', @@ -185,88 +100,28 @@ export class MockApiService extends ApiService { }) } - async getDrives(): Promise { - await pauseFor(1000) - return [ - { - logicalname: '/dev/nvme0n1p3', - vendor: 'Unknown Vendor', - model: 'Samsung SSD - 970 EVO Plus 2TB', - partitions: [ - { - logicalname: 'pabcd', - label: null, - capacity: 1979120929996, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - version: '0.2.17', - timestamp: new Date().toISOString(), - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: null, - }, - ], - capacity: 1979120929996, - guid: 'uuid-uuid-uuid-uuid', - }, - { - logicalname: 'dcba', - vendor: 'CT1000MX', - model: '500SSD1', - partitions: [ - { - logicalname: 'pbcba', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - version: '0.2.17', - timestamp: new Date().toISOString(), - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: null, - }, - ], - capacity: 1000190509056, - guid: null, - }, - { - logicalname: '/dev/sda', - vendor: 'ASMT', - model: '2115', - partitions: [ - { - logicalname: 'pbcba', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - version: '0.2.17', - timestamp: new Date().toISOString(), - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: 'guid-guid-guid-guid', - }, - ], - capacity: 1000190509, - guid: null, - }, - ] + async setKeyboard(_params: FullKeyboard): Promise { + await pauseFor(300) + return null + } + + async setLanguage(params: SetLanguageParams): Promise { + await pauseFor(300) + return null + } + + async getDisks(): Promise { + await pauseFor(500) + return MOCK_DISKS + } + + async installOs(params: InstallOsParams): Promise { + await pauseFor(2000) + this.installCompleted = true + return { + guid: 'mock-data-guid', + attach: !params.dataDrive.wipe, + } } async verifyCifs( @@ -282,21 +137,29 @@ export class MockApiService extends ApiService { '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', wrappedKey: '', }, + '9876-5432-1234-5671': { + hostname: 'adjective-noun', + version: '0.3.6', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: '', + }, } } - async attach(params: T.AttachParams): Promise { + async attach(params: AttachParams): Promise { await pauseFor(1000) - + this.statusIndex = 1 // Jump to running state return { progress: PROGRESS, guid: 'progress-guid', } } - async execute(setupInfo: T.SetupExecuteParams): Promise { + async execute(params: SetupExecuteParams): Promise { await pauseFor(1000) - + this.statusIndex = 1 // Jump to running state return { progress: PROGRESS, guid: 'progress-guid', @@ -304,33 +167,113 @@ export class MockApiService extends ApiService { } async initFollowLogs(): Promise { - await pauseFor(1000) + await pauseFor(500) return { startCursor: 'fakestartcursor', guid: 'logs-guid', } } - async complete(): Promise { - await pauseFor(1000) + async complete(): Promise { + await pauseFor(500) return { - torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'], hostname: 'adjective-noun', - lanAddress: 'https://adjective-noun.local', - rootCa: encodeBase64(rootCA), + rootCa: encodeBase64(ROOT_CA), + needsRestart: this.installCompleted, } } async exit(): Promise { - await pauseFor(1000) + await pauseFor(500) + } + + async shutdown(): Promise { + await pauseFor(500) } async restart(): Promise { - await pauseFor(1000) + await pauseFor(500) } } -const rootCA = `-----BEGIN CERTIFICATE----- +const MOCK_DISKS: DiskInfo[] = [ + { + logicalname: '/dev/sda', + vendor: 'Samsung', + model: 'SSD 970 EVO Plus', + partitions: [ + { + logicalname: '/dev/sda1', + label: null, + capacity: 500000000000, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 500000000000, + guid: null, + }, + { + logicalname: '/dev/sdb', + vendor: 'Crucial', + model: 'MX500', + partitions: [ + { + logicalname: '/dev/sdb1', + label: null, + capacity: 1000000000000, + used: null, + startOs: { + '1234-5678-9876-5432': { + hostname: 'existing-server', + version: '0.3.6', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, + }, + guid: 'existing-guid', + }, + ], + capacity: 1000000000000, + guid: 'existing-guid', + }, + { + logicalname: '/dev/sdc', + vendor: 'WD', + model: 'Blue SN570', + partitions: [ + { + logicalname: '/dev/sdc1', + label: 'Backup', + capacity: 2000000000000, + used: 500000000000, + startOs: { + 'backup-server-id': { + hostname: 'backup-server', + version: '0.3.5', + timestamp: new Date(Date.now() - 86400000).toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: '', + }, + }, + guid: null, + }, + ], + capacity: 2000000000000, + guid: null, + }, +] + +const PROGRESS: T.FullProgress = { + overall: null, + phases: [], +} + +const ROOT_CA = `-----BEGIN CERTIFICATE----- MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO @@ -352,8 +295,3 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX 2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4 -----END CERTIFICATE-----` - -const PROGRESS = { - overall: null, - phases: [], -} diff --git a/web/projects/setup-wizard/src/app/services/state.service.ts b/web/projects/setup-wizard/src/app/services/state.service.ts index fc8e3e66e..e1c07ff12 100644 --- a/web/projects/setup-wizard/src/app/services/state.service.ts +++ b/web/projects/setup-wizard/src/app/services/state.service.ts @@ -1,6 +1,28 @@ import { inject, Injectable } from '@angular/core' -import { ApiService } from './api.service' import { T } from '@start9labs/start-sdk' +import { ApiService } from './api.service' + +export type SetupType = 'fresh' | 'restore' | 'attach' | 'transfer' + +export type RecoverySource = + | { + type: 'migrate' + guid: string + } + | { + type: 'backup' + target: + | { type: 'disk'; logicalname: string } + | { + type: 'cifs' + hostname: string + path: string + username: string + password: string | null + } + serverId: string + password: string // plaintext, will be encrypted before sending + } @Injectable({ providedIn: 'root', @@ -8,34 +30,68 @@ import { T } from '@start9labs/start-sdk' export class StateService { private readonly api = inject(ApiService) - kiosk?: boolean - setupType?: 'fresh' | 'restore' | 'attach' | 'transfer' - recoverySource?: T.RecoverySource + // Determined at app init + kiosk = false - async importDrive(guid: string, password: string): Promise { + // Set during install flow, or loaded from status response + language = '' + keyboard = '' + + // From install response or status response (incomplete) + dataDriveGuid = '' + attach = false + + // Set during setup flow + setupType?: SetupType + recoverySource?: RecoverySource + + /** + * Called for attach flow (existing data drive) + */ + async attachDrive(password: string | null): Promise { await this.api.attach({ - guid, - startOsPassword: await this.api.encrypt(password), - kiosk: this.kiosk, + guid: this.dataDriveGuid, + password: password ? await this.api.encrypt(password) : null, }) } - async setupEmbassy( - storageLogicalname: string, - password: string, - ): Promise { + /** + * Called for fresh, restore, and transfer flows + * password is required for fresh, optional for restore/transfer + */ + async executeSetup(password: string | null): Promise { + let recoverySource: T.RecoverySource | null = null + + if (this.recoverySource) { + if (this.recoverySource.type === 'migrate') { + recoverySource = this.recoverySource + } else { + // backup type - need to encrypt the backup password + recoverySource = { + type: 'backup', + target: this.recoverySource.target, + serverId: this.recoverySource.serverId, + password: await this.api.encrypt(this.recoverySource.password), + } + } + } + await this.api.execute({ - startOsLogicalname: storageLogicalname, - startOsPassword: await this.api.encrypt(password), - recoverySource: this.recoverySource - ? this.recoverySource.type === 'migrate' - ? this.recoverySource - : { - ...this.recoverySource, - password: await this.api.encrypt(this.recoverySource.password), - } - : null, - kiosk: this.kiosk, + guid: this.dataDriveGuid, + password: password ? await this.api.encrypt(password) : null, + recoverySource, }) } + + /** + * Reset state for a fresh start + */ + reset(): void { + this.language = '' + this.keyboard = '' + this.dataDriveGuid = '' + this.attach = false + this.setupType = undefined + this.recoverySource = undefined + } } diff --git a/web/projects/setup-wizard/src/app/types.ts b/web/projects/setup-wizard/src/app/types.ts new file mode 100644 index 000000000..854ca3d48 --- /dev/null +++ b/web/projects/setup-wizard/src/app/types.ts @@ -0,0 +1,94 @@ +import { + DiskInfo, + FullKeyboard, + PartitionInfo, + StartOSDiskInfo, +} from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' + +// === Echo === + +export type EchoReq = { + message: string +} + +// === Setup Status === + +export type SetupStatusRes = + | { status: 'needs-install'; keyboard: FullKeyboard | null } + | { + status: 'incomplete' + guid: string + attach: boolean + keyboard: FullKeyboard | null + } + | { status: 'running'; progress: T.FullProgress; guid: string } + | { status: 'complete' } + +// === Install OS === + +export interface InstallOsParams { + osDrive: string // e.g. /dev/sda + dataDrive: { + logicalname: string // e.g. /dev/sda, /dev/sdb3 + wipe: boolean + } +} + +export interface InstallOsRes { + guid: string // data drive guid + attach: boolean +} + +// === Attach === + +export interface AttachParams { + password: T.EncryptedWire | null + guid: string // data drive +} + +// === Execute === + +export interface SetupExecuteParams { + guid: string + password: T.EncryptedWire | null // null = keep existing password (for restore/transfer) + recoverySource: + | { + type: 'migrate' + guid: string + } + | { + type: 'backup' + target: + | { type: 'disk'; logicalname: string } + | { + type: 'cifs' + hostname: string + path: string + username: string + password: string | null + } + password: T.EncryptedWire + serverId: string + } + | null +} + +// === Complete === + +export interface SetupCompleteRes { + hostname: string // unique.local + rootCa: string + needsRestart: boolean +} + +// === Disk Info Helpers === + +export type StartOSDiskInfoWithId = StartOSDiskInfo & { + id: string +} + +export type StartOSDiskInfoFull = StartOSDiskInfoWithId & { + partition: PartitionInfo + drive: DiskInfo +} diff --git a/web/projects/setup-wizard/src/styles.scss b/web/projects/setup-wizard/src/styles.scss index 818b4ea92..1e1d12cb2 100644 --- a/web/projects/setup-wizard/src/styles.scss +++ b/web/projects/setup-wizard/src/styles.scss @@ -19,16 +19,29 @@ router-outlet + * { flex-direction: column; align-items: center; box-sizing: border-box; + justify-content: center; padding: 2rem; margin: 0 auto; [tuiCardLarge] { width: 100%; background: var(--tui-background-elevation-2); - margin: auto; + margin-top: 2rem; } } +.inline-title { + display: inline-flex; + align-items: center; + gap: 0.5rem; + + :first-child { + width: 2rem; + height: 2rem; + } + +} + button:disabled { opacity: var(--tui-disabled-opacity); pointer-events: none; @@ -74,4 +87,4 @@ h2 { [tuiCell]:not(:last-of-type) { box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal); -} +} \ No newline at end of file diff --git a/web/projects/shared/src/components/drive.component.ts b/web/projects/shared/src/components/drive.component.ts deleted file mode 100644 index e2f8e14ff..000000000 --- a/web/projects/shared/src/components/drive.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TuiIcon, TuiTitle } from '@taiga-ui/core' -import { Component, Input } from '@angular/core' -import { UnitConversionPipesModule } from '../pipes/unit-conversion/unit-conversion.module' - -@Component({ - selector: 'button[drive]', - template: ` - - - {{ drive.logicalname }} - - {{ drive.vendor || 'Unknown Vendor' }} - - {{ drive.model || 'Unknown Model' }} - - Capacity: {{ drive.capacity | convertBytes }} - - - `, - imports: [TuiIcon, TuiTitle, UnitConversionPipesModule], -}) -export class DriveComponent { - @Input() drive!: { - logicalname: string | null - vendor: string | null - model: string | null - capacity: number - } -} diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index c467df91d..e229d9f24 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -99,6 +99,7 @@ export default { 101: 'Sie haben nicht gespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?', 102: 'Verlassen', 103: 'Sind Sie sicher?', + 104: 'Neues Netzwerk-Gateway', 108: 'Öffentlich', 109: 'privat', 111: 'Keine Onion-Domains', @@ -493,6 +494,7 @@ export default { 516: 'Empfohlen', 517: 'Möchten Sie diese Aufgabe wirklich verwerfen?', 518: 'Verwerfen', + 519: 'Muss eine gültige IPv4- oder IPv6-Adresse mit optionalem Port sein', 520: 'Update verfügbar', 521: 'Um das Problem zu beheben, siehe', 522: 'SDK version', @@ -582,10 +584,10 @@ export default { 612: 'Grund', 613: 'Private Gateways für die StartOS-Benutzeroberfläche können nicht deaktiviert werden', 614: 'Root-CA', - 615: 'DHCP-Server', - 616: 'DHCP-Server können nicht bearbeitet werden', + 615: 'Die von Ihrem Router bereitgestellten DNS-Server verwenden', + 616: 'Manuell festgelegte DNS-Server verwenden', 617: 'Statisch', - 618: 'Statische Server', + 618: 'Server', 619: 'Warnung. StartOS verwendet derzeit das folgende Gateway für DNS', 620: 'Wenn Sie dieses Gateway für die Auflösung privater Domains verwenden möchten, legen Sie alternative statische DNS-Server mit dem obigen Formular fest.', 621: 'Einen Dienst paketieren', @@ -596,5 +598,86 @@ export default { 626: 'Hochladen', 627: 'UI öffnen', 628: 'In Zwischenablage kopiert', - 629: 'Die Liste ist leer', + 629: 'StartOS wird installiert', + 630: 'Wähle deine Sprache', + 631: 'Tastaturlayout auswählen', + 632: 'Tastatur', + 633: 'Einrichtungsablauf auswählen', + 634: 'Neu beginnen', + 635: 'Einen brandneuen Server einrichten', + 636: 'Aus Backup wiederherstellen', + 637: 'StartOS-Daten aus einem verschlüsselten Backup wiederherstellen', + 638: 'Daten von einem bestehenden StartOS-Datenträger übertragen', + 639: 'Laufwerke auswählen', + 640: 'Keine Laufwerke gefunden. Bitte schließen Sie ein Laufwerk an und klicken Sie auf Aktualisieren.', + 641: 'OS-Laufwerk', + 642: 'Datenlaufwerk', + 643: 'StartOS-Daten erkannt', + 644: 'Daten auf dem OS-Laufwerk könnten überschrieben werden.', + 645: 'Ihre StartOS-Daten auf dem Datenlaufwerk bleiben erhalten.', + 646: 'Daten auf diesem Laufwerk werden überschrieben.', + 647: 'Daten auf beiden Laufwerken werden überschrieben.', + 648: 'Backup auswählen', + 649: 'Wählen Sie das StartOS-Backup aus, das Sie wiederherstellen möchten', + 650: 'Netzwerk-Backup öffnen', + 651: 'Physische Backups', + 652: 'Keine physischen Backups', + 653: 'Netzwerkordner verbinden', + 654: 'Netzwerk-Backup auswählen', + 655: 'Backup entsperren', + 656: 'Daten übertragen', + 657: 'Wählen Sie das Laufwerk mit Ihren bestehenden StartOS-Daten aus', + 658: 'Laufwerk auswählen', + 659: 'Keine StartOS-Datenlaufwerke gefunden', + 660: 'Master-Passwort festlegen', + 661: 'Neues Passwort festlegen (optional)', + 662: 'Machen Sie es gut. Schreiben Sie es auf.', + 663: 'Überspringen, um Ihr bestehendes Passwort beizubehalten.', + 664: 'Passwort eingeben', + 665: 'Passwort bestätigen', + 666: 'Fertigstellen', + 667: 'Einrichtung wird gestartet', + 670: 'Warten Sie 1–2 Minuten und aktualisieren Sie die Seite', + 672: 'Einrichtung abgeschlossen!', + 673: 'Sie können Ihr Backup-Laufwerk entfernen', + 674: 'Sie können Ihr Übertragungs-Laufwerk entfernen', + 675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.', + 676: 'Adressinformationen herunterladen', + 677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA', + 678: 'USB entfernt', + 679: 'Entfernen Sie das USB-Installationsmedium aus Ihrem Server', + 680: 'Server neu starten', + 681: 'Warten, bis der Server wieder online ist', + 682: 'Server ist wieder online', + 683: 'Starten Sie Ihren Server neu, um die Einrichtung abzuschließen', + 684: 'Lokale Adresse öffnen', + 685: 'Weiter zur Anmeldung', + 686: 'Zur StartOS-Anmeldeseite wechseln', + 687: 'Der Server ist nicht wieder online gegangen. Bitte überprüfen Sie Ihren Server und versuchen Sie, manuell darauf zuzugreifen.', + 691: 'Dieses Feld ist erforderlich', + 692: 'Verbindung fehlgeschlagen', + 693: 'Verbindung zum Netzwerkordner nicht möglich. Stellen Sie sicher, dass (1) der Zielcomputer mit dem LAN verbunden ist, (2) der Zielordner freigegeben ist und (3) Hostname, Pfad und Anmeldedaten korrekt sind.', + 694: 'Dieses Laufwerk enthält bestehende StartOS-Daten.', + 695: 'um Ihre Daten zu behalten.', + 696: 'um zu verwerfen', + 697: 'Geben Sie das Passwort ein, das zum Verschlüsseln dieses Backups verwendet wurde.', + 698: 'Mehrere Backups gefunden. Wählen Sie aus, welches wiederhergestellt werden soll.', + 699: 'Backups', + 700: 'Das Laufwerk, auf dem das StartOS-Betriebssystem installiert wird.', + 701: 'Das Laufwerk, auf dem Ihre StartOS-Daten (Dienste, Einstellungen usw.) gespeichert werden. Dies kann dasselbe wie das OS-Laufwerk oder ein separates Laufwerk sein.', + 702: 'Versuchen Sie nach der Datenübertragung von diesem Laufwerk nicht, erneut als Start9-Server davon zu booten. Dies kann zu Fehlfunktionen von Diensten, Datenbeschädigung oder Geldverlust führen.', + 703: 'Muss mindestens 12 Zeichen lang sein', + 704: 'Darf höchstens 64 Zeichen lang sein', + 705: 'Passwörter stimmen nicht überein', + 706: 'Beibehalten', + 707: 'Überschreiben', + 708: 'Entsperren', + 709: 'Laufwerk', + 710: 'Übertragen', + 711: 'Die Liste ist leer', + 712: 'Jetzt neu starten', + 713: 'Später', + 714: 'Installation abgeschlossen!', + 715: 'StartOS wurde erfolgreich installiert.', + 716: 'Weiter zur Einrichtung', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 0fe5fa9bb..a12f328e4 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -56,7 +56,7 @@ export const ENGLISH = { 'Beginning shutdown': 57, 'Add': 58, 'Ok': 59, - 'french': 60, + 'fr_FR': 60, 'This value cannot be changed once set': 61, 'Continue': 62, 'Click or drop file here': 63, @@ -98,6 +98,7 @@ export const ENGLISH = { 'You have unsaved changes. Are you sure you want to leave?': 101, 'Leave': 102, 'Are you sure?': 103, + 'New gateway': 104, // as in, a network gateway 'public': 108, 'private': 109, 'No Tor domains': 111, @@ -462,10 +463,10 @@ export const ENGLISH = { 'StartOS UI': 485, 'WiFi': 486, 'Documentation': 487, // as in, a website to view documentation - 'spanish': 488, - 'polish': 489, - 'german': 490, - 'english': 491, + 'es_ES': 488, + 'pl_PL': 489, + 'de_DE': 490, + 'en_US': 491, 'Start Menu': 492, 'Install Progress': 493, 'Downloading': 494, @@ -492,6 +493,7 @@ export const ENGLISH = { 'Recommended': 516, // as in, we recommend this 'Are you sure you want to dismiss this task?': 517, 'Dismiss': 518, // as in, dismiss or delete a task + 'Must be a valid IPv4 or Ipv6 address with optional port': 519, 'Update available': 520, 'To resolve the issue, refer to': 521, 'SDK version': 522, @@ -581,10 +583,10 @@ export const ENGLISH = { 'Reason': 612, // as in, an explanation for something 'Cannot disable private gateways for StartOS UI': 613, 'Root CA': 614, // as in, the unique, fixed-length digital identifier generated from a certificate's data using a cryptographic hash function - 'DHCP Servers': 615, - 'Cannot edit DHCP servers': 616, + 'Use the DNS servers provided by your router': 615, + 'Use DNS servers you specify manually': 616, 'Static': 617, // as in, unchanging - 'Static Servers': 618, // as in, servers that do not change + 'Servers': 618, // as in, computer servers 'Warning. StartOS is currently using the following gateway for DNS': 619, 'If you intend to use this gateway for private domain resolution, set alternative static DNS servers using the form above.': 620, 'Package a service': 621, // as in, package a software application for an operating system @@ -595,5 +597,87 @@ export const ENGLISH = { 'Upload': 626, // as in, upload a file 'Open UI': 627, // as in, upload a file 'Copied to clipboard': 628, - 'The list is empty': 629, -} as Record + // Setup wizard keys + 'Installing StartOS': 629, + 'Select your language': 630, + 'Select Keyboard Layout': 631, + 'Keyboard': 632, + 'Select Setup Flow': 633, + 'Start Fresh': 634, + 'Set up a brand new server': 635, + 'Restore from Backup': 636, + 'Restore StartOS data from an encrypted backup': 637, + 'Transfer data from an existing StartOS data drive': 638, + 'Select Drives': 639, // as in storage devices + 'No drives found. Please connect a drive and click Refresh.': 640, + 'OS Drive': 641, + 'Data Drive': 642, + 'StartOS Data Detected': 643, + 'Data on the OS drive may be overwritten.': 644, + 'your StartOS data on the data drive will be preserved.': 645, + 'Data on this drive will be overwritten.': 646, + 'Data on both drives will be overwritten.': 647, + 'Select Backup': 648, + 'Select the StartOS backup you want to restore': 649, + 'Open Network Backup': 650, // as in, a backup stored on a networked device + 'Physical Backups': 651, // as in, a backup stored on a physical drive + 'No physical backups': 652, + 'Connect Network Folder': 653, + 'Select Network Backup': 654, + 'Unlock Backup': 655, + 'Transfer Data': 656, + 'Select the drive containing your existing StartOS data': 657, + 'Select Drive': 658, + 'No StartOS data drives found': 659, + 'Set Master Password': 660, + 'Set New Password (Optional)': 661, + 'Make it good. Write it down.': 662, + 'Skip to keep your existing password.': 663, + 'Enter Password': 664, + 'Confirm Password': 665, + 'Finish': 666, + 'Starting setup': 667, + 'Wait 1-2 minutes and refresh the page': 670, + 'Setup Complete!': 672, + 'You can unplug your backup drive': 673, + 'You can unplug your transfer drive': 674, + 'http://start.local was for setup only. It will no longer work.': 675, + 'Download Address Info': 676, + "Contains your server's permanent local address and Root CA": 677, + 'USB Removed': 678, + 'Remove the USB installation media from your server': 679, + 'Restart Server': 680, + 'Waiting for server to come back online': 681, + 'Server is back online': 682, + 'Restart your server to complete setup': 683, + 'Open Local Address': 684, + 'Continue to Login': 685, + 'Proceed to the StartOS login screen': 686, + 'Server did not come back online. Please check your server and try accessing it manually.': 687, + 'This field is required': 691, + 'Connection Failed': 692, + 'Unable to connect to network folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.': 693, + 'This drive contains existing StartOS data.': 694, + 'to keep your data.': 695, + 'to discard': 696, + 'Enter the password that was used to encrypt this backup.': 697, + 'Multiple backups found. Select which one to restore.': 698, + 'Backups': 699, + 'The drive where the StartOS operating system will be installed.': 700, + 'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.': 701, + 'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.': 702, + 'Must be 12 characters or greater': 703, + 'Must be 64 character or less': 704, + 'Passwords do not match': 705, + 'Preserve': 706, + 'Overwrite': 707, + 'Unlock': 708, + 'Drive': 709, // the noun, a storage device + 'Transfer': 710, // the verb + 'The list is empty': 711, + 'Restart now': 712, + 'Later': 713, // as in, (do it) later + 'Installation Complete!': 714, + 'StartOS has been installed successfully.': 715, + 'Continue to Setup': 716, +} as const diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index b1022ff62..3233d1496 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -99,6 +99,7 @@ export default { 101: 'Tienes cambios no guardados. ¿Estás seguro de que deseas salir?', 102: 'Salir', 103: '¿Estás seguro?', + 104: 'Nueva puerta de enlace de red', 108: 'público', 109: 'privado', 111: 'Sin dominios onion', @@ -493,6 +494,7 @@ export default { 516: 'Recomendado', 517: '¿Estás seguro de que deseas descartar esta tarea?', 518: 'Descartar', + 519: 'Debe ser una dirección IPv4 o IPv6 válida con puerto opcional', 520: 'Actualización disponible', 521: 'Para resolver el problema, consulta', 522: 'Versión de SDK', @@ -582,10 +584,10 @@ export default { 612: 'Razón', 613: 'No se pueden deshabilitar las puertas de enlace privadas para la interfaz de usuario de StartOS', 614: 'CA raíz', - 615: 'Servidores DHCP', - 616: 'No se pueden editar los servidores DHCP', + 615: 'Usar los servidores DNS proporcionados por su router', + 616: 'Usar servidores DNS que especifique manualmente', 617: 'Estático', - 618: 'Servidores estáticos', + 618: 'Servidores', 619: 'Advertencia. StartOS está utilizando actualmente la siguiente puerta de enlace para DNS', 620: 'Si deseas usar esta puerta de enlace para la resolución de dominios privados, configura servidores DNS estáticos alternativos usando el formulario anterior.', 621: 'Empaquetar un servicio', @@ -596,5 +598,86 @@ export default { 626: 'Subir', 627: 'Abrir UI', 628: 'Copiado al portapapeles', - 629: 'La lista está vacía', + 629: 'Instalando StartOS', + 630: 'Selecciona tu idioma', + 631: 'Seleccionar distribución del teclado', + 632: 'Teclado', + 633: 'Seleccionar flujo de configuración', + 634: 'Empezar desde cero', + 635: 'Configurar un servidor completamente nuevo', + 636: 'Restaurar desde copia de seguridad', + 637: 'Restaurar datos de StartOS desde una copia de seguridad cifrada', + 638: 'Transferir datos desde una unidad de datos StartOS existente', + 639: 'Seleccionar unidades', + 640: 'No se encontraron unidades. Conecte una unidad y haga clic en Actualizar.', + 641: 'Unidad del sistema operativo', + 642: 'Unidad de datos', + 643: 'Datos de StartOS detectados', + 644: 'Los datos de la unidad del sistema operativo pueden sobrescribirse.', + 645: 'Sus datos de StartOS en la unidad de datos se conservarán.', + 646: 'Los datos de esta unidad se sobrescribirán.', + 647: 'Los datos de ambas unidades se sobrescribirán.', + 648: 'Seleccionar copia de seguridad', + 649: 'Seleccione la copia de seguridad de StartOS que desea restaurar', + 650: 'Abrir copia de seguridad de red', + 651: 'Copias de seguridad físicas', + 652: 'No hay copias de seguridad físicas', + 653: 'Conectar carpeta de red', + 654: 'Seleccionar copia de seguridad de red', + 655: 'Desbloquear copia de seguridad', + 656: 'Transferir datos', + 657: 'Seleccione la unidad que contiene sus datos StartOS existentes', + 658: 'Seleccionar unidad', + 659: 'No se encontraron unidades de datos StartOS', + 660: 'Establecer contraseña maestra', + 661: 'Establecer nueva contraseña (opcional)', + 662: 'Que sea buena. Escríbala.', + 663: 'Omitir para mantener su contraseña existente.', + 664: 'Introducir contraseña', + 665: 'Confirmar contraseña', + 666: 'Finalizar', + 667: 'Iniciando configuración', + 670: 'Espere 1–2 minutos y actualice la página', + 672: '¡Configuración completa!', + 673: 'Puede desconectar su unidad de copia de seguridad', + 674: 'Puede desconectar su unidad de transferencia', + 675: 'http://start.local era solo para la configuración. Ya no funcionará.', + 676: 'Descargar información de direcciones', + 677: 'Contiene la dirección local permanente de su servidor y la CA raíz', + 678: 'USB retirado', + 679: 'Retire el medio de instalación USB de su servidor', + 680: 'Reiniciar servidor', + 681: 'Esperando a que el servidor vuelva a estar en línea', + 682: 'El servidor ha vuelto a estar en línea', + 683: 'Reinicie su servidor para completar la configuración', + 684: 'Abrir dirección local', + 685: 'Continuar al inicio de sesión', + 686: 'Ir a la pantalla de inicio de sesión de StartOS', + 687: 'El servidor no volvió a estar en línea. Verifique su servidor e intente acceder manualmente.', + 691: 'Este campo es obligatorio', + 692: 'Conexión fallida', + 693: 'No se pudo conectar a la carpeta de red. Asegúrese de que (1) el equipo de destino esté conectado a la LAN, (2) la carpeta de destino esté compartida y (3) el nombre de host, la ruta y las credenciales sean correctos.', + 694: 'Esta unidad contiene datos existentes de StartOS.', + 695: 'para conservar sus datos.', + 696: 'para descartar', + 697: 'Introduzca la contraseña que se utilizó para cifrar esta copia de seguridad.', + 698: 'Se encontraron varias copias de seguridad. Seleccione cuál restaurar.', + 699: 'Copias de seguridad', + 700: 'La unidad donde se instalará el sistema operativo StartOS.', + 701: 'La unidad donde se almacenarán sus datos de StartOS (servicios, ajustes, etc.). Puede ser la misma que la unidad del sistema operativo o una unidad separada.', + 702: 'Después de transferir datos desde esta unidad, no intente arrancar desde ella nuevamente como un servidor Start9. Esto puede provocar fallos en los servicios, corrupción de datos o pérdida de fondos.', + 703: 'Debe tener 12 caracteres o más', + 704: 'Debe tener 64 caracteres o menos', + 705: 'Las contraseñas no coinciden', + 706: 'Conservar', + 707: 'Sobrescribir', + 708: 'Desbloquear', + 709: 'Unidad', + 710: 'Transferir', + 711: 'La lista está vacía', + 712: 'Reiniciar ahora', + 713: 'Más tarde', + 714: '¡Instalación completada!', + 715: 'StartOS se ha instalado correctamente.', + 716: 'Continuar con la configuración', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index aa3cce393..fc86d901c 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -99,6 +99,7 @@ export default { 101: 'Vous avez des modifications non enregistrées. Voulez-vous vraiment quitter ?', 102: 'Quitter', 103: 'Êtes-vous sûr ?', + 104: 'Nouvelle passerelle réseau', 108: 'public', 109: 'privé', 111: 'Aucune domaine onion', @@ -493,6 +494,7 @@ export default { 516: 'Recommandé', 517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?', 518: 'Ignorer', + 519: 'Doit être une adresse IPv4 ou IPv6 valide avec un port facultatif', 520: 'Mise à jour disponible', 521: 'Pour résoudre le problème, consultez', 522: 'Version de SDK', @@ -582,10 +584,10 @@ export default { 612: 'Raison', 613: "Impossible de désactiver les passerelles privées pour l'interface utilisateur StartOS", 614: 'CA racine', - 615: 'Serveurs DHCP', - 616: 'Impossible de modifier les serveurs DHCP', + 615: 'Utiliser les serveurs DNS fournis par votre routeur', + 616: 'Utiliser des serveurs DNS que vous spécifiez manuellement', 617: 'Statique', - 618: 'Serveurs statiques', + 618: 'Serveurs', 619: 'Avertissement. StartOS utilise actuellement la passerelle suivante pour le DNS', 620: 'Si vous souhaitez utiliser cette passerelle pour la résolution de domaines privés, définissez des serveurs DNS statiques alternatifs à l’aide du formulaire ci-dessus.', 621: 'Emballer un service', @@ -596,5 +598,86 @@ export default { 626: 'Téléverser', 627: 'Ouvrir UI', 628: 'Copié dans le presse-papiers', - 629: 'La liste est vide', + 629: 'Installation de StartOS', + 630: 'Sélectionnez votre langue', + 631: 'Sélectionner la disposition du clavier', + 632: 'Clavier', + 633: 'Sélectionner le mode de configuration', + 634: 'Démarrer à neuf', + 635: 'Configurer un tout nouveau serveur', + 636: 'Restaurer à partir d’une sauvegarde', + 637: 'Restaurer les données StartOS à partir d’une sauvegarde chiffrée', + 638: 'Transférer les données depuis un disque de données StartOS existant', + 639: 'Sélectionner les disques', + 640: 'Aucun disque trouvé. Veuillez connecter un disque et cliquer sur Actualiser.', + 641: 'Disque du système', + 642: 'Disque de données', + 643: 'Données StartOS détectées', + 644: 'Les données du disque système peuvent être écrasées.', + 645: 'Vos données StartOS sur le disque de données seront conservées.', + 646: 'Les données de ce disque seront écrasées.', + 647: 'Les données des deux disques seront écrasées.', + 648: 'Sélectionner une sauvegarde', + 649: 'Sélectionnez la sauvegarde StartOS que vous souhaitez restaurer', + 650: 'Ouvrir une sauvegarde réseau', + 651: 'Sauvegardes physiques', + 652: 'Aucune sauvegarde physique', + 653: 'Connecter un dossier réseau', + 654: 'Sélectionner une sauvegarde réseau', + 655: 'Déverrouiller la sauvegarde', + 656: 'Transférer les données', + 657: 'Sélectionnez le disque contenant vos données StartOS existantes', + 658: 'Sélectionner le disque', + 659: 'Aucun disque de données StartOS trouvé', + 660: 'Définir le mot de passe maître', + 661: 'Définir un nouveau mot de passe (facultatif)', + 662: 'Choisissez-le bien. Notez-le.', + 663: 'Ignorer pour conserver votre mot de passe existant.', + 664: 'Saisir le mot de passe', + 665: 'Confirmer le mot de passe', + 666: 'Terminer', + 667: 'Démarrage de la configuration', + 670: 'Attendez 1 à 2 minutes puis actualisez la page', + 672: 'Configuration terminée !', + 673: 'Vous pouvez débrancher votre disque de sauvegarde', + 674: 'Vous pouvez débrancher votre disque de transfert', + 675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.', + 676: 'Télécharger les informations d’adresse', + 677: 'Contient l’adresse locale permanente de votre serveur et la CA racine', + 678: 'USB retiré', + 679: 'Retirez le support d’installation USB de votre serveur', + 680: 'Redémarrer le serveur', + 681: 'En attente du retour en ligne du serveur', + 682: 'Le serveur est de nouveau en ligne', + 683: 'Redémarrez votre serveur pour terminer la configuration', + 684: 'Ouvrir l’adresse locale', + 685: 'Continuer vers la connexion', + 686: 'Accéder à l’écran de connexion StartOS', + 687: 'Le serveur n’est pas revenu en ligne. Veuillez vérifier votre serveur et essayer d’y accéder manuellement.', + 691: 'Ce champ est requis', + 692: 'Échec de la connexion', + 693: 'Impossible de se connecter au dossier réseau. Assurez-vous que (1) l’ordinateur cible est connecté au LAN, (2) le dossier cible est partagé et (3) le nom d’hôte, le chemin et les identifiants sont corrects.', + 694: 'Ce disque contient des données StartOS existantes.', + 695: 'pour conserver vos données.', + 696: 'pour supprimer', + 697: 'Saisissez le mot de passe utilisé pour chiffrer cette sauvegarde.', + 698: 'Plusieurs sauvegardes trouvées. Sélectionnez celle à restaurer.', + 699: 'Sauvegardes', + 700: 'Le disque sur lequel le système d’exploitation StartOS sera installé.', + 701: 'Le disque sur lequel vos données StartOS (services, paramètres, etc.) seront stockées. Il peut s’agir du même disque que le système ou d’un disque séparé.', + 702: 'Après le transfert des données depuis ce disque, n’essayez pas de démarrer dessus à nouveau en tant que serveur Start9. Cela peut entraîner des dysfonctionnements des services, une corruption des données ou une perte de fonds.', + 703: 'Doit comporter au moins 12 caractères', + 704: 'Doit comporter au maximum 64 caractères', + 705: 'Les mots de passe ne correspondent pas', + 706: 'Conserver', + 707: 'Écraser', + 708: 'Déverrouiller', + 709: 'Disque', + 710: 'Transférer', + 711: 'La liste est vide', + 712: 'Redémarrer maintenant', + 713: 'Plus tard', + 714: 'Installation terminée !', + 715: 'StartOS a été installé avec succès.', + 716: 'Continuer vers la configuration', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index e39fdb756..3911af7bb 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -99,6 +99,7 @@ export default { 101: 'Masz niezapisane zmiany. Czy na pewno chcesz opuścić tę stronę?', 102: 'Opuść', 103: 'Czy jesteś pewien?', + 104: 'Nowa brama sieciowa', 108: 'publiczny', 109: 'prywatny', 111: 'Brak domeny onion', @@ -493,6 +494,7 @@ export default { 516: 'Zalecane', 517: 'Czy na pewno chcesz odrzucić to zadanie?', 518: 'Odrzuć', + 519: 'Musi być prawidłowym adresem IPv4 lub IPv6 z opcjonalnym portem', 520: 'Aktualizacja dostępna', 521: 'Aby rozwiązać problem, zapoznaj się z', 522: 'Wersja SDK', @@ -582,10 +584,10 @@ export default { 612: 'Powód', 613: 'Nie można wyłączyć prywatnych bram dla interfejsu użytkownika StartOS', 614: 'głównego CA', - 615: 'Serwery DHCP', - 616: 'Nie można edytować serwerów DHCP', + 615: 'Użyj serwerów DNS dostarczonych przez router', + 616: 'Użyj serwerów DNS określonych ręcznie', 617: 'Statyczny', - 618: 'Serwery statyczne', + 618: 'Serwery', 619: 'Ostrzeżenie. StartOS obecnie używa następującej bramy do DNS', 620: 'Jeśli zamierzasz używać tej bramy do rozwiązywania domen prywatnych, ustaw alternatywne statyczne serwery DNS za pomocą powyższego formularza.', 621: 'Spakietuj usługę', @@ -596,5 +598,86 @@ export default { 626: 'Prześlij', 627: 'Otwórz UI', 628: 'Skopiowano do schowka', - 629: 'Lista jest pusta', + 629: 'Instalowanie StartOS', + 630: 'Wybierz swój język', + 631: 'Wybierz układ klawiatury', + 632: 'Klawiatura', + 633: 'Wybierz tryb konfiguracji', + 634: 'Rozpocznij od nowa', + 635: 'Skonfiguruj zupełnie nowy serwer', + 636: 'Przywróć z kopii zapasowej', + 637: 'Przywróć dane StartOS z zaszyfrowanej kopii zapasowej', + 638: 'Przenieś dane z istniejącego dysku danych StartOS', + 639: 'Wybierz dyski', + 640: 'Nie znaleziono dysków. Podłącz dysk i kliknij Odśwież.', + 641: 'Dysk systemowy', + 642: 'Dysk danych', + 643: 'Wykryto dane StartOS', + 644: 'Dane na dysku systemowym mogą zostać nadpisane.', + 645: 'Twoje dane StartOS na dysku danych zostaną zachowane.', + 646: 'Dane na tym dysku zostaną nadpisane.', + 647: 'Dane na obu dyskach zostaną nadpisane.', + 648: 'Wybierz kopię zapasową', + 649: 'Wybierz kopię zapasową StartOS do przywrócenia', + 650: 'Otwórz kopię zapasową sieciową', + 651: 'Kopie zapasowe fizyczne', + 652: 'Brak fizycznych kopii zapasowych', + 653: 'Połącz folder sieciowy', + 654: 'Wybierz kopię zapasową sieciową', + 655: 'Odblokuj kopię zapasową', + 656: 'Przenieś dane', + 657: 'Wybierz dysk zawierający istniejące dane StartOS', + 658: 'Wybierz dysk', + 659: 'Nie znaleziono dysków danych StartOS', + 660: 'Ustaw hasło główne', + 661: 'Ustaw nowe hasło (opcjonalnie)', + 662: 'Zadbaj o nie. Zapisz je.', + 663: 'Pomiń, aby zachować istniejące hasło.', + 664: 'Wprowadź hasło', + 665: 'Potwierdź hasło', + 666: 'Zakończ', + 667: 'Rozpoczynanie konfiguracji', + 670: 'Poczekaj 1–2 minuty i odśwież stronę', + 672: 'Konfiguracja zakończona!', + 673: 'Możesz odłączyć dysk kopii zapasowej', + 674: 'Możesz odłączyć dysk transferowy', + 675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.', + 676: 'Pobierz informacje adresowe', + 677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)', + 678: 'USB usunięty', + 679: 'Usuń instalacyjny nośnik USB z serwera', + 680: 'Uruchom ponownie serwer', + 681: 'Oczekiwanie na ponowne połączenie serwera', + 682: 'Serwer jest ponownie online', + 683: 'Uruchom ponownie serwer, aby zakończyć konfigurację', + 684: 'Otwórz adres lokalny', + 685: 'Przejdź do logowania', + 686: 'Przejdź do ekranu logowania StartOS', + 687: 'Serwer nie wrócił do trybu online. Sprawdź serwer i spróbuj uzyskać do niego dostęp ręcznie.', + 691: 'To pole jest wymagane', + 692: 'Nie udało się połączyć', + 693: 'Nie można połączyć się z folderem sieciowym. Upewnij się, że (1) komputer docelowy jest podłączony do sieci LAN, (2) folder docelowy jest udostępniony oraz (3) nazwa hosta, ścieżka i dane logowania są poprawne.', + 694: 'Ten dysk zawiera istniejące dane StartOS.', + 695: 'aby zachować dane.', + 696: 'aby odrzucić', + 697: 'Wprowadź hasło użyte do zaszyfrowania tej kopii zapasowej.', + 698: 'Znaleziono wiele kopii zapasowych. Wybierz, którą przywrócić.', + 699: 'Kopie zapasowe', + 700: 'Dysk, na którym zostanie zainstalowany system operacyjny StartOS.', + 701: 'Dysk, na którym będą przechowywane dane StartOS (usługi, ustawienia itp.). Może to być ten sam dysk co systemowy lub oddzielny dysk.', + 702: 'Po przeniesieniu danych z tego dysku nie próbuj ponownie uruchamiać z niego systemu jako serwer Start9. Może to spowodować nieprawidłowe działanie usług, uszkodzenie danych lub utratę środków.', + 703: 'Musi mieć co najmniej 12 znaków', + 704: 'Musi mieć maksymalnie 64 znaki', + 705: 'Hasła nie są zgodne', + 706: 'Zachowaj', + 707: 'Nadpisz', + 708: 'Odblokuj', + 709: 'Dysk', + 710: 'Przenieś', + 711: 'Lista jest pusta', + 712: 'Uruchom ponownie teraz', + 713: 'Później', + 714: 'Instalacja zakończona!', + 715: 'StartOS został pomyślnie zainstalowany.', + 716: 'Przejdź do konfiguracji', } satisfies i18n diff --git a/web/projects/shared/src/i18n/i18n.providers.ts b/web/projects/shared/src/i18n/i18n.providers.ts index cee7f1aa9..5aa38998c 100644 --- a/web/projects/shared/src/i18n/i18n.providers.ts +++ b/web/projects/shared/src/i18n/i18n.providers.ts @@ -6,7 +6,7 @@ import { TuiLanguageSwitcherService, } from '@taiga-ui/i18n' import { ENGLISH } from './dictionaries/en' -import { i18nService } from './i18n.service' +import { i18nService, Languages } from './i18n.service' export type i18nKey = keyof typeof ENGLISH export type i18n = Record<(typeof ENGLISH)[i18nKey], string> @@ -20,7 +20,7 @@ export const I18N_LOADER = new InjectionToken< >('') export const I18N_STORAGE = new InjectionToken< - (lang: TuiLanguageName) => Promise + (lang: Languages) => Promise >('', { factory: () => () => Promise.resolve(), }) diff --git a/web/projects/shared/src/i18n/i18n.service.ts b/web/projects/shared/src/i18n/i18n.service.ts index b627cddbf..375e941bb 100644 --- a/web/projects/shared/src/i18n/i18n.service.ts +++ b/web/projects/shared/src/i18n/i18n.service.ts @@ -1,6 +1,21 @@ import { inject, Injectable, signal } from '@angular/core' import { TuiLanguageName, TuiLanguageSwitcherService } from '@taiga-ui/i18n' import { I18N, I18N_LOADER, I18N_STORAGE } from './i18n.providers' +import { T } from '@start9labs/start-sdk' + +export const languages = ['en_US', 'es_ES', 'de_DE', 'fr_FR', 'pl_PL'] as const +export type Languages = (typeof languages)[number] + +/** + * Maps POSIX locale strings to TUI language names + */ +export const LANGUAGE_TO_TUI: Record = { + en_US: 'english', + es_ES: 'spanish', + de_DE: 'german', + fr_FR: 'french', + pl_PL: 'polish', +} @Injectable({ providedIn: 'root', @@ -12,20 +27,40 @@ export class i18nService extends TuiLanguageSwitcherService { readonly loading = signal(false) - override setLanguage(language: TuiLanguageName = 'english'): void { + /** + * Current language as POSIX locale string + */ + get lang(): Languages { + return ( + (Object.entries(LANGUAGE_TO_TUI).find( + ([, tui]) => tui === this.language, + )?.[0] as Languages) || 'en_US' + ) + } + + localize(string: T.LocaleString): string { + if (typeof string === 'string') return string + + return ( + string[this.lang] ?? string['en_US'] ?? Object.values(string)[0] ?? '' + ) + } + + setLang(language: Languages = 'en_US'): void { + const tuiLang = LANGUAGE_TO_TUI[language] const current = this.language - super.setLanguage(language) + super.setLanguage(tuiLang) this.loading.set(true) - if (current === language) { - this.i18nLoader(language).then(value => { + if (current === tuiLang) { + this.i18nLoader(tuiLang).then(value => { this.i18n.set(value) this.loading.set(false) }) } else { this.store(language).then(() => - this.i18nLoader(language).then(value => { + this.i18nLoader(tuiLang).then(value => { this.i18n.set(value) this.loading.set(false) }), @@ -33,12 +68,3 @@ export class i18nService extends TuiLanguageSwitcherService { } } } - -export const languages = [ - 'english', - 'spanish', - 'polish', - 'german', - 'french', -] as const -export type Languages = (typeof languages)[number] diff --git a/web/projects/shared/src/i18n/localize.pipe.ts b/web/projects/shared/src/i18n/localize.pipe.ts new file mode 100644 index 000000000..e9f439ec0 --- /dev/null +++ b/web/projects/shared/src/i18n/localize.pipe.ts @@ -0,0 +1,16 @@ +import { inject, Injectable, Pipe, PipeTransform } from '@angular/core' +import { i18nService } from './i18n.service' +import { T } from '@start9labs/start-sdk' + +@Pipe({ + name: 'localize', + pure: false, +}) +@Injectable({ providedIn: 'root' }) +export class LocalizePipe implements PipeTransform { + private readonly i18nService = inject(i18nService) + + transform(string: T.LocaleString): string { + return this.i18nService.localize(string) + } +} diff --git a/web/projects/shared/src/public-api.ts b/web/projects/shared/src/public-api.ts index cab15d62c..c3c683984 100644 --- a/web/projects/shared/src/public-api.ts +++ b/web/projects/shared/src/public-api.ts @@ -8,7 +8,6 @@ export * from './classes/rpc-error' export * from './components/initializing/logs-window.component' export * from './components/initializing/initializing.component' export * from './components/ticker.component' -export * from './components/drive.component' export * from './components/markdown.component' export * from './components/prompt.component' export * from './components/server.component' @@ -19,6 +18,7 @@ export * from './directives/safe-links.directive' export * from './i18n/i18n.pipe' export * from './i18n/i18n.providers' export * from './i18n/i18n.service' +export * from './i18n/localize.pipe' export * from './pipes/exver/exver.module' export * from './pipes/exver/exver.pipe' @@ -59,3 +59,5 @@ export * from './util/rpc.util' export * from './util/to-guid' export * from './util/to-local-iso-string' export * from './util/unused' +export * from './util/keyboards' +export * from './util/languages' diff --git a/web/projects/shared/src/types/workspace-config.ts b/web/projects/shared/src/types/workspace-config.ts index 5f5f6601d..ebc678ccd 100644 --- a/web/projects/shared/src/types/workspace-config.ts +++ b/web/projects/shared/src/types/workspace-config.ts @@ -10,7 +10,7 @@ export type AccessType = export type WorkspaceConfig = { gitHash: string useMocks: boolean - // each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard + // each key corresponds to a project and values adjust settings for that project, eg: ui, setup-wizard ui: { api: { url: string diff --git a/web/projects/shared/src/util/keyboards.ts b/web/projects/shared/src/util/keyboards.ts new file mode 100644 index 000000000..de8688564 --- /dev/null +++ b/web/projects/shared/src/util/keyboards.ts @@ -0,0 +1,97 @@ +import { LanguageCode } from './languages' + +/** + * Keyboard layout codes (X11/Wayland) + */ +export type KeyboardLayout = 'us' | 'gb' | 'es' | 'latam' | 'de' | 'fr' | 'pl' + +/** + * Keyboard keymap codes (console/TTY) + */ +export type KeyboardKeymap = 'us' | 'uk' | 'es' | 'la' | 'de' | 'fr' | 'pl' + +/** + * Keyboard layout display names + */ +export type KeyboardName = + | 'US English' + | 'UK English' + | 'Spanish' + | 'Latin American' + | 'German' + | 'French' + | 'Polish' + +/** + * Keyboard definition with layout and keymap + */ +export interface Keyboard { + layout: KeyboardLayout + keymap: KeyboardKeymap + name: KeyboardName +} + +/** + * Full keyboard configuration for backend API + */ +export interface FullKeyboard { + layout: KeyboardLayout + keymap: KeyboardKeymap + model: string | null + variant: string | null + options: string[] +} + +/** + * Keyboard layouts grouped by language code + */ +export const KEYBOARDS_BY_LANGUAGE: Record = { + en: [ + { layout: 'us', keymap: 'us', name: 'US English' }, + { layout: 'gb', keymap: 'uk', name: 'UK English' }, + ], + es: [ + { layout: 'es', keymap: 'es', name: 'Spanish' }, + { layout: 'latam', keymap: 'la', name: 'Latin American' }, + ], + de: [{ layout: 'de', keymap: 'de', name: 'German' }], + fr: [{ layout: 'fr', keymap: 'fr', name: 'French' }], + pl: [{ layout: 'pl', keymap: 'pl', name: 'Polish' }], +} + +/** + * All available keyboard layouts + */ +export const ALL_KEYBOARDS: Keyboard[] = [ + { layout: 'us', keymap: 'us', name: 'US English' }, + { layout: 'gb', keymap: 'uk', name: 'UK English' }, + { layout: 'es', keymap: 'es', name: 'Spanish' }, + { layout: 'latam', keymap: 'la', name: 'Latin American' }, + { layout: 'de', keymap: 'de', name: 'German' }, + { layout: 'fr', keymap: 'fr', name: 'French' }, + { layout: 'pl', keymap: 'pl', name: 'Polish' }, +] + +/** + * Get all keyboards sorted with language-specific keyboards first, + * then remaining keyboards alphabetically by name. + */ +export function getAllKeyboardsSorted(languageCode: LanguageCode): Keyboard[] { + const languageKeyboards = KEYBOARDS_BY_LANGUAGE[languageCode] + const languageLayouts = new Set(languageKeyboards.map(kb => kb.layout)) + const otherKeyboards = ALL_KEYBOARDS.filter( + kb => !languageLayouts.has(kb.layout), + ).sort((a, b) => a.name.localeCompare(b.name)) + return [...languageKeyboards, ...otherKeyboards] +} + +/** + * Get the display name for a keyboard layout. + */ +export function getKeyboardName( + layout: KeyboardLayout | string, +): KeyboardName | string { + const keyboard = ALL_KEYBOARDS.find(kb => kb.layout === layout) + if (keyboard) return keyboard.name + return layout // fallback to the layout itself if not found +} diff --git a/web/projects/shared/src/util/languages.ts b/web/projects/shared/src/util/languages.ts new file mode 100644 index 000000000..baf1e03a4 --- /dev/null +++ b/web/projects/shared/src/util/languages.ts @@ -0,0 +1,44 @@ +import { Languages } from '../i18n/i18n.service' + +/** + * ISO language codes + */ +export type LanguageCode = 'en' | 'es' | 'de' | 'fr' | 'pl' + +/** + * Language definition with metadata + */ +export interface Language { + code: LanguageCode + name: Languages + nativeName: string +} + +/** + * Available languages with their metadata + */ +export const LANGUAGES: Language[] = [ + { code: 'en', name: 'en_US', nativeName: 'English' }, + { code: 'es', name: 'es_ES', nativeName: 'Español' }, + { code: 'de', name: 'de_DE', nativeName: 'Deutsch' }, + { code: 'fr', name: 'fr_FR', nativeName: 'Français' }, + { code: 'pl', name: 'pl_PL', nativeName: 'Polski' }, +] + +/** + * Maps POSIX locale strings to ISO language codes + */ +export const LANGUAGE_TO_CODE: Record = { + en_US: 'en', + es_ES: 'es', + de_DE: 'de', + fr_FR: 'fr', + pl_PL: 'pl', +} + +/** + * Params for setting language via API + */ +export interface SetLanguageParams { + language: Languages +} diff --git a/web/projects/ui/src/app/app.component.ts b/web/projects/ui/src/app/app.component.ts index 0e9c25734..1ef65c6fb 100644 --- a/web/projects/ui/src/app/app.component.ts +++ b/web/projects/ui/src/app/app.component.ts @@ -39,9 +39,9 @@ export class AppComponent { .subscribe() readonly ui = inject>(PatchDB) - .watch$('ui', 'language') + .watch$('serverInfo', 'language') .pipe(takeUntilDestroyed()) .subscribe(language => { - this.i18n.setLanguage(language || 'english') + this.i18n.setLang(language || 'en_US') }) } diff --git a/web/projects/ui/src/app/app.providers.ts b/web/projects/ui/src/app/app.providers.ts index e2db99464..7528b2305 100644 --- a/web/projects/ui/src/app/app.providers.ts +++ b/web/projects/ui/src/app/app.providers.ts @@ -1,7 +1,7 @@ import { inject, provideAppInitializer } from '@angular/core' import { UntypedFormBuilder } from '@angular/forms' import { provideAnimations } from '@angular/platform-browser/animations' -import { Router } from '@angular/router' +import { ActivationStart, Router } from '@angular/router' import { WA_LOCATION } from '@ng-web-apis/common' import initArgon from '@start9labs/argon2' import { @@ -12,6 +12,7 @@ import { I18N_PROVIDERS, I18N_STORAGE, i18nService, + Languages, RELATIVE_URL, VERSION, WorkspaceConfig, @@ -32,7 +33,7 @@ import { TUI_DATE_VALUE_TRANSFORMER, } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' -import { filter, identity, of, pairwise } from 'rxjs' +import { filter, identity, merge, of, pairwise } from 'rxjs' import { ConfigService } from 'src/app/services/config.service' import { PATCH_CACHE, @@ -115,11 +116,15 @@ export const APP_PROVIDERS = [ { provide: TUI_DIALOGS_CLOSE, useFactory: () => - inject(StateService).pipe( - pairwise(), - filter( - ([prev, curr]) => - prev === 'running' && (curr === 'error' || curr === 'initializing'), + merge( + inject(Router).events.pipe(filter(e => e instanceof ActivationStart)), + inject(StateService).pipe( + pairwise(), + filter( + ([prev, curr]) => + prev === 'running' && + (curr === 'error' || curr === 'initializing'), + ), ), ), }, @@ -128,7 +133,7 @@ export const APP_PROVIDERS = [ useFactory: () => { const api = inject(ApiService) - return (language: string) => api.setDbValue(['language'], language) + return (language: Languages) => api.setLanguage({ language }) }, }, { diff --git a/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts b/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts index 7c2bce126..de6af9ba0 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts @@ -1,5 +1,6 @@ import { Component, inject } from '@angular/core' import { FormsModule } from '@angular/forms' +import { Router, RouterLink } from '@angular/router' import { invert } from '@start9labs/shared' import { IST } from '@start9labs/start-sdk' import { TUI_IS_MOBILE } from '@taiga-ui/cdk' @@ -36,6 +37,7 @@ import { HintPipe } from '../pipes/hint.pipe' [placeholder]="spec.name" [items]="items" [(ngModel)]="selected" + (ngModelChange)="onChange($event)" > } @else { @for (item of items; track item) { - + @if (inverted[item]?.startsWith('~')) { + + {{ item }} + + } @else { + + } } } @@ -70,6 +84,7 @@ import { HintPipe } from '../pipes/hint.pipe' providers: [tuiFluidTypographyOptionsProvider({ max: 1 })], imports: [ FormsModule, + RouterLink, TuiTextfield, TuiSelect, TuiDataList, @@ -81,8 +96,8 @@ import { HintPipe } from '../pipes/hint.pipe' ], }) export class FormSelectComponent extends Control { - private readonly inverted = invert(this.spec.values) - + protected readonly router = inject(Router) + protected readonly inverted = invert(this.spec.values) protected readonly mobile = inject(TUI_IS_MOBILE) protected readonly items = Object.values(this.spec.values) protected readonly disabledItemHandler = (item: string) => @@ -101,4 +116,12 @@ export class FormSelectComponent extends Control { set selected(value: string | null) { this.value = (value && this.inverted[value]) || null } + + protected onChange(value: string) { + const mapped = this.inverted[value] + + if (typeof mapped === 'string' && mapped.startsWith('~')) { + this.router.navigate([mapped.slice(1)]) + } + } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts index ef602aa7a..2991c8bf8 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts @@ -247,10 +247,10 @@ export class PublicDomainService { ), values: gateways.reduce>( (obj, gateway) => ({ - ...obj, [gateway.id]: gateway.name || gateway.ipInfo.name, + ...obj, }), - {}, + { '~/system/gateways': this.i18n.transform('New gateway') }, ), default: '', disabled: gateways diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts index a72f53feb..142ef126d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts @@ -14,7 +14,7 @@ import { Exver, ExverPipesModule, i18nPipe, - isEmptyObject, + i18nService, LoadingService, sameUrl, } from '@start9labs/shared' @@ -123,6 +123,7 @@ export class MarketplaceControlsComponent { private readonly router = inject(Router) private readonly marketplace = inject(MarketplaceService) private readonly api = inject(ApiService) + private readonly i18n = inject(i18nService) readonly pkg = input.required>() @@ -149,7 +150,7 @@ export class MarketplaceControlsComponent { const originalUrl = localPkg?.registry || null if (!localPkg) { - if (await this.alerts.alertInstall(this.pkg().alerts.install || '')) { + if (await this.alerts.alertInstall(this.i18n.localize(this.pkg().alerts.install || ''))) { this.installOrUpload(currentUrl) } return @@ -184,10 +185,7 @@ export class MarketplaceControlsComponent { const packages = await getAllPackages(this.patch) const breakages = dryUpdate({ id, version }, packages, this.exver) - if ( - isEmptyObject(breakages) || - (await this.alerts.alertBreakages(breakages)) - ) { + if (!breakages.length || (await this.alerts.alertBreakages(breakages))) { this.installOrUpload(url) } } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts index 63a3928c6..58e8170e8 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts @@ -9,7 +9,7 @@ import { i18nPipe, LoadingService, } from '@start9labs/shared' -import { ISB } from '@start9labs/start-sdk' +import { ISB, utils } from '@start9labs/start-sdk' import { TuiButton, TuiTitle } from '@taiga-ui/core' import { TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' @@ -21,6 +21,14 @@ import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +// IPv4 +const ipv4 = + /(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/ + +// IPv6 (your existing pattern) +const ipv6 = + /(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/ + @Component({ template: ` @@ -111,17 +119,11 @@ export default class SystemDnsComponent { strategy: ISB.Value.union({ name: 'strategy', default: 'dhcp', + description: `
  • DHCP: ${this.i18n.transform('Use the DNS servers provided by your router')}
  • ${this.i18n.transform('Static')}: ${this.i18n.transform('Use DNS servers you specify manually')}
`, variants: ISB.Variants.of({ dhcp: { name: 'DHCP', - spec: ISB.InputSpec.of({ - servers: ISB.Value.dynamicText(() => ({ - name: this.i18n.transform('DHCP Servers'), - default: null, - required: true, - disabled: this.i18n.transform('Cannot edit DHCP servers'), - })), - }), + spec: ISB.InputSpec.of({}), }, static: { name: this.i18n.transform('Static'), @@ -129,11 +131,21 @@ export default class SystemDnsComponent { servers: ISB.Value.list( ISB.List.text( { - name: this.i18n.transform('Static Servers'), + name: this.i18n.transform('Servers'), minLength: 1, maxLength: 3, }, - { placeholder: '1.1.1.1' }, + { + placeholder: '1.1.1.1', + patterns: [ + { + regex: `^(${ipv4.source}(:\\d{1,5})?|${ipv6.source}|\\[${ipv6.source}\\](:\\d{1,5})?)$`, + description: this.i18n.transform( + 'Must be a valid IPv4 or Ipv6 address with optional port', + ), + }, + ], + }, ), ), }), diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index 14cc377fd..aaff817d0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -12,15 +12,20 @@ import { RouterLink } from '@angular/router' import { DialogService, ErrorService, + getAllKeyboardsSorted, + getKeyboardName, i18nKey, i18nPipe, i18nService, - languages, - Languages, + Keyboard, + KeyboardLayout, + Language, + LANGUAGES, + LANGUAGE_TO_CODE, LoadingService, } from '@start9labs/shared' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' -import { TuiAnimated, TuiContext, TuiStringHandler } from '@taiga-ui/cdk' +import { TuiAnimated } from '@taiga-ui/cdk' import { TuiAppearance, TuiButton, @@ -49,6 +54,7 @@ import { TitleDirective } from 'src/app/services/title.service' import { SnekDirective } from './snek.directive' import { UPDATE } from './update.component' import { SystemWipeComponent } from './wipe.component' +import { KeyboardSelectComponent } from './keyboard-select.component' @Component({ template: ` @@ -104,20 +110,16 @@ import { SystemWipeComponent } from './wipe.component' {{ 'Language' | i18n }} - - @if (language; as lang) { - {{ lang | i18n }} - } @else { - {{ i18nService.language }} - } + + {{ currentLanguage?.nativeName }} + + {{ item.nativeName }} +
{{ 'Kiosk Mode' | i18n }} - + {{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }} - - {{ - server.kiosk === true - ? ('Disable Kiosk Mode unless you need to attach a monitor' - | i18n) - : server.kiosk === false - ? ('Enable Kiosk Mode if you need to attach a monitor' | i18n) - : ('Kiosk Mode is unavailable on this device' | i18n) - }} + + @if (server.kiosk === null) { + {{ 'Kiosk Mode is unavailable on this device' | i18n }} + } @else { + {{ + server.kiosk + ? ('Disable Kiosk Mode unless you need to attach a monitor' + | i18n) + : ('Enable Kiosk Mode if you need to attach a monitor' | i18n) + }} + } + @if (server.kiosk !== null && server.keyboard?.layout; as layout) { + + + {{ getKeyboardName(layout) }} + + + } @if (server.kiosk !== null) { + + + `, + styles: ` + p { + margin-bottom: 1rem; + } + + footer { + display: flex; + gap: 1rem; + margin-top: 1.5rem; + } + `, + imports: [ + FormsModule, + TuiButton, + TuiTextfield, + TuiChevron, + TuiSelect, + TuiDataListWrapper, + i18nPipe, + ], +}) +export class KeyboardSelectComponent { + private readonly context = + injectContext< + TuiDialogContext< + Keyboard | null, + { keyboards: Keyboard[]; currentLayout: KeyboardLayout | null } + > + >() + + protected readonly mobile = inject(TUI_IS_MOBILE) + readonly keyboards = this.context.data.keyboards + readonly initialLayout = this.context.data.currentLayout + selected = + this.keyboards.find(kb => kb.layout === this.initialLayout) || + this.keyboards[0]! + + readonly stringify = (kb: Keyboard) => kb.name + + cancel() { + this.context.completeWith(null) + } + + confirm() { + this.context.completeWith(this.selected) + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts index c8348945a..0a87d2faf 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts @@ -12,6 +12,7 @@ import { DialogService, i18nKey, i18nPipe, + LocalizePipe, MarkdownPipe, SafeLinksDirective, } from '@start9labs/shared' @@ -138,7 +139,7 @@ import UpdatesComponent from './updates.component'

@@ -237,6 +238,7 @@ import UpdatesComponent from './updates.component' TuiProgressCircle, TuiTitle, TuiFade, + LocalizePipe, MarkdownPipe, NgDompurifyPipe, SafeLinksDirective, diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index ccbadf7e5..4a30d43f6 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -410,7 +410,7 @@ export namespace Mock { docsUrl: 'https://bitcoin.org', releaseNotes: 'Even better support for Bitcoin and wallets!', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, @@ -452,7 +452,7 @@ export namespace Mock { docsUrl: 'https://bitcoinknots.org', releaseNotes: 'Even better support for Bitcoin and wallets!', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, @@ -504,7 +504,7 @@ export namespace Mock { docsUrl: 'https://bitcoin.org', releaseNotes: 'Even better support for Bitcoin and wallets!', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, @@ -546,7 +546,7 @@ export namespace Mock { docsUrl: 'https://bitcoinknots.org', releaseNotes: 'Even better support for Bitcoin and wallets!', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, @@ -600,7 +600,7 @@ export namespace Mock { docsUrl: 'https://lightning.engineering/', releaseNotes: 'Upstream release to 0.17.5', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: LND_ICON, sourceVersion: null, @@ -655,7 +655,7 @@ export namespace Mock { docsUrl: 'https://lightning.engineering/', releaseNotes: 'Upstream release to 0.17.4', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: LND_ICON, sourceVersion: null, @@ -714,7 +714,7 @@ export namespace Mock { docsUrl: 'https://bitcoin.org', releaseNotes: 'Even better support for Bitcoin and wallets!', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, @@ -756,7 +756,7 @@ export namespace Mock { docsUrl: 'https://bitcoinknots.org', releaseNotes: 'Even better support for Bitcoin and wallets!', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, @@ -808,7 +808,7 @@ export namespace Mock { docsUrl: 'https://lightning.engineering/', releaseNotes: 'Upstream release and minor fixes.', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: LND_ICON, sourceVersion: null, @@ -863,7 +863,7 @@ export namespace Mock { marketingSite: '', releaseNotes: 'Upstream release and minor fixes.', osVersion: '0.3.6', - sdkVersion: '0.4.0-beta.47', + sdkVersion: '0.4.0-beta.48', gitHash: 'fakehash', icon: PROXY_ICON, sourceVersion: null, diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index f0798b49a..565bca8e4 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -1,6 +1,12 @@ import { Dump } from 'patch-db-client' import { DataModel } from 'src/app/services/patch-db/data-model' -import { StartOSDiskInfo, FetchLogsReq, FetchLogsRes } from '@start9labs/shared' +import { + FetchLogsReq, + FetchLogsRes, + FullKeyboard, + SetLanguageParams, + StartOSDiskInfo, +} from '@start9labs/shared' import { IST, T } from '@start9labs/start-sdk' import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { @@ -120,6 +126,12 @@ export namespace RR { } // net.tor.reset export type ResetTorRes = null + export type SetKeyboardReq = FullKeyboard // server.set-keyboard + export type SetKeyboardRes = null + + export type SetLanguageReq = SetLanguageParams // server.set-language + export type SetLanguageRes = null + // smtp export type SetSMTPReq = T.SmtpValue // server.set-smtp diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index 25b1ebfb5..41d918ef0 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -117,6 +117,10 @@ export abstract class ApiService { abstract toggleKiosk(enable: boolean): Promise + abstract setKeyboard(params: RR.SetKeyboardReq): Promise + + abstract setLanguage(params: RR.SetLanguageReq): Promise + abstract setDns(params: RR.SetDnsReq): Promise abstract queryDns(params: RR.QueryDnsReq): Promise diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index bf4ba6097..47890f047 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -256,6 +256,14 @@ export class LiveApiService extends ApiService { }) } + async setKeyboard(params: RR.SetKeyboardReq): Promise { + return this.rpcRequest({ method: 'server.set-keyboard', params }) + } + + async setLanguage(params: RR.SetLanguageReq): Promise { + return this.rpcRequest({ method: 'server.set-language', params }) + } + async setDns(params: RR.SetDnsReq): Promise { return this.rpcRequest({ method: 'net.dns.set-static', diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 5d243463c..c2f8d953b 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -22,7 +22,6 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs' import { mockPatchData } from './mock-patch' import { AuthService } from '../auth.service' import { T } from '@start9labs/start-sdk' -import { MarketplacePkg } from '@start9labs/marketplace' import { WebSocketSubject } from 'rxjs/webSocket' import { toAuthorityUrl } from 'src/app/utils/acme' @@ -454,6 +453,36 @@ export class MockApiService extends ApiService { return null } + async setKeyboard(params: RR.SetKeyboardReq): Promise { + await pauseFor(1000) + + const patch = [ + { + op: PatchOp.REPLACE, + path: '/serverInfo/keyboard', + value: params, + }, + ] + this.mockRevision(patch) + + return null + } + + async setLanguage(params: RR.SetLanguageReq): Promise { + await pauseFor(1000) + + const patch = [ + { + op: PatchOp.REPLACE, + path: '/serverInfo/language', + value: params.language, + }, + ] + this.mockRevision(patch) + + return null + } + async setDns(params: RR.SetDnsReq): Promise { await pauseFor(2000) diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 6eb52aa63..b7da8d5b9 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -12,7 +12,6 @@ export const mockPatchData: DataModel = { }, startosRegistry: 'https://registry.start9.com/', snakeHighScore: 0, - language: 'english', }, serverInfo: { arch: 'x86_64', @@ -220,6 +219,15 @@ export const mockPatchData: DataModel = { ram: 8 * 1024 * 1024 * 1024, devices: [], kiosk: true, + language: 'en_US', + keyboard: { + layout: 'us', + keymap: 'us', + model: null, + variant: null, + options: [], + }, + // keyboard: null, }, packageData: { lnd: { diff --git a/web/projects/ui/src/app/services/patch-db/data-model.ts b/web/projects/ui/src/app/services/patch-db/data-model.ts index 7e08174b2..30df37bec 100644 --- a/web/projects/ui/src/app/services/patch-db/data-model.ts +++ b/web/projects/ui/src/app/services/patch-db/data-model.ts @@ -1,8 +1,12 @@ -import { Languages } from '@start9labs/shared' +import { FullKeyboard, Languages } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' -export type DataModel = T.Public & { +export type DataModel = { ui: UIData + serverInfo: T.ServerInfo & { + language: Languages + keyboard: FullKeyboard | null + } packageData: AllPackageData } @@ -11,7 +15,6 @@ export type UIData = { registries: Record snakeHighScore: number startosRegistry: string - language: Languages } export type PackageDataEntry =